summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'opendc-web/opendc-web-ui/src/components')
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/LoadingScreen.js11
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/MapConstants.js28
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/MapStageComponent.js103
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/controls/ExportCanvasComponent.js13
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.js11
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.sass9
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.js13
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.sass5
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/controls/ZoomControlComponent.js24
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/Backdrop.js8
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/GrayLayer.js17
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/HoverTile.js27
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/ImageComponent.js48
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/RackFillBar.js68
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/RoomTile.js20
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/TileObject.js25
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/TilePlusIcon.js44
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/elements/WallSegment.js32
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/groups/GridGroup.js34
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/groups/RackGroup.js25
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/groups/RoomGroup.js48
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/groups/TileGroup.js35
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/groups/TopologyGroup.js44
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/groups/WallGroup.js22
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/layers/HoverLayerComponent.js75
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/layers/MapLayerComponent.js17
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/layers/ObjectHoverLayerComponent.js11
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/map/layers/RoomHoverLayerComponent.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/results/PortfolioResultsComponent.js93
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.js53
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.sass50
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/project/PortfolioListComponent.js66
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/project/ProjectSidebarComponent.js15
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/project/ScenarioListComponent.js62
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/project/TopologyListComponent.js60
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/NameComponent.js13
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/TopologySidebarComponent.js31
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js13
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js26
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/BackToRackComponent.js10
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js10
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineNameComponent.js5
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js18
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitAddComponent.js35
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitComponent.js52
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitListComponent.js20
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitTabsComponent.js78
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/AddPrefabComponent.js10
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/BackToRoomComponent.js10
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/DeleteRackComponent.js10
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/EmptySlotComponent.js13
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineComponent.js43
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.js20
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.sass2
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackNameComponent.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.js25
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass11
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/BackToBuildingComponent.js10
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/DeleteRoomComponent.js10
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/EditRoomComponent.js22
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RackConstructionComponent.js27
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomNameComponent.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomSidebarComponent.js20
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/ContactSection.js54
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/ContactSection.sass15
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/ContentSection.js19
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/ContentSection.sass9
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/IntroSection.js40
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.js18
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.sass24
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/ModelingSection.js22
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.js24
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.sass5
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/SimulationSection.js22
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/StakeholderSection.js30
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/TeamSection.js53
-rw-r--r--opendc-web/opendc-web-ui/src/components/home/TechnologiesSection.js40
-rw-r--r--opendc-web/opendc-web-ui/src/components/modals/ConfirmationModal.js37
-rw-r--r--opendc-web/opendc-web-ui/src/components/modals/Modal.js53
-rw-r--r--opendc-web/opendc-web-ui/src/components/modals/TextInputModal.js54
-rw-r--r--opendc-web/opendc-web-ui/src/components/modals/custom-components/NewPortfolioModalComponent.js78
-rw-r--r--opendc-web/opendc-web-ui/src/components/modals/custom-components/NewScenarioModalComponent.js144
-rw-r--r--opendc-web/opendc-web-ui/src/components/modals/custom-components/NewTopologyModalComponent.js71
-rw-r--r--opendc-web/opendc-web-ui/src/components/navigation/AppNavbarComponent.js26
-rw-r--r--opendc-web/opendc-web-ui/src/components/navigation/HomeNavbar.js23
-rw-r--r--opendc-web/opendc-web-ui/src/components/navigation/LogoutButton.js17
-rw-r--r--opendc-web/opendc-web-ui/src/components/navigation/Navbar.js92
-rw-r--r--opendc-web/opendc-web-ui/src/components/navigation/Navbar.sass30
-rw-r--r--opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.sass35
-rw-r--r--opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.js28
-rw-r--r--opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.sass3
-rw-r--r--opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.js33
-rw-r--r--opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.sass70
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/FilterButton.js24
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js13
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/FilterPanel.sass5
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewProjectButtonComponent.js17
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/ProjectActionButtons.js29
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/ProjectAuthList.js39
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/ProjectAuthRow.js24
101 files changed, 3104 insertions, 0 deletions
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/LoadingScreen.js b/opendc-web/opendc-web-ui/src/components/app/map/LoadingScreen.js
new file mode 100644
index 00000000..7efea9b0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/LoadingScreen.js
@@ -0,0 +1,11 @@
+import React from 'react'
+import FontAwesome from 'react-fontawesome'
+
+const LoadingScreen = () => (
+ <div className="display-4">
+ <FontAwesome name="refresh" className="mr-4" spin />
+ Loading your project...
+ </div>
+)
+
+export default LoadingScreen
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/MapConstants.js b/opendc-web/opendc-web-ui/src/components/app/map/MapConstants.js
new file mode 100644
index 00000000..d6ea1f84
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/MapConstants.js
@@ -0,0 +1,28 @@
+export const MAP_SIZE = 50
+export const TILE_SIZE_IN_PIXELS = 100
+export const TILE_SIZE_IN_METERS = 0.5
+export const MAP_SIZE_IN_PIXELS = MAP_SIZE * TILE_SIZE_IN_PIXELS
+
+export const OBJECT_MARGIN_IN_PIXELS = TILE_SIZE_IN_PIXELS / 5
+export const TILE_PLUS_MARGIN_IN_PIXELS = TILE_SIZE_IN_PIXELS / 3
+export const OBJECT_SIZE_IN_PIXELS = TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2
+
+export const GRID_LINE_WIDTH_IN_PIXELS = 2
+export const WALL_WIDTH_IN_PIXELS = TILE_SIZE_IN_PIXELS / 8
+export const OBJECT_BORDER_WIDTH_IN_PIXELS = TILE_SIZE_IN_PIXELS / 12
+export const TILE_PLUS_WIDTH_IN_PIXELS = TILE_SIZE_IN_PIXELS / 10
+
+export const SIDEBAR_WIDTH = 350
+export const VIEWPORT_PADDING = 50
+
+export const RACK_FILL_ICON_WIDTH = OBJECT_SIZE_IN_PIXELS / 3
+export const RACK_FILL_ICON_OPACITY = 0.8
+
+export const MAP_MOVE_PIXELS_PER_EVENT = 20
+export const MAP_SCALE_PER_EVENT = 1.1
+export const MAP_MIN_SCALE = 0.5
+export const MAP_MAX_SCALE = 1.5
+
+export const MAX_NUM_UNITS_PER_MACHINE = 6
+export const DEFAULT_RACK_SLOT_CAPACITY = 42
+export const DEFAULT_RACK_POWER_CAPACITY = 10000
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/MapStageComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/MapStageComponent.js
new file mode 100644
index 00000000..2cd0ed6e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/MapStageComponent.js
@@ -0,0 +1,103 @@
+import React from 'react'
+import { Stage } from 'react-konva'
+import { Shortcuts } from 'react-shortcuts'
+import MapLayer from '../../../containers/app/map/layers/MapLayer'
+import ObjectHoverLayer from '../../../containers/app/map/layers/ObjectHoverLayer'
+import RoomHoverLayer from '../../../containers/app/map/layers/RoomHoverLayer'
+import { NAVBAR_HEIGHT } from '../../navigation/Navbar'
+import { MAP_MOVE_PIXELS_PER_EVENT } from './MapConstants'
+import { Provider } from 'react-redux'
+import { store } from '../../../store/configure-store'
+
+class MapStageComponent extends React.Component {
+ state = {
+ mouseX: 0,
+ mouseY: 0,
+ }
+
+ constructor(props) {
+ super(props)
+
+ this.updateDimensions = this.updateDimensions.bind(this)
+ this.updateScale = this.updateScale.bind(this)
+ }
+
+ componentDidMount() {
+ this.updateDimensions()
+
+ window.addEventListener('resize', this.updateDimensions)
+ window.addEventListener('wheel', this.updateScale)
+
+ window['exportCanvasToImage'] = () => {
+ const download = document.createElement('a')
+ download.href = this.stage.getStage().toDataURL()
+ download.download = 'opendc-canvas-export-' + Date.now() + '.png'
+ download.click()
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.updateDimensions)
+ window.removeEventListener('wheel', this.updateScale)
+ }
+
+ updateDimensions() {
+ this.props.setMapDimensions(window.innerWidth, window.innerHeight - NAVBAR_HEIGHT)
+ }
+
+ updateScale(e) {
+ e.preventDefault()
+ this.props.zoomInOnPosition(e.deltaY < 0, this.state.mouseX, this.state.mouseY)
+ }
+
+ updateMousePosition() {
+ const mousePos = this.stage.getStage().getPointerPosition()
+ this.setState({ mouseX: mousePos.x, mouseY: mousePos.y })
+ }
+
+ handleShortcuts(action) {
+ switch (action) {
+ case 'MOVE_LEFT':
+ this.moveWithDelta(MAP_MOVE_PIXELS_PER_EVENT, 0)
+ break
+ case 'MOVE_RIGHT':
+ this.moveWithDelta(-MAP_MOVE_PIXELS_PER_EVENT, 0)
+ break
+ case 'MOVE_UP':
+ this.moveWithDelta(0, MAP_MOVE_PIXELS_PER_EVENT)
+ break
+ case 'MOVE_DOWN':
+ this.moveWithDelta(0, -MAP_MOVE_PIXELS_PER_EVENT)
+ break
+ default:
+ break
+ }
+ }
+
+ moveWithDelta(deltaX, deltaY) {
+ this.props.setMapPositionWithBoundsCheck(this.props.mapPosition.x + deltaX, this.props.mapPosition.y + deltaY)
+ }
+
+ render() {
+ return (
+ <Shortcuts name="MAP" handler={this.handleShortcuts.bind(this)} targetNodeSelector="body">
+ <Stage
+ ref={(stage) => {
+ this.stage = stage
+ }}
+ width={this.props.mapDimensions.width}
+ height={this.props.mapDimensions.height}
+ onMouseMove={this.updateMousePosition.bind(this)}
+ >
+ <Provider store={store}>
+ <MapLayer />
+ <RoomHoverLayer mouseX={this.state.mouseX} mouseY={this.state.mouseY} />
+ <ObjectHoverLayer mouseX={this.state.mouseX} mouseY={this.state.mouseY} />
+ </Provider>
+ </Stage>
+ </Shortcuts>
+ )
+ }
+}
+
+export default MapStageComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/controls/ExportCanvasComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/controls/ExportCanvasComponent.js
new file mode 100644
index 00000000..8487f47b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/controls/ExportCanvasComponent.js
@@ -0,0 +1,13 @@
+import React from 'react'
+
+const ExportCanvasComponent = () => (
+ <button
+ className="btn btn-success btn-circle btn-sm"
+ title="Export Canvas to PNG Image"
+ onClick={() => window['exportCanvasToImage']()}
+ >
+ <span className="fa fa-camera" />
+ </button>
+)
+
+export default ExportCanvasComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.js
new file mode 100644
index 00000000..7cbb45c0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.js
@@ -0,0 +1,11 @@
+import React from 'react'
+import { TILE_SIZE_IN_METERS, TILE_SIZE_IN_PIXELS } from '../MapConstants'
+import './ScaleIndicatorComponent.sass'
+
+const ScaleIndicatorComponent = ({ scale }) => (
+ <div className="scale-indicator" style={{ width: TILE_SIZE_IN_PIXELS * scale }}>
+ {TILE_SIZE_IN_METERS}m
+ </div>
+)
+
+export default ScaleIndicatorComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.sass b/opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.sass
new file mode 100644
index 00000000..03a72c99
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/controls/ScaleIndicatorComponent.sass
@@ -0,0 +1,9 @@
+.scale-indicator
+ position: absolute
+ right: 10px
+ bottom: 10px
+ z-index: 50
+
+ border: solid 2px #212529
+ border-top: none
+ border-left: none
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.js
new file mode 100644
index 00000000..f372734d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.js
@@ -0,0 +1,13 @@
+import React from 'react'
+import ZoomControlContainer from '../../../../containers/app/map/controls/ZoomControlContainer'
+import ExportCanvasComponent from './ExportCanvasComponent'
+import './ToolPanelComponent.sass'
+
+const ToolPanelComponent = () => (
+ <div className="tool-panel">
+ <ZoomControlContainer />
+ <ExportCanvasComponent />
+ </div>
+)
+
+export default ToolPanelComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.sass b/opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.sass
new file mode 100644
index 00000000..8b27d24a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/controls/ToolPanelComponent.sass
@@ -0,0 +1,5 @@
+.tool-panel
+ position: absolute
+ left: 10px
+ bottom: 10px
+ z-index: 50
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/controls/ZoomControlComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/controls/ZoomControlComponent.js
new file mode 100644
index 00000000..65944bea
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/controls/ZoomControlComponent.js
@@ -0,0 +1,24 @@
+import React from 'react'
+
+const ZoomControlComponent = ({ zoomInOnCenter }) => {
+ return (
+ <span>
+ <button
+ className="btn btn-default btn-circle btn-sm mr-1"
+ title="Zoom in"
+ onClick={() => zoomInOnCenter(true)}
+ >
+ <span className="fa fa-plus" />
+ </button>
+ <button
+ className="btn btn-default btn-circle btn-sm mr-1"
+ title="Zoom out"
+ onClick={() => zoomInOnCenter(false)}
+ >
+ <span className="fa fa-minus" />
+ </button>
+ </span>
+ )
+}
+
+export default ZoomControlComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/Backdrop.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/Backdrop.js
new file mode 100644
index 00000000..8ccfe584
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/Backdrop.js
@@ -0,0 +1,8 @@
+import React from 'react'
+import { Rect } from 'react-konva'
+import { BACKDROP_COLOR } from '../../../../util/colors'
+import { MAP_SIZE_IN_PIXELS } from '../MapConstants'
+
+const Backdrop = () => <Rect x={0} y={0} width={MAP_SIZE_IN_PIXELS} height={MAP_SIZE_IN_PIXELS} fill={BACKDROP_COLOR} />
+
+export default Backdrop
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/GrayLayer.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/GrayLayer.js
new file mode 100644
index 00000000..c54a34ad
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/GrayLayer.js
@@ -0,0 +1,17 @@
+import React from 'react'
+import { Rect } from 'react-konva'
+import { GRAYED_OUT_AREA_COLOR } from '../../../../util/colors'
+import { MAP_SIZE_IN_PIXELS } from '../MapConstants'
+
+const GrayLayer = ({ onClick }) => (
+ <Rect
+ x={0}
+ y={0}
+ width={MAP_SIZE_IN_PIXELS}
+ height={MAP_SIZE_IN_PIXELS}
+ fill={GRAYED_OUT_AREA_COLOR}
+ onClick={onClick}
+ />
+)
+
+export default GrayLayer
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/HoverTile.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/HoverTile.js
new file mode 100644
index 00000000..912229c4
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/HoverTile.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Rect } from 'react-konva'
+import { ROOM_HOVER_INVALID_COLOR, ROOM_HOVER_VALID_COLOR } from '../../../../util/colors'
+import { TILE_SIZE_IN_PIXELS } from '../MapConstants'
+
+const HoverTile = ({ pixelX, pixelY, isValid, scale, onClick }) => (
+ <Rect
+ x={pixelX}
+ y={pixelY}
+ scaleX={scale}
+ scaleY={scale}
+ width={TILE_SIZE_IN_PIXELS}
+ height={TILE_SIZE_IN_PIXELS}
+ fill={isValid ? ROOM_HOVER_VALID_COLOR : ROOM_HOVER_INVALID_COLOR}
+ onClick={onClick}
+ />
+)
+
+HoverTile.propTypes = {
+ pixelX: PropTypes.number.isRequired,
+ pixelY: PropTypes.number.isRequired,
+ isValid: PropTypes.bool.isRequired,
+ onClick: PropTypes.func.isRequired,
+}
+
+export default HoverTile
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/ImageComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/ImageComponent.js
new file mode 100644
index 00000000..2b5c569f
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/ImageComponent.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Image } from 'react-konva'
+
+class ImageComponent extends React.Component {
+ static imageCaches = {}
+ static propTypes = {
+ src: PropTypes.string.isRequired,
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ opacity: PropTypes.number.isRequired,
+ }
+
+ state = {
+ image: null,
+ }
+
+ componentDidMount() {
+ if (ImageComponent.imageCaches[this.props.src]) {
+ this.setState({ image: ImageComponent.imageCaches[this.props.src] })
+ return
+ }
+
+ const image = new window.Image()
+ image.src = this.props.src
+ image.onload = () => {
+ this.setState({ image })
+ ImageComponent.imageCaches[this.props.src] = image
+ }
+ }
+
+ render() {
+ return (
+ <Image
+ image={this.state.image}
+ x={this.props.x}
+ y={this.props.y}
+ width={this.props.width}
+ height={this.props.height}
+ opacity={this.props.opacity}
+ />
+ )
+ }
+}
+
+export default ImageComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/RackFillBar.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/RackFillBar.js
new file mode 100644
index 00000000..8c573a6f
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/RackFillBar.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Group, Rect } from 'react-konva'
+import {
+ RACK_ENERGY_BAR_BACKGROUND_COLOR,
+ RACK_ENERGY_BAR_FILL_COLOR,
+ RACK_SPACE_BAR_BACKGROUND_COLOR,
+ RACK_SPACE_BAR_FILL_COLOR,
+} from '../../../../util/colors'
+import {
+ OBJECT_BORDER_WIDTH_IN_PIXELS,
+ OBJECT_MARGIN_IN_PIXELS,
+ RACK_FILL_ICON_OPACITY,
+ RACK_FILL_ICON_WIDTH,
+ TILE_SIZE_IN_PIXELS,
+} from '../MapConstants'
+import ImageComponent from './ImageComponent'
+
+const RackFillBar = ({ positionX, positionY, type, fillFraction }) => {
+ const halfOfObjectBorderWidth = OBJECT_BORDER_WIDTH_IN_PIXELS / 2
+ const x =
+ positionX * TILE_SIZE_IN_PIXELS +
+ OBJECT_MARGIN_IN_PIXELS +
+ (type === 'space' ? halfOfObjectBorderWidth : 0.5 * (TILE_SIZE_IN_PIXELS - 2 * OBJECT_MARGIN_IN_PIXELS))
+ const startY = positionY * TILE_SIZE_IN_PIXELS + OBJECT_MARGIN_IN_PIXELS + halfOfObjectBorderWidth
+ const width = 0.5 * (TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2) - halfOfObjectBorderWidth
+ const fullHeight = TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2 - OBJECT_BORDER_WIDTH_IN_PIXELS
+
+ const fractionHeight = fillFraction * fullHeight
+ const fractionY =
+ (positionY + 1) * TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS - halfOfObjectBorderWidth - fractionHeight
+
+ return (
+ <Group>
+ <Rect
+ x={x}
+ y={startY}
+ width={width}
+ height={fullHeight}
+ fill={type === 'space' ? RACK_SPACE_BAR_BACKGROUND_COLOR : RACK_ENERGY_BAR_BACKGROUND_COLOR}
+ />
+ <Rect
+ x={x}
+ y={fractionY}
+ width={width}
+ height={fractionHeight}
+ fill={type === 'space' ? RACK_SPACE_BAR_FILL_COLOR : RACK_ENERGY_BAR_FILL_COLOR}
+ />
+ <ImageComponent
+ src={'/img/topology/rack-' + type + '-icon.png'}
+ x={x + width * 0.5 - RACK_FILL_ICON_WIDTH * 0.5}
+ y={startY + fullHeight * 0.5 - RACK_FILL_ICON_WIDTH * 0.5}
+ width={RACK_FILL_ICON_WIDTH}
+ height={RACK_FILL_ICON_WIDTH}
+ opacity={RACK_FILL_ICON_OPACITY}
+ />
+ </Group>
+ )
+}
+
+RackFillBar.propTypes = {
+ positionX: PropTypes.number.isRequired,
+ positionY: PropTypes.number.isRequired,
+ type: PropTypes.string.isRequired,
+ fillFraction: PropTypes.number.isRequired,
+}
+
+export default RackFillBar
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/RoomTile.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/RoomTile.js
new file mode 100644
index 00000000..43bf918e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/RoomTile.js
@@ -0,0 +1,20 @@
+import React from 'react'
+import { Rect } from 'react-konva'
+import Shapes from '../../../../shapes/index'
+import { TILE_SIZE_IN_PIXELS } from '../MapConstants'
+
+const RoomTile = ({ tile, color }) => (
+ <Rect
+ x={tile.positionX * TILE_SIZE_IN_PIXELS}
+ y={tile.positionY * TILE_SIZE_IN_PIXELS}
+ width={TILE_SIZE_IN_PIXELS}
+ height={TILE_SIZE_IN_PIXELS}
+ fill={color}
+ />
+)
+
+RoomTile.propTypes = {
+ tile: Shapes.Tile,
+}
+
+export default RoomTile
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/TileObject.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/TileObject.js
new file mode 100644
index 00000000..9e87cc82
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/TileObject.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Rect } from 'react-konva'
+import { OBJECT_BORDER_COLOR } from '../../../../util/colors'
+import { OBJECT_BORDER_WIDTH_IN_PIXELS, OBJECT_MARGIN_IN_PIXELS, TILE_SIZE_IN_PIXELS } from '../MapConstants'
+
+const TileObject = ({ positionX, positionY, color }) => (
+ <Rect
+ x={positionX * TILE_SIZE_IN_PIXELS + OBJECT_MARGIN_IN_PIXELS}
+ y={positionY * TILE_SIZE_IN_PIXELS + OBJECT_MARGIN_IN_PIXELS}
+ width={TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2}
+ height={TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2}
+ fill={color}
+ stroke={OBJECT_BORDER_COLOR}
+ strokeWidth={OBJECT_BORDER_WIDTH_IN_PIXELS}
+ />
+)
+
+TileObject.propTypes = {
+ positionX: PropTypes.number.isRequired,
+ positionY: PropTypes.number.isRequired,
+ color: PropTypes.string.isRequired,
+}
+
+export default TileObject
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/TilePlusIcon.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/TilePlusIcon.js
new file mode 100644
index 00000000..be3a00a8
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/TilePlusIcon.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Group, Line } from 'react-konva'
+import { TILE_PLUS_COLOR } from '../../../../util/colors'
+import { TILE_PLUS_MARGIN_IN_PIXELS, TILE_PLUS_WIDTH_IN_PIXELS, TILE_SIZE_IN_PIXELS } from '../MapConstants'
+
+const TilePlusIcon = ({ pixelX, pixelY, mapScale }) => {
+ const linePoints = [
+ [
+ pixelX + 0.5 * TILE_SIZE_IN_PIXELS * mapScale,
+ pixelY + TILE_PLUS_MARGIN_IN_PIXELS * mapScale,
+ pixelX + 0.5 * TILE_SIZE_IN_PIXELS * mapScale,
+ pixelY + TILE_SIZE_IN_PIXELS * mapScale - TILE_PLUS_MARGIN_IN_PIXELS * mapScale,
+ ],
+ [
+ pixelX + TILE_PLUS_MARGIN_IN_PIXELS * mapScale,
+ pixelY + 0.5 * TILE_SIZE_IN_PIXELS * mapScale,
+ pixelX + TILE_SIZE_IN_PIXELS * mapScale - TILE_PLUS_MARGIN_IN_PIXELS * mapScale,
+ pixelY + 0.5 * TILE_SIZE_IN_PIXELS * mapScale,
+ ],
+ ]
+ return (
+ <Group>
+ {linePoints.map((points, index) => (
+ <Line
+ key={index}
+ points={points}
+ lineCap="round"
+ stroke={TILE_PLUS_COLOR}
+ strokeWidth={TILE_PLUS_WIDTH_IN_PIXELS * mapScale}
+ listening={false}
+ />
+ ))}
+ </Group>
+ )
+}
+
+TilePlusIcon.propTypes = {
+ pixelX: PropTypes.number,
+ pixelY: PropTypes.number,
+ mapScale: PropTypes.number,
+}
+
+export default TilePlusIcon
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/elements/WallSegment.js b/opendc-web/opendc-web-ui/src/components/app/map/elements/WallSegment.js
new file mode 100644
index 00000000..8aa2aebf
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/elements/WallSegment.js
@@ -0,0 +1,32 @@
+import React from 'react'
+import { Line } from 'react-konva'
+import Shapes from '../../../../shapes/index'
+import { WALL_COLOR } from '../../../../util/colors'
+import { TILE_SIZE_IN_PIXELS, WALL_WIDTH_IN_PIXELS } from '../MapConstants'
+
+const WallSegment = ({ wallSegment }) => {
+ let points
+ if (wallSegment.isHorizontal) {
+ points = [
+ wallSegment.startPosX * TILE_SIZE_IN_PIXELS,
+ wallSegment.startPosY * TILE_SIZE_IN_PIXELS,
+ (wallSegment.startPosX + wallSegment.length) * TILE_SIZE_IN_PIXELS,
+ wallSegment.startPosY * TILE_SIZE_IN_PIXELS,
+ ]
+ } else {
+ points = [
+ wallSegment.startPosX * TILE_SIZE_IN_PIXELS,
+ wallSegment.startPosY * TILE_SIZE_IN_PIXELS,
+ wallSegment.startPosX * TILE_SIZE_IN_PIXELS,
+ (wallSegment.startPosY + wallSegment.length) * TILE_SIZE_IN_PIXELS,
+ ]
+ }
+
+ return <Line points={points} lineCap="round" stroke={WALL_COLOR} strokeWidth={WALL_WIDTH_IN_PIXELS} />
+}
+
+WallSegment.propTypes = {
+ wallSegment: Shapes.WallSegment,
+}
+
+export default WallSegment
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/groups/GridGroup.js b/opendc-web/opendc-web-ui/src/components/app/map/groups/GridGroup.js
new file mode 100644
index 00000000..ebc00244
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/groups/GridGroup.js
@@ -0,0 +1,34 @@
+import React from 'react'
+import { Group, Line } from 'react-konva'
+import { GRID_COLOR } from '../../../../util/colors'
+import { GRID_LINE_WIDTH_IN_PIXELS, MAP_SIZE, MAP_SIZE_IN_PIXELS, TILE_SIZE_IN_PIXELS } from '../MapConstants'
+
+const MAP_COORDINATE_ENTRIES = Array.from(new Array(MAP_SIZE), (x, i) => i)
+const HORIZONTAL_POINT_PAIRS = MAP_COORDINATE_ENTRIES.map((index) => [
+ 0,
+ index * TILE_SIZE_IN_PIXELS,
+ MAP_SIZE_IN_PIXELS,
+ index * TILE_SIZE_IN_PIXELS,
+])
+const VERTICAL_POINT_PAIRS = MAP_COORDINATE_ENTRIES.map((index) => [
+ index * TILE_SIZE_IN_PIXELS,
+ 0,
+ index * TILE_SIZE_IN_PIXELS,
+ MAP_SIZE_IN_PIXELS,
+])
+
+const GridGroup = () => (
+ <Group>
+ {HORIZONTAL_POINT_PAIRS.concat(VERTICAL_POINT_PAIRS).map((points, index) => (
+ <Line
+ key={index}
+ points={points}
+ stroke={GRID_COLOR}
+ strokeWidth={GRID_LINE_WIDTH_IN_PIXELS}
+ listening={false}
+ />
+ ))}
+ </Group>
+)
+
+export default GridGroup
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/groups/RackGroup.js b/opendc-web/opendc-web-ui/src/components/app/map/groups/RackGroup.js
new file mode 100644
index 00000000..eb6dc24a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/groups/RackGroup.js
@@ -0,0 +1,25 @@
+import React from 'react'
+import { Group } from 'react-konva'
+import RackEnergyFillContainer from '../../../../containers/app/map/RackEnergyFillContainer'
+import RackSpaceFillContainer from '../../../../containers/app/map/RackSpaceFillContainer'
+import Shapes from '../../../../shapes/index'
+import { RACK_BACKGROUND_COLOR } from '../../../../util/colors'
+import TileObject from '../elements/TileObject'
+
+const RackGroup = ({ tile }) => {
+ return (
+ <Group>
+ <TileObject positionX={tile.positionX} positionY={tile.positionY} color={RACK_BACKGROUND_COLOR} />
+ <Group>
+ <RackSpaceFillContainer tileId={tile._id} positionX={tile.positionX} positionY={tile.positionY} />
+ <RackEnergyFillContainer tileId={tile._id} positionX={tile.positionX} positionY={tile.positionY} />
+ </Group>
+ </Group>
+ )
+}
+
+RackGroup.propTypes = {
+ tile: Shapes.Tile,
+}
+
+export default RackGroup
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/groups/RoomGroup.js b/opendc-web/opendc-web-ui/src/components/app/map/groups/RoomGroup.js
new file mode 100644
index 00000000..1fd54687
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/groups/RoomGroup.js
@@ -0,0 +1,48 @@
+import React from 'react'
+import { Group } from 'react-konva'
+import GrayContainer from '../../../../containers/app/map/GrayContainer'
+import TileContainer from '../../../../containers/app/map/TileContainer'
+import WallContainer from '../../../../containers/app/map/WallContainer'
+import Shapes from '../../../../shapes/index'
+
+const RoomGroup = ({ room, interactionLevel, currentRoomInConstruction, onClick }) => {
+ if (currentRoomInConstruction === room._id) {
+ return (
+ <Group onClick={onClick}>
+ {room.tileIds.map((tileId) => (
+ <TileContainer key={tileId} tileId={tileId} newTile={true} />
+ ))}
+ </Group>
+ )
+ }
+
+ return (
+ <Group onClick={onClick}>
+ {(() => {
+ if (
+ (interactionLevel.mode === 'RACK' || interactionLevel.mode === 'MACHINE') &&
+ interactionLevel.roomId === room._id
+ ) {
+ return [
+ room.tileIds
+ .filter((tileId) => tileId !== interactionLevel.tileId)
+ .map((tileId) => <TileContainer key={tileId} tileId={tileId} />),
+ <GrayContainer key={-1} />,
+ room.tileIds
+ .filter((tileId) => tileId === interactionLevel.tileId)
+ .map((tileId) => <TileContainer key={tileId} tileId={tileId} />),
+ ]
+ } else {
+ return room.tileIds.map((tileId) => <TileContainer key={tileId} tileId={tileId} />)
+ }
+ })()}
+ <WallContainer roomId={room._id} />
+ </Group>
+ )
+}
+
+RoomGroup.propTypes = {
+ room: Shapes.Room,
+}
+
+export default RoomGroup
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/groups/TileGroup.js b/opendc-web/opendc-web-ui/src/components/app/map/groups/TileGroup.js
new file mode 100644
index 00000000..1e106823
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/groups/TileGroup.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Group } from 'react-konva'
+import RackContainer from '../../../../containers/app/map/RackContainer'
+import Shapes from '../../../../shapes/index'
+import { ROOM_DEFAULT_COLOR, ROOM_IN_CONSTRUCTION_COLOR } from '../../../../util/colors'
+import RoomTile from '../elements/RoomTile'
+
+const TileGroup = ({ tile, newTile, roomLoad, onClick }) => {
+ let tileObject
+ if (tile.rackId) {
+ tileObject = <RackContainer tile={tile} />
+ } else {
+ tileObject = null
+ }
+
+ let color = ROOM_DEFAULT_COLOR
+ if (newTile) {
+ color = ROOM_IN_CONSTRUCTION_COLOR
+ }
+
+ return (
+ <Group onClick={() => onClick(tile)}>
+ <RoomTile tile={tile} color={color} />
+ {tileObject}
+ </Group>
+ )
+}
+
+TileGroup.propTypes = {
+ tile: Shapes.Tile,
+ newTile: PropTypes.bool,
+}
+
+export default TileGroup
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/groups/TopologyGroup.js b/opendc-web/opendc-web-ui/src/components/app/map/groups/TopologyGroup.js
new file mode 100644
index 00000000..6096fc8b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/groups/TopologyGroup.js
@@ -0,0 +1,44 @@
+import React from 'react'
+import { Group } from 'react-konva'
+import GrayContainer from '../../../../containers/app/map/GrayContainer'
+import RoomContainer from '../../../../containers/app/map/RoomContainer'
+import Shapes from '../../../../shapes/index'
+
+const TopologyGroup = ({ topology, interactionLevel }) => {
+ if (!topology) {
+ return <Group />
+ }
+
+ if (interactionLevel.mode === 'BUILDING') {
+ return (
+ <Group>
+ {topology.roomIds.map((roomId) => (
+ <RoomContainer key={roomId} roomId={roomId} />
+ ))}
+ </Group>
+ )
+ }
+
+ return (
+ <Group>
+ {topology.roomIds
+ .filter((roomId) => roomId !== interactionLevel.roomId)
+ .map((roomId) => (
+ <RoomContainer key={roomId} roomId={roomId} />
+ ))}
+ {interactionLevel.mode === 'ROOM' ? <GrayContainer /> : null}
+ {topology.roomIds
+ .filter((roomId) => roomId === interactionLevel.roomId)
+ .map((roomId) => (
+ <RoomContainer key={roomId} roomId={roomId} />
+ ))}
+ </Group>
+ )
+}
+
+TopologyGroup.propTypes = {
+ topology: Shapes.Topology,
+ interactionLevel: Shapes.InteractionLevel,
+}
+
+export default TopologyGroup
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/groups/WallGroup.js b/opendc-web/opendc-web-ui/src/components/app/map/groups/WallGroup.js
new file mode 100644
index 00000000..7b0f5ca0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/groups/WallGroup.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Group } from 'react-konva'
+import Shapes from '../../../../shapes/index'
+import { deriveWallLocations } from '../../../../util/tile-calculations'
+import WallSegment from '../elements/WallSegment'
+
+const WallGroup = ({ tiles }) => {
+ return (
+ <Group>
+ {deriveWallLocations(tiles).map((wallSegment, index) => (
+ <WallSegment key={index} wallSegment={wallSegment} />
+ ))}
+ </Group>
+ )
+}
+
+WallGroup.propTypes = {
+ tiles: PropTypes.arrayOf(Shapes.Tile).isRequired,
+}
+
+export default WallGroup
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/layers/HoverLayerComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/layers/HoverLayerComponent.js
new file mode 100644
index 00000000..bead87de
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/layers/HoverLayerComponent.js
@@ -0,0 +1,75 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Layer } from 'react-konva'
+import HoverTile from '../elements/HoverTile'
+import { TILE_SIZE_IN_PIXELS } from '../MapConstants'
+
+class HoverLayerComponent extends React.Component {
+ static propTypes = {
+ mouseX: PropTypes.number.isRequired,
+ mouseY: PropTypes.number.isRequired,
+ mapPosition: PropTypes.object.isRequired,
+ mapScale: PropTypes.number.isRequired,
+ isEnabled: PropTypes.func.isRequired,
+ onClick: PropTypes.func.isRequired,
+ }
+
+ state = {
+ positionX: -1,
+ positionY: -1,
+ validity: false,
+ }
+
+ componentDidUpdate() {
+ if (!this.props.isEnabled()) {
+ return
+ }
+
+ const positionX = Math.floor(
+ (this.props.mouseX - this.props.mapPosition.x) / (this.props.mapScale * TILE_SIZE_IN_PIXELS)
+ )
+ const positionY = Math.floor(
+ (this.props.mouseY - this.props.mapPosition.y) / (this.props.mapScale * TILE_SIZE_IN_PIXELS)
+ )
+
+ if (positionX !== this.state.positionX || positionY !== this.state.positionY) {
+ this.setState({
+ positionX,
+ positionY,
+ validity: this.props.isValid(positionX, positionY),
+ })
+ }
+ }
+
+ render() {
+ if (!this.props.isEnabled()) {
+ return <Layer />
+ }
+
+ const pixelX = this.props.mapScale * this.state.positionX * TILE_SIZE_IN_PIXELS + this.props.mapPosition.x
+ const pixelY = this.props.mapScale * this.state.positionY * TILE_SIZE_IN_PIXELS + this.props.mapPosition.y
+
+ return (
+ <Layer opacity={0.6}>
+ <HoverTile
+ pixelX={pixelX}
+ pixelY={pixelY}
+ scale={this.props.mapScale}
+ isValid={this.state.validity}
+ onClick={() =>
+ this.state.validity ? this.props.onClick(this.state.positionX, this.state.positionY) : undefined
+ }
+ />
+ {this.props.children
+ ? React.cloneElement(this.props.children, {
+ pixelX,
+ pixelY,
+ scale: this.props.mapScale,
+ })
+ : undefined}
+ </Layer>
+ )
+ }
+}
+
+export default HoverLayerComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/layers/MapLayerComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/layers/MapLayerComponent.js
new file mode 100644
index 00000000..8ee14c9c
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/layers/MapLayerComponent.js
@@ -0,0 +1,17 @@
+import React from 'react'
+import { Group, Layer } from 'react-konva'
+import TopologyContainer from '../../../../containers/app/map/TopologyContainer'
+import Backdrop from '../elements/Backdrop'
+import GridGroup from '../groups/GridGroup'
+
+const MapLayerComponent = ({ mapPosition, mapScale }) => (
+ <Layer>
+ <Group x={mapPosition.x} y={mapPosition.y} scaleX={mapScale} scaleY={mapScale}>
+ <Backdrop />
+ <TopologyContainer />
+ <GridGroup />
+ </Group>
+ </Layer>
+)
+
+export default MapLayerComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/layers/ObjectHoverLayerComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/layers/ObjectHoverLayerComponent.js
new file mode 100644
index 00000000..661fc255
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/layers/ObjectHoverLayerComponent.js
@@ -0,0 +1,11 @@
+import React from 'react'
+import TilePlusIcon from '../elements/TilePlusIcon'
+import HoverLayerComponent from './HoverLayerComponent'
+
+const ObjectHoverLayerComponent = (props) => (
+ <HoverLayerComponent {...props}>
+ <TilePlusIcon {...props} />
+ </HoverLayerComponent>
+)
+
+export default ObjectHoverLayerComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/map/layers/RoomHoverLayerComponent.js b/opendc-web/opendc-web-ui/src/components/app/map/layers/RoomHoverLayerComponent.js
new file mode 100644
index 00000000..887e2891
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/map/layers/RoomHoverLayerComponent.js
@@ -0,0 +1,6 @@
+import React from 'react'
+import HoverLayerComponent from './HoverLayerComponent'
+
+const RoomHoverLayerComponent = (props) => <HoverLayerComponent {...props} />
+
+export default RoomHoverLayerComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/results/PortfolioResultsComponent.js b/opendc-web/opendc-web-ui/src/components/app/results/PortfolioResultsComponent.js
new file mode 100644
index 00000000..759acd57
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/results/PortfolioResultsComponent.js
@@ -0,0 +1,93 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Bar, CartesianGrid, ComposedChart, ErrorBar, ResponsiveContainer, Scatter, XAxis, YAxis } from 'recharts'
+import { AVAILABLE_METRICS, METRIC_NAMES_SHORT, METRIC_UNITS } from '../../../util/available-metrics'
+import { mean, std } from 'mathjs'
+import Shapes from '../../../shapes/index'
+import approx from 'approximate-number'
+
+const PortfolioResultsComponent = ({ portfolio, scenarios }) => {
+ if (!portfolio) {
+ return <div>Loading...</div>
+ }
+
+ const nonFinishedScenarios = scenarios.filter((s) => s.simulation.state !== 'FINISHED')
+
+ if (nonFinishedScenarios.length > 0) {
+ if (nonFinishedScenarios.every((s) => s.simulation.state === 'QUEUED' || s.simulation.state === 'RUNNING')) {
+ return (
+ <div>
+ <h1>Simulation running...</h1>
+ <p>{nonFinishedScenarios.length} of the scenarios are still being simulated</p>
+ </div>
+ )
+ }
+ if (nonFinishedScenarios.some((s) => s.simulation.state === 'FAILED')) {
+ return (
+ <div>
+ <h1>Simulation failed.</h1>
+ <p>
+ Try again by creating a new scenario. Please contact the OpenDC team for support, if issues
+ persist.
+ </p>
+ </div>
+ )
+ }
+ }
+
+ const dataPerMetric = {}
+
+ AVAILABLE_METRICS.forEach((metric) => {
+ dataPerMetric[metric] = scenarios.map((scenario) => ({
+ name: scenario.name,
+ value: mean(scenario.results[metric]),
+ errorX: std(scenario.results[metric]),
+ }))
+ })
+
+ return (
+ <div className="full-height" style={{ overflowY: 'scroll', overflowX: 'hidden' }}>
+ <h2>Portfolio: {portfolio.name}</h2>
+ <p>Repeats per Scenario: {portfolio.targets.repeatsPerScenario}</p>
+ <div className="row">
+ {AVAILABLE_METRICS.map((metric) => (
+ <div className="col-6 mb-2" key={metric}>
+ <h4>{METRIC_NAMES_SHORT[metric]}</h4>
+ <ResponsiveContainer aspect={16 / 9} width="100%">
+ <ComposedChart
+ data={dataPerMetric[metric]}
+ margin={{ left: 35, bottom: 15 }}
+ layout="vertical"
+ >
+ <CartesianGrid strokeDasharray="3 3" />
+ <XAxis
+ tickFormatter={(tick) => approx(tick)}
+ label={{ value: METRIC_UNITS[metric], position: 'bottom', offset: 0 }}
+ type="number"
+ />
+ <YAxis dataKey="name" type="category" />
+ <Bar dataKey="value" fill="#3399FF" isAnimationActive={false} />
+ <Scatter dataKey="value" opacity={0} isAnimationActive={false}>
+ <ErrorBar
+ dataKey="errorX"
+ width={10}
+ strokeWidth={3}
+ stroke="#FF6600"
+ direction="x"
+ />
+ </Scatter>
+ </ComposedChart>
+ </ResponsiveContainer>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+}
+
+PortfolioResultsComponent.propTypes = {
+ portfolio: Shapes.Portfolio,
+ scenarios: PropTypes.arrayOf(Shapes.Scenario),
+}
+
+export default PortfolioResultsComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.js
new file mode 100644
index 00000000..f7368f54
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types'
+import classNames from 'classnames'
+import React from 'react'
+import './Sidebar.sass'
+
+class Sidebar extends React.Component {
+ static propTypes = {
+ isRight: PropTypes.bool.isRequired,
+ collapsible: PropTypes.bool,
+ }
+
+ static defaultProps = {
+ collapsible: true,
+ }
+
+ state = {
+ collapsed: false,
+ }
+
+ render() {
+ const collapseButton = (
+ <div
+ className={classNames('sidebar-collapse-button', {
+ 'sidebar-collapse-button-right': this.props.isRight,
+ })}
+ onClick={() => this.setState({ collapsed: !this.state.collapsed })}
+ >
+ {(this.state.collapsed && this.props.isRight) || (!this.state.collapsed && !this.props.isRight) ? (
+ <span className="fa fa-angle-left" title={this.props.isRight ? 'Expand' : 'Collapse'} />
+ ) : (
+ <span className="fa fa-angle-right" title={this.props.isRight ? 'Collapse' : 'Expand'} />
+ )}
+ </div>
+ )
+
+ if (this.state.collapsed) {
+ return collapseButton
+ }
+ return (
+ <div
+ className={classNames('sidebar p-3 h-100', {
+ 'sidebar-right': this.props.isRight,
+ })}
+ onWheel={(e) => e.stopPropagation()}
+ >
+ {this.props.children}
+ {this.props.collapsible && collapseButton}
+ </div>
+ )
+ }
+}
+
+export default Sidebar
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.sass b/opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.sass
new file mode 100644
index 00000000..b8e15716
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/Sidebar.sass
@@ -0,0 +1,50 @@
+@import ../../../style-globals/_variables.sass
+@import ../../../style-globals/_mixins.sass
+
+.sidebar-collapse-button
+ position: absolute
+ left: 5px
+ top: 5px
+ padding: 5px 7px
+
+ background: white
+ border: solid 1px $gray-semi-light
+ z-index: 99
+
+ +clickable
+ +border-radius(5px)
+ +transition(background, 200ms)
+
+ &.sidebar-collapse-button-right
+ left: auto
+ right: 5px
+ top: 5px
+
+ &:hover
+ background: #eeeeee
+
+.sidebar
+ position: absolute
+ top: 0
+ left: 0
+ width: $side-bar-width
+
+ z-index: 100
+ background: white
+
+ border-right: $gray-semi-dark 1px solid
+
+ .sidebar-collapse-button
+ left: auto
+ right: -25px
+
+.sidebar-right
+ left: auto
+ right: 0
+
+ border-left: $gray-semi-dark 1px solid
+ border-right: none
+
+ .sidebar-collapse-button-right
+ left: -25px
+ right: auto
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/project/PortfolioListComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/PortfolioListComponent.js
new file mode 100644
index 00000000..b000b9e2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/PortfolioListComponent.js
@@ -0,0 +1,66 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import Shapes from '../../../../shapes'
+import { Link } from 'react-router-dom'
+import FontAwesome from 'react-fontawesome'
+import ScenarioListContainer from '../../../../containers/app/sidebars/project/ScenarioListContainer'
+
+class PortfolioListComponent extends React.Component {
+ static propTypes = {
+ portfolios: PropTypes.arrayOf(Shapes.Portfolio),
+ currentProjectId: PropTypes.string.isRequired,
+ currentPortfolioId: PropTypes.string,
+ onNewPortfolio: PropTypes.func.isRequired,
+ onChoosePortfolio: PropTypes.func.isRequired,
+ onDeletePortfolio: PropTypes.func.isRequired,
+ }
+
+ onDelete(id) {
+ this.props.onDeletePortfolio(id)
+ }
+
+ render() {
+ return (
+ <div className="pb-3">
+ <h2>
+ Portfolios
+ <button
+ className="btn btn-outline-primary float-right"
+ onClick={this.props.onNewPortfolio.bind(this)}
+ >
+ <FontAwesome name="plus" />
+ </button>
+ </h2>
+
+ {this.props.portfolios.map((portfolio, idx) => (
+ <div key={portfolio._id}>
+ <div className="row mb-1">
+ <div
+ className={
+ 'col-7 align-self-center ' +
+ (portfolio._id === this.props.currentPortfolioId ? 'font-weight-bold' : '')
+ }
+ >
+ {portfolio.name}
+ </div>
+ <div className="col-5 text-right">
+ <Link
+ className="btn btn-outline-primary mr-1 fa fa-play"
+ to={`/projects/${this.props.currentProjectId}/portfolios/${portfolio._id}`}
+ onClick={() => this.props.onChoosePortfolio(portfolio._id)}
+ />
+ <span
+ className="btn btn-outline-danger fa fa-trash"
+ onClick={() => this.onDelete(portfolio._id)}
+ />
+ </div>
+ </div>
+ <ScenarioListContainer portfolioId={portfolio._id} />
+ </div>
+ ))}
+ </div>
+ )
+ }
+}
+
+export default PortfolioListComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/project/ProjectSidebarComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/ProjectSidebarComponent.js
new file mode 100644
index 00000000..4789315e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/ProjectSidebarComponent.js
@@ -0,0 +1,15 @@
+import React from 'react'
+import Sidebar from '../Sidebar'
+import TopologyListContainer from '../../../../containers/app/sidebars/project/TopologyListContainer'
+import PortfolioListContainer from '../../../../containers/app/sidebars/project/PortfolioListContainer'
+
+const ProjectSidebarComponent = ({ collapsible }) => (
+ <Sidebar isRight={false} collapsible={collapsible}>
+ <div className="h-100 overflow-auto container-fluid">
+ <TopologyListContainer />
+ <PortfolioListContainer />
+ </div>
+ </Sidebar>
+)
+
+export default ProjectSidebarComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/project/ScenarioListComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/ScenarioListComponent.js
new file mode 100644
index 00000000..e775a663
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/ScenarioListComponent.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import Shapes from '../../../../shapes'
+import { Link } from 'react-router-dom'
+import FontAwesome from 'react-fontawesome'
+
+class ScenarioListComponent extends React.Component {
+ static propTypes = {
+ scenarios: PropTypes.arrayOf(Shapes.Scenario),
+ portfolioId: PropTypes.string,
+ currentProjectId: PropTypes.string.isRequired,
+ currentScenarioId: PropTypes.string,
+ onNewScenario: PropTypes.func.isRequired,
+ onChooseScenario: PropTypes.func.isRequired,
+ onDeleteScenario: PropTypes.func.isRequired,
+ }
+
+ onDelete(id) {
+ this.props.onDeleteScenario(id)
+ }
+
+ render() {
+ return (
+ <>
+ {this.props.scenarios.map((scenario, idx) => (
+ <div key={scenario._id} className="row mb-1">
+ <div
+ className={
+ 'col-7 pl-5 align-self-center ' +
+ (scenario._id === this.props.currentScenarioId ? 'font-weight-bold' : '')
+ }
+ >
+ {scenario.name}
+ </div>
+ <div className="col-5 text-right">
+ <Link
+ className="btn btn-outline-primary mr-1 fa fa-play disabled"
+ to={`/projects/${this.props.currentProjectId}/portfolios/${scenario.portfolioId}/scenarios/${scenario._id}`}
+ onClick={() => this.props.onChooseScenario(scenario.portfolioId, scenario._id)}
+ />
+ <span
+ className={'btn btn-outline-danger fa fa-trash ' + (idx === 0 ? 'disabled' : '')}
+ onClick={() => (idx !== 0 ? this.onDelete(scenario._id) : undefined)}
+ />
+ </div>
+ </div>
+ ))}
+ <div className="pl-4 mb-2">
+ <div
+ className="btn btn-outline-primary"
+ onClick={() => this.props.onNewScenario(this.props.portfolioId)}
+ >
+ <FontAwesome name="plus" className="mr-1" />
+ New scenario
+ </div>
+ </div>
+ </>
+ )
+ }
+}
+
+export default ScenarioListComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/project/TopologyListComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/TopologyListComponent.js
new file mode 100644
index 00000000..2f42f7e4
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/project/TopologyListComponent.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import Shapes from '../../../../shapes'
+import FontAwesome from 'react-fontawesome'
+
+class TopologyListComponent extends React.Component {
+ static propTypes = {
+ topologies: PropTypes.arrayOf(Shapes.Topology),
+ currentTopologyId: PropTypes.string,
+ onChooseTopology: PropTypes.func.isRequired,
+ onNewTopology: PropTypes.func.isRequired,
+ onDeleteTopology: PropTypes.func.isRequired,
+ }
+
+ onChoose(id) {
+ this.props.onChooseTopology(id)
+ }
+
+ onDelete(id) {
+ this.props.onDeleteTopology(id)
+ }
+
+ render() {
+ return (
+ <div className="pb-3">
+ <h2>
+ Topologies
+ <button className="btn btn-outline-primary float-right" onClick={this.props.onNewTopology}>
+ <FontAwesome name="plus" />
+ </button>
+ </h2>
+
+ {this.props.topologies.map((topology, idx) => (
+ <div key={topology._id} className="row mb-1">
+ <div
+ className={
+ 'col-7 align-self-center ' +
+ (topology._id === this.props.currentTopologyId ? 'font-weight-bold' : '')
+ }
+ >
+ {topology.name}
+ </div>
+ <div className="col-5 text-right">
+ <span
+ className="btn btn-outline-primary mr-1 fa fa-play"
+ onClick={() => this.onChoose(topology._id)}
+ />
+ <span
+ className={'btn btn-outline-danger fa fa-trash ' + (idx === 0 ? 'disabled' : '')}
+ onClick={() => (idx !== 0 ? this.onDelete(topology._id) : undefined)}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ )
+ }
+}
+
+export default TopologyListComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/NameComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/NameComponent.js
new file mode 100644
index 00000000..5fb0dc55
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/NameComponent.js
@@ -0,0 +1,13 @@
+import React from 'react'
+import FontAwesome from 'react-fontawesome'
+
+const NameComponent = ({ name, onEdit }) => (
+ <h2>
+ {name}
+ <button className="btn btn-outline-secondary float-right" onClick={onEdit}>
+ <FontAwesome name="pencil" />
+ </button>
+ </h2>
+)
+
+export default NameComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/TopologySidebarComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/TopologySidebarComponent.js
new file mode 100644
index 00000000..f5eee36b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/TopologySidebarComponent.js
@@ -0,0 +1,31 @@
+import React from 'react'
+import BuildingSidebarContainer from '../../../../containers/app/sidebars/topology/building/BuildingSidebarContainer'
+import MachineSidebarContainer from '../../../../containers/app/sidebars/topology/machine/MachineSidebarContainer'
+import RackSidebarContainer from '../../../../containers/app/sidebars/topology/rack/RackSidebarContainer'
+import RoomSidebarContainer from '../../../../containers/app/sidebars/topology/room/RoomSidebarContainer'
+import Sidebar from '../Sidebar'
+
+const TopologySidebarComponent = ({ interactionLevel }) => {
+ let sidebarContent
+
+ switch (interactionLevel.mode) {
+ case 'BUILDING':
+ sidebarContent = <BuildingSidebarContainer />
+ break
+ case 'ROOM':
+ sidebarContent = <RoomSidebarContainer />
+ break
+ case 'RACK':
+ sidebarContent = <RackSidebarContainer />
+ break
+ case 'MACHINE':
+ sidebarContent = <MachineSidebarContainer />
+ break
+ default:
+ sidebarContent = 'Missing Content'
+ }
+
+ return <Sidebar isRight={true}>{sidebarContent}</Sidebar>
+}
+
+export default TopologySidebarComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js
new file mode 100644
index 00000000..eea62f84
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js
@@ -0,0 +1,13 @@
+import React from 'react'
+import NewRoomConstructionContainer from '../../../../../containers/app/sidebars/topology/building/NewRoomConstructionContainer'
+
+const BuildingSidebarComponent = () => {
+ return (
+ <div>
+ <h2>Building</h2>
+ <NewRoomConstructionContainer />
+ </div>
+ )
+}
+
+export default BuildingSidebarComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js
new file mode 100644
index 00000000..fd552c1e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js
@@ -0,0 +1,26 @@
+import React from 'react'
+
+const NewRoomConstructionComponent = ({ onStart, onFinish, onCancel, currentRoomInConstruction }) => {
+ if (currentRoomInConstruction === '-1') {
+ return (
+ <div className="btn btn-outline-primary btn-block" onClick={onStart}>
+ <span className="fa fa-plus mr-2" />
+ Construct a new room
+ </div>
+ )
+ }
+ return (
+ <div>
+ <div className="btn btn-primary btn-block" onClick={onFinish}>
+ <span className="fa fa-check mr-2" />
+ Finalize new room
+ </div>
+ <div className="btn btn-default btn-block" onClick={onCancel}>
+ <span className="fa fa-times mr-2" />
+ Cancel construction
+ </div>
+ </div>
+ )
+}
+
+export default NewRoomConstructionComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/BackToRackComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/BackToRackComponent.js
new file mode 100644
index 00000000..70d522b2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/BackToRackComponent.js
@@ -0,0 +1,10 @@
+import React from 'react'
+
+const BackToRackComponent = ({ onClick }) => (
+ <div className="btn btn-secondary btn-block" onClick={onClick}>
+ <span className="fa fa-angle-left mr-2" />
+ Back to rack
+ </div>
+)
+
+export default BackToRackComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js
new file mode 100644
index 00000000..37820316
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js
@@ -0,0 +1,10 @@
+import React from 'react'
+
+const DeleteMachineComponent = ({ onClick }) => (
+ <div className="btn btn-outline-danger btn-block" onClick={onClick}>
+ <span className="fa fa-trash mr-2" />
+ Delete this machine
+ </div>
+)
+
+export default DeleteMachineComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineNameComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineNameComponent.js
new file mode 100644
index 00000000..992383c4
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineNameComponent.js
@@ -0,0 +1,5 @@
+import React from 'react'
+
+const MachineNameComponent = ({ position }) => <h2>Machine at slot {position}</h2>
+
+export default MachineNameComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js
new file mode 100644
index 00000000..7c78cf9e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js
@@ -0,0 +1,18 @@
+import React from 'react'
+import BackToRackContainer from '../../../../../containers/app/sidebars/topology/machine/BackToRackContainer'
+import DeleteMachineContainer from '../../../../../containers/app/sidebars/topology/machine/DeleteMachineContainer'
+import MachineNameContainer from '../../../../../containers/app/sidebars/topology/machine/MachineNameContainer'
+import UnitTabsContainer from '../../../../../containers/app/sidebars/topology/machine/UnitTabsContainer'
+
+const MachineSidebarComponent = ({ machineId }) => {
+ return (
+ <div className="h-100 overflow-auto">
+ <MachineNameContainer />
+ <BackToRackContainer />
+ <DeleteMachineContainer />
+ <UnitTabsContainer />
+ </div>
+ )
+}
+
+export default MachineSidebarComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitAddComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitAddComponent.js
new file mode 100644
index 00000000..4e9dbc7e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitAddComponent.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+
+class UnitAddComponent extends React.Component {
+ static propTypes = {
+ units: PropTypes.array.isRequired,
+ onAdd: PropTypes.func.isRequired,
+ }
+
+ render() {
+ return (
+ <div className="form-inline">
+ <div className="form-group w-100">
+ <select className="form-control w-70 mr-1" ref={(unitSelect) => (this.unitSelect = unitSelect)}>
+ {this.props.units.map((unit) => (
+ <option value={unit._id} key={unit._id}>
+ {unit.name}
+ </option>
+ ))}
+ </select>
+ <button
+ type="submit"
+ className="btn btn-outline-primary"
+ onClick={() => this.props.onAdd(this.unitSelect.value)}
+ >
+ <span className="fa fa-plus mr-2" />
+ Add
+ </button>
+ </div>
+ </div>
+ )
+ }
+}
+
+export default UnitAddComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitComponent.js
new file mode 100644
index 00000000..de55e506
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitComponent.js
@@ -0,0 +1,52 @@
+import React from 'react'
+import { UncontrolledPopover, PopoverHeader, PopoverBody, Button } from 'reactstrap'
+
+function UnitComponent({ index, unitType, unit, onDelete }) {
+ let unitInfo
+ if (unitType === 'cpu' || unitType === 'gpu') {
+ unitInfo = (
+ <>
+ <strong>Clockrate: </strong>
+ <code>{unit.clockRateMhz}</code>
+ <br />
+ <strong>Num. Cores: </strong>
+ <code>{unit.numberOfCores}</code>
+ <br />
+ <strong>Energy Cons.: </strong>
+ <code>{unit.energyConsumptionW} W</code>
+ <br />
+ </>
+ )
+ } else if (unitType === 'memory' || unitType === 'storage') {
+ unitInfo = (
+ <>
+ <strong>Speed:</strong>
+ <code>{unit.speedMbPerS} Mb/s</code>
+ <br />
+ <strong>Size:</strong>
+ <code>{unit.sizeMb} MB</code>
+ <br />
+ <strong>Energy Cons.:</strong>
+ <code>{unit.energyConsumptionW} W</code>
+ <br />
+ </>
+ )
+ }
+
+ return (
+ <li className="d-flex list-group-item justify-content-between align-items-center">
+ <span style={{ maxWidth: '60%' }}>{unit.name}</span>
+ <span>
+ <Button outline={true} color="info" className="mr-1 fa fa-info-circle" id={`unit-${index}`} />
+ <UncontrolledPopover trigger="focus" placement="left" target={`unit-${index}`}>
+ <PopoverHeader>Unit Information</PopoverHeader>
+ <PopoverBody>{unitInfo}</PopoverBody>
+ </UncontrolledPopover>
+
+ <span className="btn btn-outline-danger fa fa-trash" onClick={onDelete} />
+ </span>
+ </li>
+ )
+}
+
+export default UnitComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitListComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitListComponent.js
new file mode 100644
index 00000000..2ade0f6a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitListComponent.js
@@ -0,0 +1,20 @@
+import React from 'react'
+import UnitContainer from '../../../../../containers/app/sidebars/topology/machine/UnitContainer'
+
+const UnitListComponent = ({ unitType, unitIds }) => (
+ <ul className="list-group mt-1">
+ {unitIds.length !== 0 ? (
+ unitIds.map((unitId, index) => (
+ <UnitContainer unitType={unitType} unitId={unitId} index={index} key={index} />
+ ))
+ ) : (
+ <div className="alert alert-info">
+ <span>
+ <strong>No units...</strong> Add some with the menu above!
+ </span>
+ </div>
+ )}
+ </ul>
+)
+
+export default UnitListComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitTabsComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitTabsComponent.js
new file mode 100644
index 00000000..6599fefd
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/machine/UnitTabsComponent.js
@@ -0,0 +1,78 @@
+import React, { useState } from 'react'
+import { Nav, NavItem, NavLink, TabContent, TabPane } from 'reactstrap'
+import UnitAddContainer from '../../../../../containers/app/sidebars/topology/machine/UnitAddContainer'
+import UnitListContainer from '../../../../../containers/app/sidebars/topology/machine/UnitListContainer'
+
+const UnitTabsComponent = () => {
+ const [activeTab, setActiveTab] = useState('cpu-units')
+ const toggle = (tab) => {
+ if (activeTab !== tab) setActiveTab(tab)
+ }
+
+ return (
+ <div>
+ <Nav tabs>
+ <NavItem>
+ <NavLink
+ className={activeTab === 'cpu-units' ? 'active' : ''}
+ onClick={() => {
+ toggle('cpu-units')
+ }}
+ >
+ CPU
+ </NavLink>
+ </NavItem>
+ <NavItem>
+ <NavLink
+ className={activeTab === 'gpu-units' ? 'active' : ''}
+ onClick={() => {
+ toggle('gpu-units')
+ }}
+ >
+ GPU
+ </NavLink>
+ </NavItem>
+ <NavItem>
+ <NavLink
+ className={activeTab === 'memory-units' ? 'active' : ''}
+ onClick={() => {
+ toggle('memory-units')
+ }}
+ >
+ Memory
+ </NavLink>
+ </NavItem>
+ <NavItem>
+ <NavLink
+ className={activeTab === 'storage-units' ? 'active' : ''}
+ onClick={() => {
+ toggle('storage-units')
+ }}
+ >
+ Stor.
+ </NavLink>
+ </NavItem>
+ </Nav>
+ <TabContent activeTab={activeTab}>
+ <TabPane tabId="cpu-units">
+ <UnitAddContainer unitType="cpu" />
+ <UnitListContainer unitType="cpu" />
+ </TabPane>
+ <TabPane tabId="gpu-units">
+ <UnitAddContainer unitType="gpu" />
+ <UnitListContainer unitType="gpu" />
+ </TabPane>
+ <TabPane tabId="memory-units">
+ <UnitAddContainer unitType="memory" />
+ <UnitListContainer unitType="memory" />
+ </TabPane>
+ <TabPane tabId="storage-units">
+ <UnitAddContainer unitType="storage" />
+ <UnitListContainer unitType="storage" />
+ </TabPane>
+ </TabContent>
+ </div>
+ )
+}
+
+export default UnitTabsComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/AddPrefabComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/AddPrefabComponent.js
new file mode 100644
index 00000000..75418f9d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/AddPrefabComponent.js
@@ -0,0 +1,10 @@
+import React from 'react'
+
+const AddPrefabComponent = ({ onClick }) => (
+ <div className="btn btn-primary btn-block" onClick={onClick}>
+ <span className="fa fa-floppy-o mr-2" />
+ Save this rack to a prefab
+ </div>
+)
+
+export default AddPrefabComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/BackToRoomComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/BackToRoomComponent.js
new file mode 100644
index 00000000..c14775bf
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/BackToRoomComponent.js
@@ -0,0 +1,10 @@
+import React from 'react'
+
+const BackToRoomComponent = ({ onClick }) => (
+ <div className="btn btn-secondary btn-block mb-2" onClick={onClick}>
+ <span className="fa fa-angle-left mr-2" />
+ Back to room
+ </div>
+)
+
+export default BackToRoomComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/DeleteRackComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/DeleteRackComponent.js
new file mode 100644
index 00000000..23b0daac
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/DeleteRackComponent.js
@@ -0,0 +1,10 @@
+import React from 'react'
+
+const DeleteRackComponent = ({ onClick }) => (
+ <div className="btn btn-outline-danger btn-block" onClick={onClick}>
+ <span className="fa fa-trash mr-2" />
+ Delete this rack
+ </div>
+)
+
+export default DeleteRackComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/EmptySlotComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/EmptySlotComponent.js
new file mode 100644
index 00000000..d7e30f1d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/EmptySlotComponent.js
@@ -0,0 +1,13 @@
+import React from 'react'
+
+const EmptySlotComponent = ({ position, onAdd }) => (
+ <li className="list-group-item d-flex justify-content-between align-items-center">
+ <span className="badge badge-default badge-info mr-1 disabled">{position}</span>
+ <button className="btn btn-outline-primary" onClick={onAdd}>
+ <span className="fa fa-plus mr-2" />
+ Add machine
+ </button>
+ </li>
+)
+
+export default EmptySlotComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineComponent.js
new file mode 100644
index 00000000..caa3dc04
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineComponent.js
@@ -0,0 +1,43 @@
+import React from 'react'
+import Shapes from '../../../../../shapes'
+
+const UnitIcon = ({ id, type }) => (
+ <div>
+ <img
+ src={'/img/topology/' + id + '-icon.png'}
+ alt={'Machine contains ' + type + ' units'}
+ className="img-fluid ml-1"
+ style={{ maxHeight: '35px' }}
+ />
+ </div>
+)
+
+const MachineComponent = ({ position, machine, onClick }) => {
+ const hasNoUnits =
+ machine.cpuIds.length + machine.gpuIds.length + machine.memoryIds.length + machine.storageIds.length === 0
+
+ return (
+ <li
+ className="d-flex list-group-item list-group-item-action justify-content-between align-items-center"
+ onClick={onClick}
+ style={{ backgroundColor: 'white' }}
+ >
+ <span className="badge badge-default badge-info mr-1">{position}</span>
+ <div className="d-inline-flex">
+ {machine.cpuIds.length > 0 ? <UnitIcon id="cpu" type="CPU" /> : undefined}
+ {machine.gpuIds.length > 0 ? <UnitIcon id="gpu" type="GPU" /> : undefined}
+ {machine.memoryIds.length > 0 ? <UnitIcon id="memory" type="memory" /> : undefined}
+ {machine.storageIds.length > 0 ? <UnitIcon id="storage" type="storage" /> : undefined}
+ {hasNoUnits ? (
+ <span className="badge badge-default badge-warning">Machine with no units</span>
+ ) : undefined}
+ </div>
+ </li>
+ )
+}
+
+MachineComponent.propTypes = {
+ machine: Shapes.Machine,
+}
+
+export default MachineComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.js
new file mode 100644
index 00000000..12be26bd
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.js
@@ -0,0 +1,20 @@
+import React from 'react'
+import EmptySlotContainer from '../../../../../containers/app/sidebars/topology/rack/EmptySlotContainer'
+import MachineContainer from '../../../../../containers/app/sidebars/topology/rack/MachineContainer'
+import './MachineListComponent.sass'
+
+const MachineListComponent = ({ machineIds }) => {
+ return (
+ <ul className="list-group machine-list">
+ {machineIds.map((machineId, index) => {
+ if (machineId === null) {
+ return <EmptySlotContainer key={index} position={index + 1} />
+ } else {
+ return <MachineContainer key={index} position={index + 1} machineId={machineId} />
+ }
+ })}
+ </ul>
+ )
+}
+
+export default MachineListComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.sass b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.sass
new file mode 100644
index 00000000..11b82c93
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/MachineListComponent.sass
@@ -0,0 +1,2 @@
+.machine-list li
+ min-height: 64px
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackNameComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackNameComponent.js
new file mode 100644
index 00000000..b701909a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackNameComponent.js
@@ -0,0 +1,6 @@
+import React from 'react'
+import NameComponent from '../NameComponent'
+
+const RackNameComponent = ({ rackName, onEdit }) => <NameComponent name={rackName} onEdit={onEdit} />
+
+export default RackNameComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.js
new file mode 100644
index 00000000..ca41bf57
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.js
@@ -0,0 +1,25 @@
+import React from 'react'
+import BackToRoomContainer from '../../../../../containers/app/sidebars/topology/rack/BackToRoomContainer'
+import DeleteRackContainer from '../../../../../containers/app/sidebars/topology/rack/DeleteRackContainer'
+import MachineListContainer from '../../../../../containers/app/sidebars/topology/rack/MachineListContainer'
+import RackNameContainer from '../../../../../containers/app/sidebars/topology/rack/RackNameContainer'
+import './RackSidebarComponent.sass'
+import AddPrefabContainer from '../../../../../containers/app/sidebars/topology/rack/AddPrefabContainer'
+
+const RackSidebarComponent = () => {
+ return (
+ <div className="rack-sidebar-container flex-column">
+ <div className="rack-sidebar-header-container">
+ <RackNameContainer />
+ <BackToRoomContainer />
+ <AddPrefabContainer />
+ <DeleteRackContainer />
+ </div>
+ <div className="machine-list-container mt-2">
+ <MachineListContainer />
+ </div>
+ </div>
+ )
+}
+
+export default RackSidebarComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass
new file mode 100644
index 00000000..29fec02a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass
@@ -0,0 +1,11 @@
+.rack-sidebar-container
+ display: flex
+ height: 100%
+ max-height: 100%
+
+.rack-sidebar-header-container
+ flex: 0
+
+.machine-list-container
+ flex: 1
+ overflow-y: scroll
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/BackToBuildingComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/BackToBuildingComponent.js
new file mode 100644
index 00000000..64c0a1f6
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/BackToBuildingComponent.js
@@ -0,0 +1,10 @@
+import React from 'react'
+
+const BackToBuildingComponent = ({ onClick }) => (
+ <div className="btn btn-secondary btn-block mb-2" onClick={onClick}>
+ <span className="fa fa-angle-left mr-2" />
+ Back to building
+ </div>
+)
+
+export default BackToBuildingComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/DeleteRoomComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/DeleteRoomComponent.js
new file mode 100644
index 00000000..78417359
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/DeleteRoomComponent.js
@@ -0,0 +1,10 @@
+import React from 'react'
+
+const DeleteRoomComponent = ({ onClick }) => (
+ <div className="btn btn-outline-danger btn-block" onClick={onClick}>
+ <span className="fa fa-trash mr-2" />
+ Delete this room
+ </div>
+)
+
+export default DeleteRoomComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/EditRoomComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/EditRoomComponent.js
new file mode 100644
index 00000000..857a646f
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/EditRoomComponent.js
@@ -0,0 +1,22 @@
+import classNames from 'classnames'
+import React from 'react'
+
+const EditRoomComponent = ({ onEdit, onFinish, isEditing, isInRackConstructionMode }) =>
+ isEditing ? (
+ <div className="btn btn-info btn-block" onClick={onFinish}>
+ <span className="fa fa-check mr-2" />
+ Finish editing room
+ </div>
+ ) : (
+ <div
+ className={classNames('btn btn-outline-info btn-block', {
+ disabled: isInRackConstructionMode,
+ })}
+ onClick={() => (isInRackConstructionMode ? undefined : onEdit())}
+ >
+ <span className="fa fa-pencil mr-2" />
+ Edit the tiles of this room
+ </div>
+ )
+
+export default EditRoomComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RackConstructionComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RackConstructionComponent.js
new file mode 100644
index 00000000..44566f61
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RackConstructionComponent.js
@@ -0,0 +1,27 @@
+import classNames from 'classnames'
+import React from 'react'
+
+const RackConstructionComponent = ({ onStart, onStop, inRackConstructionMode, isEditingRoom }) => {
+ if (inRackConstructionMode) {
+ return (
+ <div className="btn btn-primary btn-block" onClick={onStop}>
+ <span className="fa fa-times mr-2" />
+ Stop rack construction
+ </div>
+ )
+ }
+
+ return (
+ <div
+ className={classNames('btn btn-outline-primary btn-block', {
+ disabled: isEditingRoom,
+ })}
+ onClick={() => (isEditingRoom ? undefined : onStart())}
+ >
+ <span className="fa fa-plus mr-2" />
+ Start rack construction
+ </div>
+ )
+}
+
+export default RackConstructionComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomNameComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomNameComponent.js
new file mode 100644
index 00000000..d637828e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomNameComponent.js
@@ -0,0 +1,6 @@
+import React from 'react'
+import NameComponent from '../NameComponent'
+
+const RoomNameComponent = ({ roomName, onEdit }) => <NameComponent name={roomName} onEdit={onEdit} />
+
+export default RoomNameComponent
diff --git a/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomSidebarComponent.js b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomSidebarComponent.js
new file mode 100644
index 00000000..1bc6533e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/app/sidebars/topology/room/RoomSidebarComponent.js
@@ -0,0 +1,20 @@
+import React from 'react'
+import BackToBuildingContainer from '../../../../../containers/app/sidebars/topology/room/BackToBuildingContainer'
+import DeleteRoomContainer from '../../../../../containers/app/sidebars/topology/room/DeleteRoomContainer'
+import EditRoomContainer from '../../../../../containers/app/sidebars/topology/room/EditRoomContainer'
+import RackConstructionContainer from '../../../../../containers/app/sidebars/topology/room/RackConstructionContainer'
+import RoomNameContainer from '../../../../../containers/app/sidebars/topology/room/RoomNameContainer'
+
+const RoomSidebarComponent = () => {
+ return (
+ <div>
+ <RoomNameContainer />
+ <BackToBuildingContainer />
+ <RackConstructionContainer />
+ <EditRoomContainer />
+ <DeleteRoomContainer />
+ </div>
+ )
+}
+
+export default RoomSidebarComponent
diff --git a/opendc-web/opendc-web-ui/src/components/home/ContactSection.js b/opendc-web/opendc-web-ui/src/components/home/ContactSection.js
new file mode 100644
index 00000000..42bdab8a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/ContactSection.js
@@ -0,0 +1,54 @@
+import React from 'react'
+import FontAwesome from 'react-fontawesome'
+import './ContactSection.sass'
+import ContentSection from './ContentSection'
+
+const ContactSection = () => (
+ <ContentSection name="contact" title="Contact">
+ <div className="row justify-content-center">
+ <div className="col-4">
+ <a href="https://github.com/atlarge-research/opendc">
+ <FontAwesome name="github" size="3x" className="mb-2" />
+ <div className="w-100" />
+ atlarge-research/opendc
+ </a>
+ </div>
+ <div className="col-4">
+ <a href="mailto:opendc@atlarge-research.com">
+ <FontAwesome name="envelope" size="3x" className="mb-2" />
+ <div className="w-100" />
+ opendc@atlarge-research.com
+ </a>
+ </div>
+ </div>
+ <div className="row">
+ <div className="col text-center">
+ <img src="img/tudelft-icon.png" className="img-fluid tudelft-icon" alt="TU Delft" />
+ </div>
+ </div>
+ <div className="row">
+ <div className="col text-center">
+ A project by the &nbsp;
+ <a href="http://atlarge.science" target="_blank" rel="noopener noreferrer">
+ <strong>@Large Research Group</strong>
+ </a>
+ .
+ </div>
+ </div>
+ <div className="row">
+ <div className="col text-center disclaimer mt-5 small">
+ <FontAwesome name="exclamation-triangle" size="2x" className="mr-2" />
+ <br />
+ OpenDC is an experimental tool. Your data may get lost, overwritten, or otherwise become unavailable.
+ <br />
+ The OpenDC authors should in no way be liable in the event this happens (see our{' '}
+ <strong>
+ <a href="https://github.com/atlarge-research/opendc/blob/master/LICENSE.md">license</a>
+ </strong>
+ ). Sorry for the inconvenience.
+ </div>
+ </div>
+ </ContentSection>
+)
+
+export default ContactSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/ContactSection.sass b/opendc-web/opendc-web-ui/src/components/home/ContactSection.sass
new file mode 100644
index 00000000..997f8d98
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/ContactSection.sass
@@ -0,0 +1,15 @@
+.contact-section
+ background-color: #444
+ color: #ddd
+
+ a
+ color: #ddd
+
+ a:hover
+ color: #fff
+
+ .tudelft-icon
+ height: 100px
+
+ .disclaimer
+ color: #cccccc
diff --git a/opendc-web/opendc-web-ui/src/components/home/ContentSection.js b/opendc-web/opendc-web-ui/src/components/home/ContentSection.js
new file mode 100644
index 00000000..9d4832d9
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/ContentSection.js
@@ -0,0 +1,19 @@
+import classNames from 'classnames'
+import PropTypes from 'prop-types'
+import React from 'react'
+import './ContentSection.sass'
+
+const ContentSection = ({ name, title, children }) => (
+ <div id={name} className={classNames(name + '-section', 'content-section')}>
+ <div className="container">
+ <h1>{title}</h1>
+ {children}
+ </div>
+ </div>
+)
+
+ContentSection.propTypes = {
+ name: PropTypes.string.isRequired,
+}
+
+export default ContentSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/ContentSection.sass b/opendc-web/opendc-web-ui/src/components/home/ContentSection.sass
new file mode 100644
index 00000000..a4c8bd66
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/ContentSection.sass
@@ -0,0 +1,9 @@
+@import ../../style-globals/_variables.sass
+
+.content-section
+ padding-top: 50px
+ padding-bottom: 150px
+ text-align: center
+
+ h1
+ margin-bottom: 30px
diff --git a/opendc-web/opendc-web-ui/src/components/home/IntroSection.js b/opendc-web/opendc-web-ui/src/components/home/IntroSection.js
new file mode 100644
index 00000000..a799272a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/IntroSection.js
@@ -0,0 +1,40 @@
+import React from 'react'
+
+const IntroSection = () => (
+ <section id="intro" className="intro-section">
+ <div className="container pt-5 pb-3">
+ <div className="row justify-content-center">
+ <div className="col-xl-4 col-lg-4 col-md-4 col-sm-8 col-8">
+ <h4>The datacenter (DC) industry...</h4>
+ <ul>
+ <li>Is worth over $15 bn, and growing</li>
+ <li>Has many hard-to-grasp concepts</li>
+ <li>Needs to become accessible to many</li>
+ </ul>
+ </div>
+ <div className="col-xl-4 col-lg-4 col-md-4 col-sm-8 col-8">
+ <img
+ src="img/datacenter-drawing.png"
+ className="col-12 img-fluid"
+ alt="Schematic top-down view of a datacenter"
+ />
+ <p className="col-12 figure-caption text-center">
+ <a href="http://www.dolphinhosts.co.uk/wp-content/uploads/2013/07/data-centers.gif">
+ Image source
+ </a>
+ </p>
+ </div>
+ <div className="col-xl-4 col-lg-4 col-md-4 col-sm-8 col-8">
+ <h4>OpenDC provides...</h4>
+ <ul>
+ <li>Collaborative online DC modeling</li>
+ <li>Diverse and effective DC simulation</li>
+ <li>Exploratory DC performance feedback</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </section>
+)
+
+export default IntroSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.js b/opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.js
new file mode 100644
index 00000000..7b410679
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.js
@@ -0,0 +1,18 @@
+import React from 'react'
+import './JumbotronHeader.sass'
+
+const JumbotronHeader = () => (
+ <section className="jumbotron-header">
+ <div className="container">
+ <div className="jumbotron text-center">
+ <h1>
+ Open<span className="dc">DC</span>
+ </h1>
+ <p className="lead">Collaborative Datacenter Simulation and Exploration for Everybody</p>
+ <img src="img/logo.png" className="img-responsive mt-3" alt="OpenDC" />
+ </div>
+ </div>
+ </section>
+)
+
+export default JumbotronHeader
diff --git a/opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.sass b/opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.sass
new file mode 100644
index 00000000..1b6a89fd
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/JumbotronHeader.sass
@@ -0,0 +1,24 @@
+.jumbotron-header
+ background: #00A6D6
+
+.jumbotron
+ background-color: inherit
+ margin-bottom: 0
+
+ padding-top: 120px
+ padding-bottom: 120px
+
+ img
+ max-width: 110px
+
+ h1
+ color: #fff
+ font-size: 4.5em
+
+ .dc
+ color: #fff
+ font-weight: bold
+
+ .lead
+ color: #fff
+ font-size: 1.4em
diff --git a/opendc-web/opendc-web-ui/src/components/home/ModelingSection.js b/opendc-web/opendc-web-ui/src/components/home/ModelingSection.js
new file mode 100644
index 00000000..643dca65
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/ModelingSection.js
@@ -0,0 +1,22 @@
+import React from 'react'
+import ScreenshotSection from './ScreenshotSection'
+
+const ModelingSection = () => (
+ <ScreenshotSection
+ name="modeling"
+ title="Datacenter Modeling"
+ imageUrl="/img/screenshot-construction.png"
+ caption="Building a datacenter in OpenDC"
+ imageIsRight={true}
+ >
+ <h3>Collaboratively...</h3>
+ <ul>
+ <li>Model DC layout, and room locations and types</li>
+ <li>Place racks in rooms and nodes in racks</li>
+ <li>Add real-world CPU, GPU, memory, storage and network units to each node</li>
+ <li>Select from diverse scheduling policies</li>
+ </ul>
+ </ScreenshotSection>
+)
+
+export default ModelingSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.js b/opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.js
new file mode 100644
index 00000000..c987d5d0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.js
@@ -0,0 +1,24 @@
+import classNames from 'classnames'
+import React from 'react'
+import ContentSection from './ContentSection'
+import './ScreenshotSection.sass'
+
+const ScreenshotSection = ({ name, title, imageUrl, caption, imageIsRight, children }) => (
+ <ContentSection name={name} title={title}>
+ <div className="row">
+ <div
+ className={classNames('col-xl-5 col-lg-5 col-md-5 col-sm-12 col-12 text-left', {
+ 'order-1': !imageIsRight,
+ })}
+ >
+ {children}
+ </div>
+ <div className="col-xl-7 col-lg-7 col-md-7 col-sm-12 col-12">
+ <img src={imageUrl} className="col-12 screenshot" alt={caption} />
+ <div className="row text-muted justify-content-center">{caption}</div>
+ </div>
+ </div>
+ </ContentSection>
+)
+
+export default ScreenshotSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.sass b/opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.sass
new file mode 100644
index 00000000..2f454cb4
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/ScreenshotSection.sass
@@ -0,0 +1,5 @@
+.screenshot
+ outline: 2px black solid
+ padding-left: 0
+ padding-right: 0
+ margin-bottom: 5px
diff --git a/opendc-web/opendc-web-ui/src/components/home/SimulationSection.js b/opendc-web/opendc-web-ui/src/components/home/SimulationSection.js
new file mode 100644
index 00000000..b0244cb5
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/SimulationSection.js
@@ -0,0 +1,22 @@
+import React from 'react'
+import ScreenshotSection from './ScreenshotSection'
+
+const ModelingSection = () => (
+ <ScreenshotSection
+ name="project"
+ title="Datacenter Simulation"
+ imageUrl="/img/screenshot-simulation-zoom.png"
+ caption="Running an experiment in OpenDC"
+ imageIsRight={false}
+ >
+ <h3>Working with OpenDC:</h3>
+ <ul>
+ <li>Seamlessly switch between construction and simulation modes</li>
+ <li>Choose one of several predefined workloads (Big Data, Bag of Tasks, Hadoop, etc.)</li>
+ <li>Play, pause, and skip around the informative simulation timeline</li>
+ <li>Visualize and demo live</li>
+ </ul>
+ </ScreenshotSection>
+)
+
+export default ModelingSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/StakeholderSection.js b/opendc-web/opendc-web-ui/src/components/home/StakeholderSection.js
new file mode 100644
index 00000000..e5ed9683
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/StakeholderSection.js
@@ -0,0 +1,30 @@
+import React from 'react'
+import ContentSection from './ContentSection'
+
+const Stakeholder = ({ name, title, subtitle }) => (
+ <div className="col-xl-4 col-lg-4 col-md-4 col-sm-6 col-6">
+ <img
+ src={'img/stakeholders/' + name + '.png'}
+ className="col-xl-3 col-lg-4 col-md-4 col-sm-4 col-4 img-fluid"
+ alt={title}
+ />
+ <div className="text-center mt-2">
+ <h4>{title}</h4>
+ <p>{subtitle}</p>
+ </div>
+ </div>
+)
+
+const StakeholderSection = () => (
+ <ContentSection name="stakeholders" title="Stakeholders">
+ <div className="row justify-content-center">
+ <Stakeholder name="Manager" title="Managers" subtitle="Seeing is deciding" />
+ <Stakeholder name="Sales" title="Sales" subtitle="Demo concepts" />
+ <Stakeholder name="Developer" title="DevOps" subtitle="Develop & tune" />
+ <Stakeholder name="Researcher" title="Researchers" subtitle="Understand & design" />
+ <Stakeholder name="Student" title="Students" subtitle="Grasp complex concepts" />
+ </div>
+ </ContentSection>
+)
+
+export default StakeholderSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/TeamSection.js b/opendc-web/opendc-web-ui/src/components/home/TeamSection.js
new file mode 100644
index 00000000..4b6f1e25
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/TeamSection.js
@@ -0,0 +1,53 @@
+import React from 'react'
+import ContentSection from './ContentSection'
+
+const TeamMember = ({ photoId, name, description }) => (
+ <div className="col-xl-4 col-lg-4 col-md-5 col-sm-6 col-12 justify-content-center">
+ <img
+ src={'img/portraits/' + photoId + '.png'}
+ className="col-xl-10 col-lg-10 col-md-10 col-sm-8 col-5 mb-2 mt-2"
+ alt={name}
+ />
+ <div className="col-12">
+ <h4>{name}</h4>
+ <div className="team-member-description">{description}</div>
+ </div>
+ </div>
+)
+
+const TeamSection = () => (
+ <ContentSection name="team" title="Core Team">
+ <div className="row justify-content-center">
+ <TeamMember photoId="aiosup" name="Prof. dr. ir. Alexandru Iosup" description="Project Lead" />
+ <TeamMember
+ photoId="gandreadis"
+ name="Georgios Andreadis"
+ description="Software Engineer responsible for the frontend web application"
+ />
+ <TeamMember
+ photoId="fmastenbroek"
+ name="Fabian Mastenbroek"
+ description="Software Engineer responsible for the datacenter simulator"
+ />
+ <TeamMember
+ photoId="jburley"
+ name="Jacob Burley"
+ description="Software Engineer responsible for prefabricated components"
+ />
+ <TeamMember
+ photoId="loverweel"
+ name="Leon Overweel"
+ description="Former product lead and Software Engineer"
+ />
+ </div>
+ <div className="text-center lead mt-3">
+ See{' '}
+ <a target="_blank" href="http://atlarge.science/opendc#team" rel="noopener noreferrer">
+ atlarge.science/opendc
+ </a>{' '}
+ for the full team!
+ </div>
+ </ContentSection>
+)
+
+export default TeamSection
diff --git a/opendc-web/opendc-web-ui/src/components/home/TechnologiesSection.js b/opendc-web/opendc-web-ui/src/components/home/TechnologiesSection.js
new file mode 100644
index 00000000..c6013c71
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/home/TechnologiesSection.js
@@ -0,0 +1,40 @@
+import React from 'react'
+import FontAwesome from 'react-fontawesome'
+import ContentSection from './ContentSection'
+
+const TechnologiesSection = () => (
+ <ContentSection name="technologies" title="Technologies">
+ <ul className="list-group text-left">
+ <li className="d-flex list-group-item justify-content-between align-items-center list-group-item-primary">
+ <span style={{ minWidth: 100 }}>
+ <FontAwesome name="window-maximize" className="mr-2" />
+ <strong className="">Browser</strong>
+ </span>
+ <span className="text-right">JavaScript, React, Redux, Konva</span>
+ </li>
+ <li className="d-flex list-group-item justify-content-between align-items-center list-group-item-warning">
+ <span style={{ minWidth: 100 }}>
+ <FontAwesome name="television" className="mr-2" />
+ <strong>Server</strong>
+ </span>
+ <span className="text-right">Python, Flask, FlaskSocketIO, OpenAPI</span>
+ </li>
+ <li className="d-flex list-group-item justify-content-between align-items-center list-group-item-success">
+ <span style={{ minWidth: 100 }}>
+ <FontAwesome name="database" className="mr-2" />
+ <strong>Database</strong>
+ </span>
+ <span className="text-right">MongoDB</span>
+ </li>
+ <li className="d-flex list-group-item justify-content-between align-items-center list-group-item-danger">
+ <span style={{ minWidth: 100 }}>
+ <FontAwesome name="cogs" className="mr-2" />
+ <strong>Simulator</strong>
+ </span>
+ <span className="text-right">Kotlin</span>
+ </li>
+ </ul>
+ </ContentSection>
+)
+
+export default TechnologiesSection
diff --git a/opendc-web/opendc-web-ui/src/components/modals/ConfirmationModal.js b/opendc-web/opendc-web-ui/src/components/modals/ConfirmationModal.js
new file mode 100644
index 00000000..589047dc
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/modals/ConfirmationModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import Modal from './Modal'
+
+class ConfirmationModal extends React.Component {
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired,
+ callback: PropTypes.func.isRequired,
+ }
+
+ onConfirm() {
+ this.props.callback(true)
+ }
+
+ onCancel() {
+ this.props.callback(false)
+ }
+
+ render() {
+ return (
+ <Modal
+ title={this.props.title}
+ show={this.props.show}
+ onSubmit={this.onConfirm.bind(this)}
+ onCancel={this.onCancel.bind(this)}
+ submitButtonType="danger"
+ submitButtonText="Confirm"
+ >
+ {this.props.message}
+ </Modal>
+ )
+ }
+}
+
+export default ConfirmationModal
diff --git a/opendc-web/opendc-web-ui/src/components/modals/Modal.js b/opendc-web/opendc-web-ui/src/components/modals/Modal.js
new file mode 100644
index 00000000..21b7f119
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/modals/Modal.js
@@ -0,0 +1,53 @@
+import React, { useState, useEffect } from 'react'
+import PropTypes from 'prop-types'
+import { Modal as RModal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'
+
+function Modal({ children, title, show, onSubmit, onCancel, submitButtonType, submitButtonText }) {
+ const [modal, setModal] = useState(show)
+
+ useEffect(() => setModal(show), [show])
+
+ const toggle = () => setModal(!modal)
+ const cancel = () => {
+ if (onCancel() !== false) {
+ toggle()
+ }
+ }
+ const submit = () => {
+ if (onSubmit() !== false) {
+ toggle()
+ }
+ }
+
+ return (
+ <RModal isOpen={modal} toggle={cancel}>
+ <ModalHeader toggle={cancel}>{title}</ModalHeader>
+ <ModalBody>{children}</ModalBody>
+ <ModalFooter>
+ <Button color="secondary" onClick={cancel}>
+ Close
+ </Button>
+ <Button color={submitButtonType} onClick={submit}>
+ {submitButtonText}
+ </Button>
+ </ModalFooter>
+ </RModal>
+ )
+}
+
+Modal.propTypes = {
+ title: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ submitButtonType: PropTypes.string,
+ submitButtonText: PropTypes.string,
+}
+
+Modal.defaultProps = {
+ submitButtonType: 'primary',
+ submitButtonText: 'Save',
+ show: false,
+}
+
+export default Modal
diff --git a/opendc-web/opendc-web-ui/src/components/modals/TextInputModal.js b/opendc-web/opendc-web-ui/src/components/modals/TextInputModal.js
new file mode 100644
index 00000000..d0918c7e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/modals/TextInputModal.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import Modal from './Modal'
+
+class TextInputModal extends React.Component {
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired,
+ callback: PropTypes.func.isRequired,
+ initialValue: PropTypes.string,
+ }
+
+ componentDidUpdate() {
+ if (this.props.initialValue && this.textInput) {
+ this.textInput.value = this.props.initialValue
+ }
+ }
+
+ onSubmit() {
+ this.props.callback(this.textInput.value)
+ this.textInput.value = ''
+ }
+
+ onCancel() {
+ this.props.callback(undefined)
+ this.textInput.value = ''
+ }
+
+ render() {
+ return (
+ <Modal
+ title={this.props.title}
+ show={this.props.show}
+ onSubmit={this.onSubmit.bind(this)}
+ onCancel={this.onCancel.bind(this)}
+ >
+ <form
+ onSubmit={(e) => {
+ e.preventDefault()
+ this.onSubmit()
+ }}
+ >
+ <div className="form-group">
+ <label className="form-control-label">{this.props.label}</label>
+ <input type="text" className="form-control" ref={(textInput) => (this.textInput = textInput)} />
+ </div>
+ </form>
+ </Modal>
+ )
+ }
+}
+
+export default TextInputModal
diff --git a/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewPortfolioModalComponent.js b/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewPortfolioModalComponent.js
new file mode 100644
index 00000000..3c6b8724
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewPortfolioModalComponent.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types'
+import React, { useRef } from 'react'
+import { Form, FormGroup, Input, Label } from 'reactstrap'
+import Modal from '../Modal'
+import { AVAILABLE_METRICS, METRIC_NAMES } from '../../../util/available-metrics'
+
+const NewPortfolioModalComponent = ({ show, callback }) => {
+ const form = useRef(null)
+ const textInput = useRef(null)
+ const repeatsInput = useRef(null)
+ const metricCheckboxes = useRef({})
+
+ const onSubmit = () => {
+ if (form.current.reportValidity()) {
+ callback(textInput.current.value, {
+ enabledMetrics: AVAILABLE_METRICS.filter((metric) => metricCheckboxes.current[metric].checked),
+ repeatsPerScenario: parseInt(repeatsInput.current.value),
+ })
+
+ return true
+ } else {
+ return false
+ }
+ }
+ const onCancel = () => callback(undefined)
+
+ return (
+ <Modal title="New Portfolio" show={show} onSubmit={onSubmit} onCancel={onCancel}>
+ <Form
+ onSubmit={(e) => {
+ e.preventDefault()
+ this.onSubmit()
+ }}
+ innerRef={form}
+ >
+ <FormGroup>
+ <Label for="name">Name</Label>
+ <Input name="name" type="text" required innerRef={textInput} placeholder="My Portfolio" />
+ </FormGroup>
+ <h4>Targets</h4>
+ <h5>Metrics</h5>
+ <FormGroup>
+ {AVAILABLE_METRICS.map((metric) => (
+ <FormGroup check key={metric}>
+ <Label for={metric} check>
+ <Input
+ name={metric}
+ type="checkbox"
+ innerRef={(ref) => (metricCheckboxes.current[metric] = ref)}
+ />
+ {METRIC_NAMES[metric]}
+ </Label>
+ </FormGroup>
+ ))}
+ </FormGroup>
+ <FormGroup>
+ <Label for="repeats">Repeats per scenario</Label>
+ <Input
+ name="repeats"
+ type="number"
+ required
+ innerRef={repeatsInput}
+ defaultValue="1"
+ min="1"
+ step="1"
+ />
+ </FormGroup>
+ </Form>
+ </Modal>
+ )
+}
+
+NewPortfolioModalComponent.propTypes = {
+ show: PropTypes.bool.isRequired,
+ callback: PropTypes.func.isRequired,
+}
+
+export default NewPortfolioModalComponent
diff --git a/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewScenarioModalComponent.js b/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewScenarioModalComponent.js
new file mode 100644
index 00000000..01a5719c
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewScenarioModalComponent.js
@@ -0,0 +1,144 @@
+import PropTypes from 'prop-types'
+import React, { useRef } from 'react'
+import { Form, FormGroup, Input, Label } from 'reactstrap'
+import Shapes from '../../../shapes'
+import Modal from '../Modal'
+
+const NewScenarioModalComponent = ({
+ show,
+ callback,
+ currentPortfolioId,
+ currentPortfolioScenarioIds,
+ traces,
+ topologies,
+ schedulers,
+}) => {
+ const form = useRef(null)
+ const textInput = useRef(null)
+ const traceSelect = useRef(null)
+ const traceLoadInput = useRef(null)
+ const topologySelect = useRef(null)
+ const failuresCheckbox = useRef(null)
+ const performanceInterferenceCheckbox = useRef(null)
+ const schedulerSelect = useRef(null)
+
+ const onSubmit = () => {
+ if (!form.current.reportValidity()) {
+ return false
+ }
+ callback(
+ textInput.current.value,
+ currentPortfolioId,
+ {
+ traceId: traceSelect.current.value,
+ loadSamplingFraction: parseFloat(traceLoadInput.current.value),
+ },
+ {
+ topologyId: topologySelect.current.value,
+ },
+ {
+ failuresEnabled: failuresCheckbox.current.checked,
+ performanceInterferenceEnabled: performanceInterferenceCheckbox.current.checked,
+ schedulerName: schedulerSelect.current.value,
+ }
+ )
+ return true
+ }
+ const onCancel = () => {
+ callback(undefined)
+ }
+
+ return (
+ <Modal title="New Scenario" show={show} onSubmit={onSubmit} onCancel={onCancel}>
+ <Form
+ onSubmit={(e) => {
+ e.preventDefault()
+ onSubmit()
+ }}
+ innerRef={form}
+ >
+ <FormGroup>
+ <Label for="name">Name</Label>
+ <Input
+ name="name"
+ type="text"
+ required
+ disabled={currentPortfolioScenarioIds.length === 0}
+ defaultValue={currentPortfolioScenarioIds.length === 0 ? 'Base scenario' : ''}
+ innerRef={textInput}
+ />
+ </FormGroup>
+ <h4>Trace</h4>
+ <FormGroup>
+ <Label for="trace">Trace</Label>
+ <Input name="trace" type="select" innerRef={traceSelect}>
+ {traces.map((trace) => (
+ <option value={trace._id} key={trace._id}>
+ {trace.name}
+ </option>
+ ))}
+ </Input>
+ </FormGroup>
+ <FormGroup>
+ <Label for="trace-load">Load sampling fraction</Label>
+ <Input
+ name="trace-load"
+ type="number"
+ innerRef={traceLoadInput}
+ required
+ defaultValue="1"
+ min="0"
+ max="1"
+ step="0.1"
+ />
+ </FormGroup>
+ <h4>Topology</h4>
+ <div className="form-group">
+ <Label for="topology">Topology</Label>
+ <Input name="topology" type="select" innerRef={topologySelect}>
+ {topologies.map((topology) => (
+ <option value={topology._id} key={topology._id}>
+ {topology.name}
+ </option>
+ ))}
+ </Input>
+ </div>
+ <h4>Operational Phenomena</h4>
+ <FormGroup check>
+ <Label check for="failures">
+ <Input type="checkbox" name="failures" innerRef={failuresCheckbox} />{' '}
+ <span className="ml-2">Enable failures</span>
+ </Label>
+ </FormGroup>
+ <FormGroup check>
+ <Label check for="perf-interference">
+ <Input type="checkbox" name="perf-interference" innerRef={performanceInterferenceCheckbox} />{' '}
+ <span className="ml-2">Enable performance interference</span>
+ </Label>
+ </FormGroup>
+ <FormGroup>
+ <Label for="scheduler">Scheduler</Label>
+ <Input name="scheduler" type="select" innerRef={schedulerSelect}>
+ {schedulers.map((scheduler) => (
+ <option value={scheduler.name} key={scheduler.name}>
+ {scheduler.name}
+ </option>
+ ))}
+ </Input>
+ </FormGroup>
+ </Form>
+ </Modal>
+ )
+}
+
+NewScenarioModalComponent.propTypes = {
+ show: PropTypes.bool.isRequired,
+ currentPortfolioId: PropTypes.string.isRequired,
+ currentPortfolioScenarioIds: PropTypes.arrayOf(PropTypes.string),
+ traces: PropTypes.arrayOf(Shapes.Trace),
+ topologies: PropTypes.arrayOf(Shapes.Topology),
+ schedulers: PropTypes.arrayOf(Shapes.Scheduler),
+ callback: PropTypes.func.isRequired,
+}
+
+export default NewScenarioModalComponent
diff --git a/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewTopologyModalComponent.js b/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewTopologyModalComponent.js
new file mode 100644
index 00000000..9fee8831
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/modals/custom-components/NewTopologyModalComponent.js
@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types'
+import { Form, FormGroup, Input, Label } from 'reactstrap'
+import React, { useRef } from 'react'
+import Shapes from '../../../shapes'
+import Modal from '../Modal'
+
+const NewTopologyModalComponent = ({ show, onCreateTopology, onDuplicateTopology, onCancel, topologies }) => {
+ const form = useRef(null)
+ const textInput = useRef(null)
+ const originTopology = useRef(null)
+
+ const onCreate = () => {
+ onCreateTopology(textInput.current.value)
+ }
+
+ const onDuplicate = () => {
+ onDuplicateTopology(textInput.current.value, originTopology.current.value)
+ }
+
+ const onSubmit = () => {
+ if (!form.current.reportValidity()) {
+ return false
+ } else if (originTopology.current.selectedIndex === 0) {
+ onCreate()
+ } else {
+ onDuplicate()
+ }
+
+ return true
+ }
+
+ return (
+ <Modal title="New Topology" show={show} onSubmit={onSubmit} onCancel={onCancel}>
+ <Form
+ onSubmit={(e) => {
+ e.preventDefault()
+ onSubmit()
+ }}
+ innerRef={form}
+ >
+ <FormGroup>
+ <Label for="name">Name</Label>
+ <Input name="name" type="text" required innerRef={textInput} />
+ </FormGroup>
+ <FormGroup>
+ <Label for="origin">Topology to duplicate</Label>
+ <Input name="origin" type="select" innerRef={originTopology}>
+ <option value={-1} key={-1}>
+ None - start from scratch
+ </option>
+ {topologies.map((topology) => (
+ <option value={topology._id} key={topology._id}>
+ {topology.name}
+ </option>
+ ))}
+ </Input>
+ </FormGroup>
+ </Form>
+ </Modal>
+ )
+}
+
+NewTopologyModalComponent.propTypes = {
+ show: PropTypes.bool.isRequired,
+ topologies: PropTypes.arrayOf(Shapes.Topology),
+ onCreateTopology: PropTypes.func.isRequired,
+ onDuplicateTopology: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+}
+
+export default NewTopologyModalComponent
diff --git a/opendc-web/opendc-web-ui/src/components/navigation/AppNavbarComponent.js b/opendc-web/opendc-web-ui/src/components/navigation/AppNavbarComponent.js
new file mode 100644
index 00000000..c5de3d0b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/navigation/AppNavbarComponent.js
@@ -0,0 +1,26 @@
+import React from 'react'
+import FontAwesome from 'react-fontawesome'
+import { Link } from 'react-router-dom'
+import { NavLink } from 'reactstrap'
+import Navbar, { NavItem } from './Navbar'
+import './Navbar.sass'
+
+const AppNavbarComponent = ({ project, fullWidth }) => (
+ <Navbar fullWidth={fullWidth}>
+ <NavItem route="/projects">
+ <NavLink tag={Link} title="My Projects" to="/projects">
+ <FontAwesome name="list" className="mr-2" />
+ My Projects
+ </NavLink>
+ </NavItem>
+ {project ? (
+ <NavItem>
+ <NavLink tag={Link} title="Current Project" to={`/projects/${project._id}`}>
+ <span>{project.name}</span>
+ </NavLink>
+ </NavItem>
+ ) : undefined}
+ </Navbar>
+)
+
+export default AppNavbarComponent
diff --git a/opendc-web/opendc-web-ui/src/components/navigation/HomeNavbar.js b/opendc-web/opendc-web-ui/src/components/navigation/HomeNavbar.js
new file mode 100644
index 00000000..08d222ea
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/navigation/HomeNavbar.js
@@ -0,0 +1,23 @@
+import React from 'react'
+import { NavItem, NavLink } from 'reactstrap'
+import Navbar from './Navbar'
+import './Navbar.sass'
+
+const ScrollNavItem = ({ id, name }) => (
+ <NavItem>
+ <NavLink href={id}>{name}</NavLink>
+ </NavItem>
+)
+
+const HomeNavbar = () => (
+ <Navbar fullWidth={false}>
+ <ScrollNavItem id="#stakeholders" name="Stakeholders" />
+ <ScrollNavItem id="#modeling" name="Modeling" />
+ <ScrollNavItem id="#project" name="Project" />
+ <ScrollNavItem id="#technologies" name="Technologies" />
+ <ScrollNavItem id="#team" name="Team" />
+ <ScrollNavItem id="#contact" name="Contact" />
+ </Navbar>
+)
+
+export default HomeNavbar
diff --git a/opendc-web/opendc-web-ui/src/components/navigation/LogoutButton.js b/opendc-web/opendc-web-ui/src/components/navigation/LogoutButton.js
new file mode 100644
index 00000000..78b02b44
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/navigation/LogoutButton.js
@@ -0,0 +1,17 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import FontAwesome from 'react-fontawesome'
+import { Link } from 'react-router-dom'
+import { NavLink } from 'reactstrap'
+
+const LogoutButton = ({ onLogout }) => (
+ <NavLink tag={Link} className="logout" title="Sign out" to="#" onClick={onLogout}>
+ <FontAwesome name="power-off" size="lg" />
+ </NavLink>
+)
+
+LogoutButton.propTypes = {
+ onLogout: PropTypes.func.isRequired,
+}
+
+export default LogoutButton
diff --git a/opendc-web/opendc-web-ui/src/components/navigation/Navbar.js b/opendc-web/opendc-web-ui/src/components/navigation/Navbar.js
new file mode 100644
index 00000000..55f98900
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/navigation/Navbar.js
@@ -0,0 +1,92 @@
+import React, { useState } from 'react'
+import { Link, useLocation } from 'react-router-dom'
+import {
+ Navbar as RNavbar,
+ NavItem as RNavItem,
+ NavLink,
+ NavbarBrand,
+ NavbarToggler,
+ Collapse,
+ Nav,
+ Container,
+} from 'reactstrap'
+import { userIsLoggedIn } from '../../auth/index'
+import Login from '../../containers/auth/Login'
+import Logout from '../../containers/auth/Logout'
+import ProfileName from '../../containers/auth/ProfileName'
+import './Navbar.sass'
+
+export const NAVBAR_HEIGHT = 60
+
+const GitHubLink = () => (
+ <a
+ href="https://github.com/atlarge-research/opendc"
+ className="ml-2 mr-3 text-dark"
+ style={{ position: 'relative', top: 7 }}
+ >
+ <span className="fa fa-github fa-2x" />
+ </a>
+)
+
+export const NavItem = ({ route, children }) => {
+ const location = useLocation()
+ return <RNavItem active={location.pathname === route}>{children}</RNavItem>
+}
+
+export const LoggedInSection = () => {
+ const location = useLocation()
+ return (
+ <Nav navbar className="auth-links">
+ {userIsLoggedIn() ? (
+ [
+ location.pathname === '/' ? (
+ <NavItem route="/projects" key="projects">
+ <NavLink tag={Link} title="My Projects" to="/projects">
+ My Projects
+ </NavLink>
+ </NavItem>
+ ) : (
+ <NavItem route="/profile" key="profile">
+ <NavLink tag={Link} title="My Profile" to="/profile">
+ <ProfileName />
+ </NavLink>
+ </NavItem>
+ ),
+ <NavItem route="logout" key="logout">
+ <Logout />
+ </NavItem>,
+ ]
+ ) : (
+ <NavItem route="login">
+ <GitHubLink />
+ <Login visible={true} />
+ </NavItem>
+ )}
+ </Nav>
+ )
+}
+
+const Navbar = ({ fullWidth, children }) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const toggle = () => setIsOpen(!isOpen)
+
+ return (
+ <RNavbar fixed="top" color="light" light expand="lg" id="navbar">
+ <Container fluid={fullWidth}>
+ <NavbarToggler onClick={toggle} />
+ <NavbarBrand tag={Link} to="/" title="OpenDC" className="opendc-brand">
+ <img src="/img/logo.png" alt="OpenDC" />
+ </NavbarBrand>
+
+ <Collapse isOpen={isOpen} navbar>
+ <Nav className="mr-auto" navbar>
+ {children}
+ </Nav>
+ <LoggedInSection />
+ </Collapse>
+ </Container>
+ </RNavbar>
+ )
+}
+
+export default Navbar
diff --git a/opendc-web/opendc-web-ui/src/components/navigation/Navbar.sass b/opendc-web/opendc-web-ui/src/components/navigation/Navbar.sass
new file mode 100644
index 00000000..c9d2aad2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/navigation/Navbar.sass
@@ -0,0 +1,30 @@
+@import ../../style-globals/_mixins.sass
+@import ../../style-globals/_variables.sass
+
+.navbar
+ border-top: $blue 3px solid
+ border-bottom: $gray-semi-dark 1px solid
+ color: $gray-very-dark
+ background: #fafafb
+
+.opendc-brand
+ display: inline-block
+ color: $gray-very-dark
+
+ +transition(background, $transition-length)
+
+ img
+ position: relative
+ bottom: 3px
+ display: inline-block
+ width: 30px
+
+.login
+ height: 40px
+ background: $blue
+ border: none
+ padding-top: 10px
+ +clickable
+
+ &:hover
+ background: $blue-dark
diff --git a/opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.js b/opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.js
new file mode 100644
index 00000000..dbdba212
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.js
@@ -0,0 +1,6 @@
+import React from 'react'
+import './BlinkingCursor.sass'
+
+const BlinkingCursor = () => <span className="blinking-cursor">_</span>
+
+export default BlinkingCursor
diff --git a/opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.sass b/opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.sass
new file mode 100644
index 00000000..ad91df85
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/not-found/BlinkingCursor.sass
@@ -0,0 +1,35 @@
+.blinking-cursor
+ -webkit-animation: 1s blink step-end infinite
+ -moz-animation: 1s blink step-end infinite
+ -o-animation: 1s blink step-end infinite
+ animation: 1s blink step-end infinite
+
+@keyframes blink
+ from, to
+ color: #eeeeee
+ 50%
+ color: #333333
+
+@-moz-keyframes blink
+ from, to
+ color: #eeeeee
+ 50%
+ color: #333333
+
+@-webkit-keyframes blink
+ from, to
+ color: #eeeeee
+ 50%
+ color: #333333
+
+@-ms-keyframes blink
+ from, to
+ color: #eeeeee
+ 50%
+ color: #333333
+
+@-o-keyframes blink
+ from, to
+ color: #eeeeee
+ 50%
+ color: #333333
diff --git a/opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.js b/opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.js
new file mode 100644
index 00000000..bcc522c9
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.js
@@ -0,0 +1,28 @@
+import React from 'react'
+import './CodeBlock.sass'
+
+const CodeBlock = () => {
+ const textBlock =
+ ' oo oooo oo <br/>' +
+ ' oo oo oo oo <br/>' +
+ ' oo oo oo oo <br/>' +
+ ' oooooo oo oo oooooo <br/>' +
+ ' oo oo oo oo <br/>' +
+ ' oo oooo oo <br/>'
+ const charList = textBlock.split('')
+
+ // Binary representation of the string "OpenDC!" ;)
+ const binaryString = '01001111011100000110010101101110010001000100001100100001'
+
+ let binaryIndex = 0
+ for (let i = 0; i < charList.length; i++) {
+ if (charList[i] === 'o') {
+ charList[i] = binaryString[binaryIndex]
+ binaryIndex++
+ }
+ }
+
+ return <div className="code-block" dangerouslySetInnerHTML={{ __html: textBlock }} />
+}
+
+export default CodeBlock
diff --git a/opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.sass b/opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.sass
new file mode 100644
index 00000000..e452f917
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/not-found/CodeBlock.sass
@@ -0,0 +1,3 @@
+.code-block
+ white-space: pre-wrap
+ margin-top: 60px
diff --git a/opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.js b/opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.js
new file mode 100644
index 00000000..a25e558a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.js
@@ -0,0 +1,33 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import BlinkingCursor from './BlinkingCursor'
+import CodeBlock from './CodeBlock'
+import './TerminalWindow.sass'
+
+const TerminalWindow = () => (
+ <div className="terminal-window">
+ <div className="terminal-header">Terminal -- bash</div>
+ <div className="terminal-body">
+ <div className="segfault">
+ $ status
+ <br />
+ opendc[4264]: segfault at 0000051497be459d1 err 12 in libopendc.9.0.4
+ <br />
+ opendc[4269]: segfault at 000004234855fc2db err 3 in libopendc.9.0.4
+ <br />
+ opendc[4270]: STDERR Page does not exist
+ <br />
+ </div>
+ <CodeBlock />
+ <div className="sub-title">
+ Got lost?
+ <BlinkingCursor />
+ </div>
+ <Link to="/" className="home-btn">
+ <span className="fa fa-home" /> GET ME BACK TO OPENDC
+ </Link>
+ </div>
+ </div>
+)
+
+export default TerminalWindow
diff --git a/opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.sass b/opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.sass
new file mode 100644
index 00000000..7f05335a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/not-found/TerminalWindow.sass
@@ -0,0 +1,70 @@
+.terminal-window
+ width: 600px
+ height: 400px
+ display: block
+
+ position: absolute
+ top: 0
+ bottom: 0
+ left: 0
+ right: 0
+
+ margin: auto
+
+ -webkit-user-select: none
+ -moz-user-select: none
+ -ms-user-select: none
+ user-select: none
+ cursor: default
+
+ overflow: hidden
+
+ box-shadow: 5px 5px 20px #444444
+
+.terminal-header
+ font-family: monospace
+ background: #cccccc
+ color: #444444
+ height: 30px
+ line-height: 30px
+ padding-left: 10px
+
+ border-top-left-radius: 7px
+ border-top-right-radius: 7px
+
+.terminal-body
+ font-family: monospace
+ text-align: center
+ background-color: #333333
+ color: #eeeeee
+ padding: 10px
+
+ height: 100%
+
+.segfault
+ text-align: left
+
+.sub-title
+ margin-top: 20px
+
+.home-btn
+ margin-top: 10px
+ padding: 5px
+ display: inline-block
+ border: 1px solid #eeeeee
+ color: #eeeeee
+ text-decoration: none
+ cursor: pointer
+
+ -webkit-transition: all 200ms
+ -moz-transition: all 200ms
+ -o-transition: all 200ms
+ transition: all 200ms
+
+.home-btn:hover
+ background: #eeeeee
+ color: #333333
+
+.home-btn:active
+ background: #333333
+ color: #eeeeee
diff --git a/opendc-web/opendc-web-ui/src/components/projects/FilterButton.js b/opendc-web/opendc-web-ui/src/components/projects/FilterButton.js
new file mode 100644
index 00000000..664f9b46
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/projects/FilterButton.js
@@ -0,0 +1,24 @@
+import classNames from 'classnames'
+import PropTypes from 'prop-types'
+import React from 'react'
+
+const FilterButton = ({ active, children, onClick }) => (
+ <button
+ className={classNames('btn btn-secondary', { active: active })}
+ onClick={() => {
+ if (!active) {
+ onClick()
+ }
+ }}
+ >
+ {children}
+ </button>
+)
+
+FilterButton.propTypes = {
+ active: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ onClick: PropTypes.func.isRequired,
+}
+
+export default FilterButton
diff --git a/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js
new file mode 100644
index 00000000..2b9795d0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js
@@ -0,0 +1,13 @@
+import React from 'react'
+import FilterLink from '../../containers/projects/FilterLink'
+import './FilterPanel.sass'
+
+const FilterPanel = () => (
+ <div className="btn-group filter-panel mb-2">
+ <FilterLink filter="SHOW_ALL">All Projects</FilterLink>
+ <FilterLink filter="SHOW_OWN">My Projects</FilterLink>
+ <FilterLink filter="SHOW_SHARED">Shared with me</FilterLink>
+ </div>
+)
+
+export default FilterPanel
diff --git a/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.sass b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.sass
new file mode 100644
index 00000000..f71cf6c8
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.sass
@@ -0,0 +1,5 @@
+.filter-panel
+ display: flex
+
+ button
+ flex: 1 !important
diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewProjectButtonComponent.js b/opendc-web/opendc-web-ui/src/components/projects/NewProjectButtonComponent.js
new file mode 100644
index 00000000..312671c6
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/projects/NewProjectButtonComponent.js
@@ -0,0 +1,17 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+
+const NewProjectButtonComponent = ({ onClick }) => (
+ <div className="bottom-btn-container">
+ <div className="btn btn-primary float-right" onClick={onClick}>
+ <span className="fa fa-plus mr-2" />
+ New Project
+ </div>
+ </div>
+)
+
+NewProjectButtonComponent.propTypes = {
+ onClick: PropTypes.func.isRequired,
+}
+
+export default NewProjectButtonComponent
diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectActionButtons.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectActionButtons.js
new file mode 100644
index 00000000..1c76cc7f
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectActionButtons.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Link } from 'react-router-dom'
+
+const ProjectActionButtons = ({ projectId, onViewUsers, onDelete }) => (
+ <td className="text-right">
+ <Link to={'/projects/' + projectId} className="btn btn-outline-primary btn-sm mr-2" title="Open this project">
+ <span className="fa fa-play" />
+ </Link>
+ <div
+ className="btn btn-outline-success btn-sm disabled mr-2"
+ title="View and edit collaborators (not supported currently)"
+ onClick={() => onViewUsers(projectId)}
+ >
+ <span className="fa fa-users" />
+ </div>
+ <div className="btn btn-outline-danger btn-sm" title="Delete this project" onClick={() => onDelete(projectId)}>
+ <span className="fa fa-trash" />
+ </div>
+ </td>
+)
+
+ProjectActionButtons.propTypes = {
+ projectId: PropTypes.string.isRequired,
+ onViewUsers: PropTypes.func,
+ onDelete: PropTypes.func,
+}
+
+export default ProjectActionButtons
diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectAuthList.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectAuthList.js
new file mode 100644
index 00000000..8eb4f93b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectAuthList.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import Shapes from '../../shapes/index'
+import ProjectAuthRow from './ProjectAuthRow'
+
+const ProjectAuthList = ({ authorizations }) => {
+ return (
+ <div className="vertically-expanding-container">
+ {authorizations.length === 0 ? (
+ <div className="alert alert-info">
+ <span className="info-icon fa fa-question-circle mr-2" />
+ <strong>No projects here yet...</strong> Add some with the 'New Project' button!
+ </div>
+ ) : (
+ <table className="table table-striped">
+ <thead>
+ <tr>
+ <th>Project name</th>
+ <th>Last edited</th>
+ <th>Access rights</th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {authorizations.map((authorization) => (
+ <ProjectAuthRow projectAuth={authorization} key={authorization.project._id} />
+ ))}
+ </tbody>
+ </table>
+ )}
+ </div>
+ )
+}
+
+ProjectAuthList.propTypes = {
+ authorizations: PropTypes.arrayOf(Shapes.Authorization).isRequired,
+}
+
+export default ProjectAuthList
diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectAuthRow.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectAuthRow.js
new file mode 100644
index 00000000..3f904061
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectAuthRow.js
@@ -0,0 +1,24 @@
+import classNames from 'classnames'
+import React from 'react'
+import ProjectActions from '../../containers/projects/ProjectActions'
+import Shapes from '../../shapes/index'
+import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP } from '../../util/authorizations'
+import { parseAndFormatDateTime } from '../../util/date-time'
+
+const ProjectAuthRow = ({ projectAuth }) => (
+ <tr>
+ <td className="pt-3">{projectAuth.project.name}</td>
+ <td className="pt-3">{parseAndFormatDateTime(projectAuth.project.datetimeLastEdited)}</td>
+ <td className="pt-3">
+ <span className={classNames('fa', 'fa-' + AUTH_ICON_MAP[projectAuth.authorizationLevel], 'mr-2')} />
+ {AUTH_DESCRIPTION_MAP[projectAuth.authorizationLevel]}
+ </td>
+ <ProjectActions projectId={projectAuth.project._id} />
+ </tr>
+)
+
+ProjectAuthRow.propTypes = {
+ projectAuth: Shapes.Authorization.isRequired,
+}
+
+export default ProjectAuthRow