Przeglądaj źródła

Add ability to download and stream Coriolis Logs

Sergiu Miclea 6 lat temu
rodzic
commit
30dc46fb09
31 zmienionych plików z 1188 dodań i 16 usunięć
  1. 1 0
      package.json
  2. 12 1
      src/components/App.jsx
  3. 1 1
      src/components/atoms/TextInput/TextInput.jsx
  4. 10 1
      src/components/molecules/DatetimePicker/DatetimePicker.jsx
  5. 3 2
      src/components/molecules/DatetimePicker/style.js
  6. 7 5
      src/components/molecules/DropdownLink/DropdownLink.jsx
  7. 91 0
      src/components/molecules/TabNavigation/TabNavigation.jsx
  8. 6 0
      src/components/molecules/TabNavigation/package.json
  9. 34 0
      src/components/molecules/TabNavigation/story.jsx
  10. 1 1
      src/components/organisms/DetailsPageHeader/DetailsPageHeader.jsx
  11. 1 0
      src/components/organisms/MainList/MainList.jsx
  12. 5 0
      src/components/organisms/Navigation/Navigation.jsx
  13. 24 0
      src/components/organisms/Navigation/images/logs-menu.svg
  14. 1 1
      src/components/organisms/PageHeader/PageHeader.jsx
  15. 1 1
      src/components/pages/AboutModal/AboutModal.jsx
  16. 128 0
      src/components/pages/LogStreamPage/LogStreamPage.jsx
  17. 6 0
      src/components/pages/LogStreamPage/package.json
  18. 179 0
      src/components/pages/LogsPage/DownloadsContent.jsx
  19. 211 0
      src/components/pages/LogsPage/LogsPage.jsx
  20. 235 0
      src/components/pages/LogsPage/StreamText.jsx
  21. 12 0
      src/components/pages/LogsPage/images/download.svg
  22. 21 0
      src/components/pages/LogsPage/images/expand.svg
  23. 24 0
      src/components/pages/LogsPage/images/log.svg
  24. 6 0
      src/components/pages/LogsPage/package.json
  25. 8 2
      src/components/templates/DetailsTemplate/DetailsTemplate.jsx
  26. 1 0
      src/constants.js
  27. 117 0
      src/stores/LogStore.js
  28. 19 0
      src/types/Log.js
  29. 4 0
      src/utils/DateUtils.js
  30. 11 0
      src/utils/DomUtils.js
  31. 8 1
      yarn.lock

+ 1 - 0
package.json

@@ -59,6 +59,7 @@
   "dependencies": {
     "@webpack-blocks/webpack2": "^0.4.0",
     "adm-zip": "^0.4.13",
+    "ansi-to-html": "^0.6.12",
     "autobind-decorator": "^2.1.0",
     "axios": "^0.18.0",
     "babel-core": "^6.26.0",

+ 12 - 1
src/components/App.jsx

@@ -37,6 +37,8 @@ import UserDetailsPage from './pages/UserDetailsPage'
 import ProjectsPage from './pages/ProjectsPage'
 import ProjectDetailsPage from './pages/ProjectDetailsPage'
 import DashboardPage from './pages/DashboardPage'
+import LogsPage from './pages/LogsPage'
+import LogStreamPage from './pages/LogStreamPage'
 
 import Tooltip from './atoms/Tooltip/Tooltip'
 
@@ -49,6 +51,9 @@ injectGlobal`
   ${Fonts}
   html, body, main {
     height: 100%;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
   }
   body {
     margin: 0;
@@ -62,6 +67,9 @@ injectGlobal`
 `
 const Wrapper = styled.div`
   height: 100%;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
   > div:first-child {
     height: 100%;
   }
@@ -88,7 +96,8 @@ class App extends React.Component<{}, State> {
     }
 
     let renderOptionalPage = (name: string, component: any, path?: string, exact?: boolean) => {
-      const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : true
+      const isAdmin = userStore.loggedUser && typeof userStore.loggedUser.isAdmin === 'boolean'
+        ? userStore.loggedUser.isAdmin : true
       let isDisabled = configLoader.config.disabledPages.find(p => p === name)
       if (navigationMenu.find(m => m.value === name && !isDisabled && (!m.requiresAdmin || isAdmin))) {
         return <Route path={`${path || `/${name}`}`} component={component} exact={exact} />
@@ -117,6 +126,8 @@ class App extends React.Component<{}, State> {
           {renderOptionalPage('users', UserDetailsPage, '/user/:id', true)}
           {renderOptionalPage('projects', ProjectsPage)}
           {renderOptionalPage('projects', ProjectDetailsPage, '/project/:id', true)}
+          {renderOptionalPage('logging', LogsPage)}
+          <Route path="/streamlog" component={LogStreamPage} />
           <Route component={NotFoundPage} />
         </Switch>
         <Notifications />

+ 1 - 1
src/components/atoms/TextInput/TextInput.jsx

@@ -78,7 +78,7 @@ const Input = styled.input`
     color: ${Palette.grayscale[3]};
   }
 `
-const Close = styled.div`
+export const Close = styled.div`
   display: ${props => props.show ? 'block' : 'none'};
   width: 16px;
   height: 16px;

+ 10 - 1
src/components/molecules/DatetimePicker/DatetimePicker.jsx

@@ -57,6 +57,7 @@ type Props = {
   isValidDate?: (currentDate: Date, selectedDate: Date) => boolean,
   timezone: 'utc' | 'local',
   useBold?: boolean,
+  dispatchChangeContinously?: boolean,
 }
 type State = {
   showPicker: boolean,
@@ -88,6 +89,10 @@ class DatetimePicker extends React.Component<Props, State> {
     }
   }
 
+  componentWillReceiveProps(newProps: Props) {
+    this.setState({ date: newProps.value && moment(newProps.value) })
+  }
+
   componentDidUpdate() {
     this.setPortalPosition()
   }
@@ -159,7 +164,11 @@ class DatetimePicker extends React.Component<Props, State> {
       date = DateUtils.getLocalTime(newDate)
     }
 
-    this.setState({ date })
+    this.setState({ date }, () => {
+      if (this.props.dispatchChangeContinously) {
+        this.dispatchChange()
+      }
+    })
   }
 
   dispatchChange() {

+ 3 - 2
src/components/molecules/DatetimePicker/style.js

@@ -43,7 +43,7 @@ const style = css`
     table {
       display: flex;
       flex-direction: column;
-      
+
       thead {
         display: flex;
         flex-direction: column;
@@ -163,7 +163,7 @@ const style = css`
       > div {
         margin-right: 0px;
 
-        &:last-child { 
+        &:last-child {
           margin-right: 0;
           margin-left: 6px;
         }
@@ -205,6 +205,7 @@ const style = css`
         height: 16px;
         background: url('${arrowImage}') center no-repeat;
         margin: auto;
+        user-select: none;
 
         &:first-child {
           transform: rotate(90deg);

+ 7 - 5
src/components/molecules/DropdownLink/DropdownLink.jsx

@@ -117,11 +117,11 @@ const EmptySearch = styled.div`
 
 type ItemType = {
   label: string,
-  value: string,
+  value: any,
   [string]: any,
 }
 type Props = {
-  selectedItem?: string,
+  selectedItem?: any,
   items: ItemType[],
   onChange?: (item: ItemType) => void,
   highlightedItem?: string,
@@ -212,8 +212,10 @@ class DropdownLink extends React.Component<Props, State> {
     let items = this.props.items
 
     return items.filter(item =>
-      item.value.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1 ||
-      item.label.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1
+      typeof item.value === 'string'
+        ? item.value.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1
+        : item.value === Number(this.state.searchText)
+        || item.label.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1
     )
   }
 
@@ -393,7 +395,7 @@ class DropdownLink extends React.Component<Props, State> {
       if (this.props.getLabel) {
         return this.props.getLabel()
       }
-      if (this.props.items && this.props.items.length && this.props.selectedItem) {
+      if (this.props.items && this.props.items.length && this.props.selectedItem != null) {
         let item = this.props.items.find(i => i.value === this.props.selectedItem)
         if (item && item.label) {
           return item.label

+ 91 - 0
src/components/molecules/TabNavigation/TabNavigation.jsx

@@ -0,0 +1,91 @@
+/*
+Copyright (C) 2019  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 styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+
+const Wrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  min-height: 0;
+`
+const Header = styled.div`
+  display: flex;
+  flex-shrink: 0;
+`
+const HeaderItem = styled.div`
+  display: flex;
+  color: ${props => props.selected ? Palette.primary : 'inherit'};
+  min-width: 96px;
+  justify-content: center;
+  border-bottom: 1px solid ${props => props.selected ? Palette.primary : 'transparent'};
+  padding: 4px 4px 8px 4px;
+  cursor: pointer;
+  margin-right: 16px;
+  &:hover {
+    border-bottom: 1px solid ${props => props.selected ? Palette.primary : '#e6e7ea'};
+  }
+  transition: all ${StyleProps.animations.swift};
+`
+const Body = styled.div`
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  min-height: 0;
+`
+
+export type TabItem = {
+  label: string,
+  value: string,
+}
+
+type Props = {
+  tabItems: TabItem[],
+  selectedTabValue: string,
+  children: React.Node,
+  onChange: (tabValue: string) => void,
+}
+
+@observer
+class TabNavigation extends React.Component<Props> {
+  render() {
+    return (
+      <Wrapper>
+        <Header>
+          {this.props.tabItems.map(item => (
+            <HeaderItem
+              key={item.value}
+              selected={item.value === this.props.selectedTabValue}
+              onClick={() => { this.props.onChange(item.value) }}
+            >
+              {item.label}
+            </HeaderItem>
+          ))}
+        </Header>
+        <Body>
+          {this.props.children}
+        </Body>
+      </Wrapper>
+    )
+  }
+}
+
+export default TabNavigation

+ 6 - 0
src/components/molecules/TabNavigation/package.json

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

+ 34 - 0
src/components/molecules/TabNavigation/story.jsx

@@ -0,0 +1,34 @@
+/*
+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 TabNavigation from '.'
+
+storiesOf('TabNavigation', module)
+  .add('default', () => (
+    <TabNavigation
+      tabItems={[
+        { label: 'Downloads', value: 'download' },
+        { label: 'Stream', value: 'stream' },
+      ]}
+      selectedTabValue="download"
+      onChange={() => { }}
+    >
+      Content
+    </TabNavigation>
+  ))
+

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

@@ -107,7 +107,7 @@ class DetailsPageHeader extends React.Component<Props, State> {
     }
 
     await notificationStore.loadData(showLoading)
-    this.pollTimeout = setTimeout(() => { this.pollData() }, 5000)
+    this.pollTimeout = setTimeout(() => { this.pollData() }, 15000)
   }
 
   render() {

+ 1 - 0
src/components/organisms/MainList/MainList.jsx

@@ -70,6 +70,7 @@ const EmptyListImage = styled.div`
   width: 96px;
   height: 96px;
   background: url('${props => props.source}') center no-repeat;
+  background-size: contain;
   margin-bottom: 46px;
 `
 const EmptyListMessage = styled.div`

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

@@ -37,6 +37,7 @@ import endpointImage from './images/endpoint-menu.svg'
 import planningImage from './images/planning-menu.svg'
 import projectImage from './images/project-menu.svg'
 import userImage from './images/user-menu.svg'
+import logsImage from './images/logs-menu.svg'
 
 const MENU_MAX_WIDTH_TOGGLE = 1350
 
@@ -184,6 +185,7 @@ const MenuImage = styled.div`
   width: 24px;
   height: 24px;
   background: url('${props => props.image}') center no-repeat;
+  background-size: contain;
 `
 
 const Footer = styled.div`
@@ -383,6 +385,9 @@ class Navigation extends React.Component<Props> {
               case 'users':
                 menuImage = userImage
                 break
+              case 'logging':
+                menuImage = logsImage
+                break
               default:
             }
 

+ 24 - 0
src/components/organisms/Navigation/images/logs-menu.svg

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="548.291px" height="548.291px" viewBox="0 0 548.291 548.291" style="enable-background:new 0 0 548.291 548.291;"
+	 xml:space="preserve">
+<g>
+	<path fill="white" d="M486.201,196.124h-13.166V132.59c0-0.396-0.062-0.795-0.115-1.196c-0.021-2.523-0.825-5-2.552-6.963L364.657,3.677
+		c-0.033-0.031-0.064-0.042-0.085-0.073c-0.63-0.707-1.364-1.292-2.143-1.795c-0.229-0.157-0.461-0.286-0.702-0.421
+		c-0.672-0.366-1.387-0.671-2.121-0.892c-0.2-0.055-0.379-0.136-0.577-0.188C358.23,0.118,357.401,0,356.562,0H96.757
+		C84.894,0,75.256,9.651,75.256,21.502v174.613H62.092c-16.971,0-30.732,13.756-30.732,30.733v159.812
+		c0,16.968,13.761,30.731,30.732,30.731h13.164V526.79c0,11.854,9.638,21.501,21.501,21.501h354.776
+		c11.853,0,21.501-9.647,21.501-21.501V417.392h13.166c16.966,0,30.729-13.764,30.729-30.731V226.854
+		C516.93,209.872,503.167,196.124,486.201,196.124z M96.757,21.502h249.054v110.009c0,5.939,4.817,10.75,10.751,10.75h94.972v53.861
+		H96.757V21.502z M317.816,303.427c0,47.77-28.973,76.746-71.558,76.746c-43.234,0-68.531-32.641-68.531-74.152
+		c0-43.679,27.887-76.319,70.906-76.319C293.389,229.702,317.816,263.213,317.816,303.427z M82.153,377.79V232.085h33.073v118.039
+		h57.944v27.66H82.153V377.79z M451.534,520.962H96.757v-103.57h354.776V520.962z M461.176,371.092
+		c-10.162,3.454-29.402,8.209-48.641,8.209c-26.589,0-45.833-6.698-59.24-19.664c-13.396-12.535-20.75-31.568-20.529-52.967
+		c0.214-48.436,35.448-76.108,83.229-76.108c18.814,0,33.292,3.688,40.431,7.139l-6.92,26.37
+		c-7.999-3.457-17.942-6.268-33.942-6.268c-27.449,0-48.209,15.567-48.209,47.134c0,30.049,18.807,47.771,45.831,47.771
+		c7.564,0,13.623-0.852,16.21-2.152v-30.488h-22.478v-25.723h54.258V371.092L461.176,371.092z"/>
+	<path fill="white" d="M212.533,305.37c0,28.535,13.407,48.64,35.452,48.64c22.268,0,35.021-21.186,35.021-49.5
+		c0-26.153-12.539-48.655-35.237-48.655C225.504,255.854,212.533,277.047,212.533,305.37z"/>
+</g>
+</svg>

+ 1 - 1
src/components/organisms/PageHeader/PageHeader.jsx

@@ -256,7 +256,7 @@ class PageHeader extends React.Component<Props, State> {
     }
 
     await notificationStore.loadData(showLoading)
-    this.pollTimeout = setTimeout(() => { this.pollData() }, 5000)
+    this.pollTimeout = setTimeout(() => { this.pollData() }, 15000)
   }
 
   render() {

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

@@ -127,7 +127,7 @@ class AboutModal extends React.Component<Props, State> {
                   <TextLine>
                     <span>Version {licenceStore.version || '-'}</span>
                     <span>|</span>
-                    <LinkMock onClick={() => { logger.download() }} >Download Log</LinkMock>
+                    <LinkMock onClick={() => { logger.download() }} >Download API Log</LinkMock>
                   </TextLine>
                   <TextLine>
                     © {new Date().getFullYear()} Cloudbase Solutions. All Rights Reserved.

+ 128 - 0
src/components/pages/LogStreamPage/LogStreamPage.jsx

@@ -0,0 +1,128 @@
+/*
+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 StreamText from '../LogsPage/StreamText'
+
+import logStore from '../../../stores/LogStore'
+
+const Wrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  padding: 16px;
+`
+
+type State = {
+  logName: string,
+  severityLevel: number,
+  isStreaming: boolean,
+}
+@observer
+class LogStreamPage extends React.Component<{}, State> {
+  state = {
+    logName: 'All Logs',
+    severityLevel: 6,
+    isStreaming: true,
+  }
+
+  componentWillMount() {
+    let logName = 'All Logs'
+    let severityLevel = 6
+
+    let logNameExp = /logName=(.*?)(?:&|$)/
+    let severityExp = /severity=(.*?)(?:&|$)/
+    let logNameMatch = logNameExp.exec(window.location.hash) || logNameExp.exec(window.location.search)
+    let severityMatch = severityExp.exec(window.location.hash) || severityExp.exec(window.location.search)
+
+    if (logNameMatch) {
+      logName = decodeURIComponent(logNameMatch[1])
+    }
+    if (severityMatch) {
+      severityLevel = Number(severityMatch[1])
+    }
+
+    this.setState({ logName, severityLevel })
+
+    logStore.getLogs({ showLoading: logStore.logs.length === 0 })
+    logStore.startLiveFeed({ logName, severityLevel })
+  }
+
+  componentDidMount() {
+    document.title = 'Coriolis Logs Stream'
+  }
+
+  componentWillUnmount() {
+    logStore.stopLiveFeed()
+    logStore.clearLiveFeed()
+  }
+
+  handleClearClick() {
+    logStore.clearLiveFeed()
+  }
+
+  handleStopPlayClick() {
+    if (this.state.isStreaming) {
+      logStore.stopLiveFeed()
+    } else {
+      logStore.startLiveFeed({ logName: this.state.logName, severityLevel: this.state.severityLevel })
+    }
+
+    this.setState({ isStreaming: !this.state.isStreaming })
+  }
+
+  handleLogNameChange(logName: string) {
+    this.setState({ logName })
+    logStore.stopLiveFeed()
+    logStore.startLiveFeed({
+      logName,
+      severityLevel: this.state.severityLevel,
+    })
+  }
+
+  handleSeverityLevelChange(severityLevel: number) {
+    this.setState({ severityLevel })
+    logStore.stopLiveFeed()
+    logStore.startLiveFeed({
+      logName: this.state.logName,
+      severityLevel,
+    })
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <StreamText
+          logName={this.state.logName}
+          logs={[{ log_name: 'All Logs' }, ...logStore.logs]}
+          severityLevel={this.state.severityLevel}
+          liveFeed={logStore.liveFeed}
+          onLogNameChange={logName => { this.handleLogNameChange(logName) }}
+          onSeverityLevelChange={level => { this.handleSeverityLevelChange(level) }}
+          disableOpenInNewWindow
+          onClearClick={() => { this.handleClearClick() }}
+          stopPlayLabel={this.state.isStreaming ? 'Stop' : 'Start'}
+          onStopPlayClick={() => { this.handleStopPlayClick() }}
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default LogStreamPage

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

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

+ 179 - 0
src/components/pages/LogsPage/DownloadsContent.jsx

@@ -0,0 +1,179 @@
+/*
+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 moment from 'moment'
+
+import type { Log as LogType } from '../../../types/Log'
+
+import { Close } from '../../atoms/TextInput'
+import DatetimePicker from '../../molecules/DatetimePicker'
+
+import StyleProps from '../../styleUtils/StyleProps'
+
+import downloadImage from './images/download.svg'
+
+const Wrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
+const Info = styled.div``
+const Dates = styled.div`
+  display: flex;
+  align-items: flex-end;
+  flex-shrink: 0;
+  margin-left: -48px;
+  margin-top: 16px;
+`
+const DateWrapper = styled.div`
+  position: relative;
+  margin-left: 48px;
+`
+const DateLabel = styled.div`
+  font-weight: ${StyleProps.fontWeights.medium};
+`
+const DateInput = styled.div`
+  margin-top: 4px;
+  display: flex;
+  align-items: center;
+`
+const CloseButton = styled(Close)`
+  right: -24px;
+  top: 29px;
+`
+const Logs = styled.div`
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+  min-height: 0;
+  margin-top: 48px;
+`
+const Log = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 16px;
+  flex-shrink: 0;
+  :first-child {
+    margin-top: 0;
+  }
+`
+const LogName = styled.div``
+const LogDownload = styled.div`
+  ${StyleProps.exactSize('16px')}
+  background: url('${downloadImage}') center no-repeat;
+  background-size: contain;
+  cursor: pointer;
+  margin-right: 16px;
+`
+
+type State = {
+  startDate: ?Date,
+  endDate: ?Date,
+}
+type Props = {
+  logs: LogType[],
+  onDownloadClick: (logName: string, startDate: ?Date, endDate: ?Date) => void,
+}
+@observer
+class DownloadsContent extends React.Component<Props, State> {
+  state = {
+    startDate: null,
+    endDate: null,
+  }
+
+  handleStartDateChange(startDate: Date) {
+    this.setState({ startDate })
+  }
+
+  handleEndDateChange(endDate: Date) {
+    this.setState({ endDate })
+  }
+
+  renderDates() {
+    return (
+      <Dates>
+        <DateWrapper>
+          <DateLabel>Start Date</DateLabel>
+          <DateInput>
+            <DatetimePicker
+              value={this.state.startDate}
+              onChange={date => { this.handleStartDateChange(date) }}
+              timezone="utc"
+              isValidDate={date => moment(date).isBefore(moment())}
+              dispatchChangeContinously
+            />
+            <CloseButton
+              show={this.state.startDate}
+              onClick={() => { this.setState({ startDate: null }) }}
+            />
+          </DateInput>
+        </DateWrapper>
+        <DateWrapper>
+          <DateLabel>End Date</DateLabel>
+          <DateInput>
+            <DatetimePicker
+              value={this.state.endDate}
+              onChange={date => { this.handleEndDateChange(date) }}
+              timezone="utc"
+              isValidDate={date => this.state.startDate ? moment(date).isBefore(moment())
+                && moment(date).isAfter(moment(this.state.startDate).subtract(1, 'day'))
+                : moment(date).isBefore(moment())}
+              dispatchChangeContinously
+            />
+            <CloseButton
+              show={this.state.endDate}
+              onClick={() => { this.setState({ endDate: null }) }}
+            />
+          </DateInput>
+        </DateWrapper>
+      </Dates>
+    )
+  }
+
+  renderLogs() {
+    return (
+      <Logs>
+        {this.props.logs.map(log => (
+          <Log key={log.log_name}>
+            <LogDownload
+              onClick={() => {
+                this.props.onDownloadClick(log.log_name, this.state.startDate, this.state.endDate)
+              }}
+            />
+            <LogName>{log.log_name}</LogName>
+          </Log>
+        ))}
+      </Logs>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <Info>
+          Optional time range for log download:
+        </Info>
+        {this.renderDates()}
+        {this.renderLogs()}
+      </Wrapper>
+    )
+  }
+}
+
+export default DownloadsContent

+ 211 - 0
src/components/pages/LogsPage/LogsPage.jsx

@@ -0,0 +1,211 @@
+/*
+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 MainTemplate from '../../templates/MainTemplate/MainTemplate'
+import Navigation from '../../organisms/Navigation/Navigation'
+import PageHeader from '../../organisms/PageHeader/PageHeader'
+import TabNavigation from '../../molecules/TabNavigation'
+
+import DownloadContent from './DownloadsContent'
+import StreamText from './StreamText'
+
+import logStore from '../../../stores/LogStore'
+import notificationStore from '../../../stores/NotificationStore'
+import projectStore from '../../../stores/ProjectStore'
+
+const TAB_ITEMS = [
+  { label: 'Download', value: 'downloads' },
+  { label: 'Stream', value: 'stream' },
+]
+
+const Wrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+`
+const TabContent = styled.div`
+  display: flex;
+  flex-direction: column;
+  padding-top: 32px;
+  flex-grow: 1;
+  min-height: 0;
+`
+
+type State = {
+  selectedTab: string,
+  streamLogName: string,
+  streamSeverityLevel: number,
+  isStreaming: boolean,
+}
+@observer
+class LogsPage extends React.Component<{}, State> {
+  state = {
+    selectedTab: 'downloads',
+    streamLogName: 'All Logs',
+    streamSeverityLevel: 6,
+    isStreaming: true,
+  }
+
+  componentWillMount() {
+    projectStore.getProjects()
+    logStore.getLogs({ showLoading: logStore.logs.length === 0 })
+  }
+
+  componentDidMount() {
+    document.title = 'Coriolis Logs'
+  }
+
+  componentWillUnmount() {
+    logStore.stopLiveFeed()
+    logStore.clearLiveFeed()
+  }
+
+  handleProjectChange() {
+    logStore.getLogs({ showLoading: true })
+    logStore.stopLiveFeed()
+    logStore.clearLiveFeed()
+    if (this.state.isStreaming) {
+      logStore.startLiveFeed({
+        logName: this.state.streamLogName,
+        severityLevel: this.state.streamSeverityLevel,
+      })
+    }
+  }
+
+  handleClearClick() {
+    logStore.clearLiveFeed()
+  }
+
+  handleStopPlayClick() {
+    if (this.state.isStreaming) {
+      logStore.stopLiveFeed()
+    } else {
+      logStore.startLiveFeed({
+        logName: this.state.streamLogName,
+        severityLevel: this.state.streamSeverityLevel,
+      })
+    }
+
+    this.setState({ isStreaming: !this.state.isStreaming })
+  }
+
+  handleTabChange(selectedTab: string) {
+    switch (selectedTab) {
+      case 'downloads':
+        logStore.getLogs()
+        logStore.stopLiveFeed()
+        break
+      case 'stream':
+        logStore.startLiveFeed({
+          logName: this.state.streamLogName,
+          severityLevel: this.state.streamSeverityLevel,
+        })
+        break
+      default:
+        break
+    }
+    this.setState({ selectedTab })
+  }
+
+  handleDownloadClick(logName: string, startDate: ?Date, endDate: ?Date) {
+    if (startDate && endDate && endDate.getTime() < startDate.getTime()) {
+      notificationStore.alert('End time must be greater than start time', 'error')
+      return
+    }
+    logStore.download(logName, startDate, endDate)
+  }
+
+  handleStreamLogNameChange(streamLogName: string) {
+    this.setState({ streamLogName })
+    logStore.stopLiveFeed()
+    logStore.startLiveFeed({
+      logName: streamLogName,
+      severityLevel: this.state.streamSeverityLevel,
+    })
+  }
+
+  handleSeverityLevelChange(streamSeverityLevel: number) {
+    this.setState({ streamSeverityLevel })
+    logStore.stopLiveFeed()
+    logStore.startLiveFeed({
+      logName: this.state.streamLogName,
+      severityLevel: streamSeverityLevel,
+    })
+  }
+
+  renderTabContent() {
+    switch (this.state.selectedTab) {
+      case 'downloads':
+        return (
+          <TabContent>
+            <DownloadContent
+              logs={logStore.logs}
+              onDownloadClick={(l, s, e) => { this.handleDownloadClick(l, s, e) }}
+            />
+          </TabContent>
+        )
+      case 'stream':
+        return (
+          <TabContent>
+            <StreamText
+              logName={this.state.streamLogName}
+              logs={[{ log_name: 'All Logs' }, ...logStore.logs]}
+              severityLevel={this.state.streamSeverityLevel}
+              liveFeed={logStore.liveFeed}
+              onLogNameChange={logName => { this.handleStreamLogNameChange(logName) }}
+              onSeverityLevelChange={level => { this.handleSeverityLevelChange(level) }}
+              onClearClick={() => { this.handleClearClick() }}
+              stopPlayLabel={this.state.isStreaming ? 'Stop' : 'Start'}
+              onStopPlayClick={() => { this.handleStopPlayClick() }}
+            />
+          </TabContent>
+        )
+      default:
+        return null
+    }
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <MainTemplate
+          navigationComponent={<Navigation currentPage="logging" />}
+          listNoMargin
+          listComponent={(
+            <TabNavigation
+              tabItems={TAB_ITEMS}
+              onChange={value => { this.handleTabChange(value) }}
+              selectedTabValue={this.state.selectedTab}
+            >
+              {this.renderTabContent()}
+            </TabNavigation>
+          )}
+          headerComponent={
+            <PageHeader
+              title="Coriolis Logs"
+              onProjectChange={() => { this.handleProjectChange() }}
+            />
+          }
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default LogsPage

+ 235 - 0
src/components/pages/LogsPage/StreamText.jsx

@@ -0,0 +1,235 @@
+/*
+Copyright (C) 2019  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, { css, injectGlobal } from 'styled-components'
+import { observer } from 'mobx-react'
+import AnsiToHtml from 'ansi-to-html'
+
+import DropdownLink from '../../molecules/DropdownLink'
+import Checkbox from '../../atoms/Checkbox'
+
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+
+import type { Log } from '../../../types/Log'
+
+import expandImage from './images/expand.svg'
+
+const Wrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  flex-grow: 1;
+`
+const Header = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  flex-shrink: 0;
+  margin-bottom: 8px;
+  align-items: center;
+`
+const DropdownLinkStyled = styled(DropdownLink)`
+  margin-left: 16px;
+`
+const OpenInNewWindow = styled.a`
+  ${StyleProps.exactSize('14px')}
+  background: url('${expandImage}') center no-repeat;
+  background-size: contain;
+  cursor: pointer;
+  margin-left: 24px;
+`
+const Content = styled.div`
+  padding: 8px;
+  border-top-left-radius: ${StyleProps.borderRadius};
+  border-top-right-radius: ${StyleProps.borderRadius};
+  border: 1px solid #DCE1EB;
+  background: #F5F6FA;
+  font-family: 'Courier New', Courier, monospace;
+  overflow: auto;
+  flex-grow: 1;
+`
+const Footer = styled.div`
+  padding: 8px;
+  border-bottom-left-radius: ${StyleProps.borderRadius};
+  border-bottom-right-radius: ${StyleProps.borderRadius};
+  border: 1px solid #DCE1EB;
+  border-top: none;
+  display: flex;
+  flex-shrink: 0;
+`
+const AutoscrollLabel = styled.div`
+  margin-left: 8px;
+  cursor: pointer;
+`
+const streamTextPill = (color: string) => css`
+  color: ${color};
+`
+const FeedLine = styled.div`
+  word-break: break-word;
+`
+const TextButton = styled.div`
+  color: ${Palette.grayscale[3]};
+  cursor: pointer;
+  margin-right: 16px;
+  transition: all ${StyleProps.animations.swift};
+  &:hover {
+    color: ${Palette.primary};
+  }
+`
+const ERROR_COLOR = '#c80546'
+const INFO_COLOR = '#747474'
+const WARNING_COLOR = '#cb9002'
+injectGlobal`
+  .streamTextPill-EMERGENCY { ${streamTextPill(ERROR_COLOR)} }
+  .streamTextPill-ALERT { ${streamTextPill(ERROR_COLOR)} }
+  .streamTextPill-CRITICAL { ${streamTextPill(ERROR_COLOR)} }
+  .streamTextPill-ERROR { ${streamTextPill(ERROR_COLOR)} }
+  .streamTextPill-WARNING { ${streamTextPill(WARNING_COLOR)} }
+  .streamTextPill-NOTICE { ${streamTextPill(WARNING_COLOR)} }
+  .streamTextPill-INFO { ${streamTextPill(INFO_COLOR)} }
+  .streamTextPill-DEBUG { ${streamTextPill(WARNING_COLOR)} }
+`
+const SEVERITY_LEVELS = [
+  { value: 0, label: 'Emergency' },
+  { value: 1, label: 'Alert' },
+  { value: 2, label: 'Critical' },
+  { value: 3, label: 'Error' },
+  { value: 4, label: 'Warning' },
+  { value: 5, label: 'Notice' },
+  { value: 6, label: 'Informational' },
+  { value: 7, label: 'Debug' },
+]
+type Props = {
+  liveFeed: string[],
+  logs: Log[],
+  logName: string,
+  severityLevel: number,
+  onLogNameChange: (logName: string) => void,
+  onSeverityLevelChange: (level: number) => void,
+  disableOpenInNewWindow?: boolean,
+  stopPlayLabel: string,
+  onStopPlayClick: () => void,
+  onClearClick: () => void,
+}
+type State = {
+  autoscroll: boolean,
+}
+@observer
+class StreamText extends React.Component<Props, State> {
+  state = {
+    autoscroll: true,
+    logName: null,
+    severityLevel: 6,
+  }
+  ansiConverter: any
+  contentRef: HTMLElement
+
+  componentWillMount() {
+    this.ansiConverter = new AnsiToHtml()
+  }
+
+  componentDidMount() {
+    this.scrollToBottom()
+  }
+
+
+  componentDidUpdate(prevProps: Props) {
+    let firstFeedOldKey = prevProps.liveFeed.length && prevProps.liveFeed[0].substr(0, 23)
+    let fristFeedNewKey = this.props.liveFeed.length && this.props.liveFeed[0].substr(0, 23)
+    let lastFeedOldKey = prevProps.liveFeed.length && prevProps.liveFeed[prevProps.liveFeed.length - 1].substr(0, 23)
+    let lastFeedNewKey = this.props.liveFeed.length && this.props.liveFeed[this.props.liveFeed.length - 1].substr(0, 23)
+
+    if ((firstFeedOldKey !== fristFeedNewKey || lastFeedOldKey !== lastFeedNewKey || prevProps.liveFeed.length !== this.props.liveFeed.length)
+      && this.state.autoscroll) {
+      this.scrollToBottom()
+    }
+  }
+
+  scrollToBottom() {
+    this.contentRef.scrollTop = this.contentRef.scrollHeight
+  }
+
+  handleScroll() {
+    let scrollTopTotal = this.contentRef.scrollTop + this.contentRef.offsetHeight
+    let scrollTopMax = this.contentRef.scrollHeight
+    if (scrollTopTotal < scrollTopMax - 50) {
+      this.setState({ autoscroll: false })
+    }
+  }
+
+  handleAutoscrollChange(autoscroll: boolean) {
+    this.setState({ autoscroll })
+    if (autoscroll) {
+      this.scrollToBottom()
+    }
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <Header>
+          <TextButton onClick={this.props.onStopPlayClick}>{this.props.stopPlayLabel}</TextButton>
+          <TextButton onClick={this.props.onClearClick}>Clear</TextButton>
+          <DropdownLinkStyled
+            items={SEVERITY_LEVELS}
+            selectedItem={this.props.severityLevel}
+            onChange={item => { this.props.onSeverityLevelChange(Number(item.value)) }}
+          />
+          <DropdownLinkStyled
+            items={this.props.logs.map(l => ({ label: l.log_name, value: l.log_name }))}
+            selectedItem={this.props.logName}
+            onChange={item => { this.props.onLogNameChange(String(item.value)) }}
+          />
+          {!this.props.disableOpenInNewWindow ? (
+            <OpenInNewWindow
+              href={`streamlog?logName=${this.props.logName}&severity=${this.props.severityLevel}`}
+              target="_blank"
+            />
+          ) : null}
+        </Header>
+        <Content
+          innerRef={ref => { this.contentRef = ref }}
+          onScroll={() => { this.handleScroll() }}
+        >
+          {this.props.liveFeed.map(feed => {
+            let exp = /(^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) (.*?)\s/gm
+            feed = feed.replace(exp, '$1 <span class="streamTextPill-$2">$2</span> ').replace(/\n/g, '<br />')
+            return (
+              <FeedLine
+                key={feed}
+                dangerouslySetInnerHTML={{
+                  __html: this.ansiConverter.toHtml(feed).replace(/(?:<b>|<\/b>)/g, ''),
+                }}
+              />
+            )
+          })}
+        </Content>
+        <Footer>
+          <Checkbox checked={this.state.autoscroll} onChange={val => { this.handleAutoscrollChange(val) }} />
+          <AutoscrollLabel onClick={() => {
+            this.handleAutoscrollChange(!this.state.autoscroll)
+          }}
+          >
+            Autoscroll with output
+          </AutoscrollLabel>
+        </Footer>
+      </Wrapper>
+    )
+  }
+}
+
+export default StreamText

+ 12 - 0
src/components/pages/LogsPage/images/download.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 29.978 29.978" style="enable-background:new 0 0 29.978 29.978;" xml:space="preserve">
+<g>
+	<path fill="#0044CB" d="M25.462,19.105v6.848H4.515v-6.848H0.489v8.861c0,1.111,0.9,2.012,2.016,2.012h24.967c1.115,0,2.016-0.9,2.016-2.012
+		v-8.861H25.462z"/>
+	<path fill="#0044CB" d="M14.62,18.426l-5.764-6.965c0,0-0.877-0.828,0.074-0.828s3.248,0,3.248,0s0-0.557,0-1.416c0-2.449,0-6.906,0-8.723
+		c0,0-0.129-0.494,0.615-0.494c0.75,0,4.035,0,4.572,0c0.536,0,0.524,0.416,0.524,0.416c0,1.762,0,6.373,0,8.742
+		c0,0.768,0,1.266,0,1.266s1.842,0,2.998,0c1.154,0,0.285,0.867,0.285,0.867s-4.904,6.51-5.588,7.193
+		C15.092,18.979,14.62,18.426,14.62,18.426z"/>
+</g>
+</svg>

+ 21 - 0
src/components/pages/LogsPage/images/expand.svg

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1"  xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="258.008px" height="258.008px" viewBox="0 0 258.008 258.008" style="enable-background:new 0 0 258.008 258.008;"
+	 xml:space="preserve">
+<g>
+	<g>
+		<path fill="#0044CB" d="M125.609,122.35H10.049C4.5,122.35,0,126.85,0,132.399v115.56c0,5.549,4.5,10.048,10.049,10.048H125.61
+			c5.548,0,10.046-4.499,10.046-10.048v-115.56C135.656,126.85,131.158,122.35,125.609,122.35z M115.559,237.909H20.098v-95.463
+			h95.461V237.909z"/>
+		<path fill="#0044CB" d="M247.958,0.001H10.049C4.5,0.001,0,4.5,0,10.049v93.312c0,5.55,4.5,10.05,10.049,10.05c5.55,0,10.049-4.5,10.049-10.05
+			V20.098h217.812v217.812h-82.915c-5.55,0-10.05,4.5-10.05,10.05c0,5.549,4.5,10.048,10.05,10.048h92.964
+			c5.55,0,10.05-4.499,10.05-10.048V10.049C258.008,4.5,253.508,0.001,247.958,0.001z"/>
+		<path fill="#0044CB" d="M154.35,106.876c1.965,1.961,4.534,2.942,7.105,2.942c2.57,0,5.142-0.981,7.104-2.942l31.755-31.757V89.57
+			c0,5.549,4.499,10.047,10.05,10.047c5.549,0,10.048-4.498,10.048-10.047V53.054c0-0.365-0.068-0.713-0.107-1.068
+			c0.329-2.933-0.588-5.979-2.837-8.229c-2.146-2.148-5.023-3.079-7.831-2.873c-0.233-0.017-0.461-0.072-0.696-0.072h-36.513
+			c-5.551,0-10.051,4.5-10.051,10.05c0,5.549,4.5,10.049,10.051,10.049h13.679L154.35,92.665
+			C150.426,96.589,150.426,102.952,154.35,106.876z"/>
+	</g>
+</g>
+</svg>

+ 24 - 0
src/components/pages/LogsPage/images/log.svg

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="548.291px" height="548.291px" viewBox="0 0 548.291 548.291" style="enable-background:new 0 0 548.291 548.291;"
+	 xml:space="preserve">
+<g>
+	<path fill="#0044CB" d="M486.201,196.124h-13.166V132.59c0-0.396-0.062-0.795-0.115-1.196c-0.021-2.523-0.825-5-2.552-6.963L364.657,3.677
+		c-0.033-0.031-0.064-0.042-0.085-0.073c-0.63-0.707-1.364-1.292-2.143-1.795c-0.229-0.157-0.461-0.286-0.702-0.421
+		c-0.672-0.366-1.387-0.671-2.121-0.892c-0.2-0.055-0.379-0.136-0.577-0.188C358.23,0.118,357.401,0,356.562,0H96.757
+		C84.894,0,75.256,9.651,75.256,21.502v174.613H62.092c-16.971,0-30.732,13.756-30.732,30.733v159.812
+		c0,16.968,13.761,30.731,30.732,30.731h13.164V526.79c0,11.854,9.638,21.501,21.501,21.501h354.776
+		c11.853,0,21.501-9.647,21.501-21.501V417.392h13.166c16.966,0,30.729-13.764,30.729-30.731V226.854
+		C516.93,209.872,503.167,196.124,486.201,196.124z M96.757,21.502h249.054v110.009c0,5.939,4.817,10.75,10.751,10.75h94.972v53.861
+		H96.757V21.502z M317.816,303.427c0,47.77-28.973,76.746-71.558,76.746c-43.234,0-68.531-32.641-68.531-74.152
+		c0-43.679,27.887-76.319,70.906-76.319C293.389,229.702,317.816,263.213,317.816,303.427z M82.153,377.79V232.085h33.073v118.039
+		h57.944v27.66H82.153V377.79z M451.534,520.962H96.757v-103.57h354.776V520.962z M461.176,371.092
+		c-10.162,3.454-29.402,8.209-48.641,8.209c-26.589,0-45.833-6.698-59.24-19.664c-13.396-12.535-20.75-31.568-20.529-52.967
+		c0.214-48.436,35.448-76.108,83.229-76.108c18.814,0,33.292,3.688,40.431,7.139l-6.92,26.37
+		c-7.999-3.457-17.942-6.268-33.942-6.268c-27.449,0-48.209,15.567-48.209,47.134c0,30.049,18.807,47.771,45.831,47.771
+		c7.564,0,13.623-0.852,16.21-2.152v-30.488h-22.478v-25.723h54.258V371.092L461.176,371.092z"/>
+	<path fill="#0044CB" d="M212.533,305.37c0,28.535,13.407,48.64,35.452,48.64c22.268,0,35.021-21.186,35.021-49.5
+		c0-26.153-12.539-48.655-35.237-48.655C225.504,255.854,212.533,277.047,212.533,305.37z"/>
+</g>
+</svg>

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

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

+ 8 - 2
src/components/templates/DetailsTemplate/DetailsTemplate.jsx

@@ -19,23 +19,29 @@ import styled from 'styled-components'
 
 const Wrapper = styled.div`
   min-width: 1100px;
+  min-height: 0;
 `
 const PageHeader = styled.div``
 const ContentHeader = styled.div``
 const Content = styled.div`
   padding: 32px 0;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 `
 type Props = {
   pageHeaderComponent: React.Node,
   contentHeaderComponent: React.Node,
   contentComponent: React.Node,
+  style?: any,
+  contentStyle?: any,
 }
 const DetailsTemplate = (props: Props) => {
   return (
-    <Wrapper>
+    <Wrapper style={props.style}>
       <PageHeader>{props.pageHeaderComponent}</PageHeader>
       <ContentHeader>{props.contentHeaderComponent}</ContentHeader>
-      <Content>{props.contentComponent}</Content>
+      <Content style={props.contentStyle}>{props.contentComponent}</Content>
     </Wrapper>
   )
 }

+ 1 - 0
src/constants.js

@@ -42,6 +42,7 @@ export const navigationMenu = [
   // User management pages
   { label: 'Projects', value: 'projects', requiresAdmin: true },
   { label: 'Users', value: 'users', requiresAdmin: true },
+  { label: 'Logs', value: 'logging', requiresAdmin: true },
 ]
 
 // https://github.com/cloudbase/coriolis/blob/master/coriolis/constants.py

+ 117 - 0
src/stores/LogStore.js

@@ -0,0 +1,117 @@
+/*
+Copyright (C) 2019  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 { observable, runInAction, action } from 'mobx'
+import cookie from 'js-cookie'
+
+import type { Log } from '../types/Log'
+
+import { coriolisUrl } from '../constants'
+
+import notificationStore from '../stores/NotificationStore'
+
+import apiCaller from '../utils/ApiCaller'
+import DateUtils from '../utils/DateUtils'
+import DomUtils from '../utils/DomUtils'
+
+const BASE_URL = `${coriolisUrl}logs`
+
+const MAX_STREAM_LINES = 200
+class LogStore {
+  @observable logs: Log[] = []
+  @observable loading: boolean = false
+  @observable liveFeed: string[] = []
+
+  @action async getLogs(options?: { showLoading?: boolean }) {
+    if (options && options.showLoading) {
+      this.loading = true
+    }
+    try {
+      let response = await apiCaller.send({ url: BASE_URL })
+      runInAction(() => {
+        this.logs = response.data.logs
+        this.loading = false
+      })
+    } catch (ex) { throw ex } finally {
+      runInAction(() => {
+        this.loading = false
+      })
+    }
+  }
+
+  @action download(logName: string, startDate: ?Date, endDate: ?Date) {
+    let token = cookie.get('token') || 'null'
+    let url = `${BASE_URL}/${logName}?auth_type=keystone&auth_token=${token}`
+    if (startDate) {
+      url += `&start_date=${DateUtils.toUnix(startDate)}`
+    }
+    if (endDate) {
+      url += `&end_date=${DateUtils.toUnix(endDate)}`
+    }
+
+    DomUtils.executeDownloadLink(url)
+  }
+
+  socket: WebSocket
+  startLiveFeed(options: { logName: string, severityLevel: number }) {
+    let { logName, severityLevel } = options
+    let token = cookie.get('token') || 'null'
+    let wsUrl
+    if (coriolisUrl === '/') {
+      wsUrl = `wss://${window.location.host}/`
+    } else {
+      wsUrl = coriolisUrl.replace('https', 'wss')
+    }
+
+    let url = `${wsUrl}log-stream?auth_type=keystone`
+    url += `&auth_token=${token}&severity=${severityLevel}`
+
+    if (logName !== 'All Logs') {
+      url += `&app_name=${logName}`
+    }
+
+    this.socket = new WebSocket(url)
+    this.socket.onopen = () => { console.log('WS Log connection open') }
+    this.socket.onmessage = e => {
+      if (typeof e.data === 'string') {
+        this.addToLiveFeed(JSON.parse(e.data))
+      }
+    }
+    this.socket.onclose = () => { console.log('WS Log connection closed') }
+    this.socket.onerror = (e: any) => {
+      notificationStore.alert(`WebSocket error: ${e.message}`, 'error')
+    }
+  }
+
+  @action addToLiveFeed(feed: { message: string }) {
+    this.liveFeed = [...this.liveFeed, feed.message]
+    if (this.liveFeed.length > MAX_STREAM_LINES) {
+      this.liveFeed = this.liveFeed.filter((f, i) => i > this.liveFeed.length - MAX_STREAM_LINES)
+    }
+  }
+
+  @action clearLiveFeed() {
+    this.liveFeed = []
+  }
+
+  @action stopLiveFeed() {
+    if (this.socket) {
+      this.socket.close()
+    }
+  }
+}
+
+export default new LogStore()

+ 19 - 0
src/types/Log.js

@@ -0,0 +1,19 @@
+/*
+Copyright (C) 2019  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
+
+export type Log = {
+  log_name: string,
+}

+ 4 - 0
src/utils/DateUtils.js

@@ -48,6 +48,10 @@ class DateUtils {
         return `${number}th`
     }
   }
+
+  static toUnix(date: Date): number {
+    return parseInt((date.getTime() / 1000).toFixed(0), 10)
+  }
 }
 
 export default DateUtils

+ 11 - 0
src/utils/DomUtils.js

@@ -121,6 +121,17 @@ class DomUtils {
     downloadAnchorNode.click()
     downloadAnchorNode.remove()
   }
+
+  static executeDownloadLink(href: string) {
+    let downloadAnchorNode = document.createElement('a')
+    downloadAnchorNode.setAttribute('href', href)
+    if (document.body) {
+      document.body.appendChild(downloadAnchorNode) // required for firefox
+    }
+    downloadAnchorNode.click()
+    downloadAnchorNode.remove()
+  }
+
   static jsonSyntaxHighlight(json: any) {
     json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
     return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (match) => {

+ 8 - 1
yarn.lock

@@ -446,6 +446,13 @@ ansi-styles@^3.1.0, ansi-styles@^3.2.0, ansi-styles@^3.2.1:
   dependencies:
     color-convert "^1.9.0"
 
+ansi-to-html@^0.6.12:
+  version "0.6.12"
+  resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.12.tgz#9dcd1646f17770d02ec065615e97f979f4e313cb"
+  integrity sha512-qBkIqLW979675mP76yB7yVkzeAWtATegdnDQ0RA3CZzknx0yUlNxMSML4xFdBfTs2GWYFQ1FELfbGbVSPzJ+LA==
+  dependencies:
+    entities "^1.1.2"
+
 anymatch@^1.3.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
@@ -3386,7 +3393,7 @@ enhanced-resolve@^3.3.0, enhanced-resolve@^3.4.0:
     object-assign "^4.0.1"
     tapable "^0.2.7"
 
-entities@^1.1.1:
+entities@^1.1.1, entities@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
   integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==