summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'opendc-web/opendc-web-ui/src')
-rw-r--r--opendc-web/opendc-web-ui/src/actions/auth.js23
-rw-r--r--opendc-web/opendc-web-ui/src/actions/interaction-level.js50
-rw-r--r--opendc-web/opendc-web-ui/src/actions/map.js82
-rw-r--r--opendc-web/opendc-web-ui/src/actions/modals/portfolios.js14
-rw-r--r--opendc-web/opendc-web-ui/src/actions/modals/prefabs.js14
-rw-r--r--opendc-web/opendc-web-ui/src/actions/modals/profile.js14
-rw-r--r--opendc-web/opendc-web-ui/src/actions/modals/projects.js14
-rw-r--r--opendc-web/opendc-web-ui/src/actions/modals/scenarios.js14
-rw-r--r--opendc-web/opendc-web-ui/src/actions/modals/topology.js84
-rw-r--r--opendc-web/opendc-web-ui/src/actions/objects.js41
-rw-r--r--opendc-web/opendc-web-ui/src/actions/portfolios.js41
-rw-r--r--opendc-web/opendc-web-ui/src/actions/prefabs.js32
-rw-r--r--opendc-web/opendc-web-ui/src/actions/projects.js52
-rw-r--r--opendc-web/opendc-web-ui/src/actions/scenarios.js43
-rw-r--r--opendc-web/opendc-web-ui/src/actions/topologies.js17
-rw-r--r--opendc-web/opendc-web-ui/src/actions/topology/building.js105
-rw-r--r--opendc-web/opendc-web-ui/src/actions/topology/machine.js25
-rw-r--r--opendc-web/opendc-web-ui/src/actions/topology/rack.js23
-rw-r--r--opendc-web/opendc-web-ui/src/actions/topology/room.js48
-rw-r--r--opendc-web/opendc-web-ui/src/actions/users.js37
-rw-r--r--opendc-web/opendc-web-ui/src/api/index.js13
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/portfolios.js42
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/prefabs.js40
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/projects.js40
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/scenarios.js42
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/schedulers.js5
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/token-signin.js10
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/topologies.js42
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/traces.js5
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/users.js48
-rw-r--r--opendc-web/opendc-web-ui/src/api/routes/util.js37
-rw-r--r--opendc-web/opendc-web-ui/src/api/socket.js50
-rw-r--r--opendc-web/opendc-web-ui/src/auth/index.js57
-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
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/GrayContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/MapStage.js22
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/RackContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/RackEnergyFillContainer.js26
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/RackSpaceFillContainer.js14
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/RoomContainer.js21
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/TileContainer.js26
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/TopologyContainer.js17
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/WallContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/controls/ScaleIndicatorContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/controls/ZoomControlContainer.js19
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/layers/MapLayer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/layers/ObjectHoverLayer.js33
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/map/layers/RoomHoverLayer.js46
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/results/PortfolioResultsContainer.js28
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/project/PortfolioListContainer.js45
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ProjectSidebarContainer.js10
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ScenarioListContainer.js41
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/project/TopologyListContainer.js46
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/TopologySidebarContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js5
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js25
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/BackToRackContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineNameContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js15
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitAddContainer.js19
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitContainer.js20
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitListContainer.js17
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js5
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/AddPrefabContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineContainer.js19
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineListContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackNameContainer.js19
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/EditRoomContainer.js21
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RackConstructionContainer.js21
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomNameContainer.js19
-rw-r--r--opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/auth/Login.js62
-rw-r--r--opendc-web/opendc-web-ui/src/containers/auth/Logout.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/auth/ProfileName.js14
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/DeleteMachineModal.js35
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/DeleteProfileModal.js35
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/DeleteRackModal.js35
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/DeleteRoomModal.js35
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/EditRackNameModal.js40
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/EditRoomNameModal.js38
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/NewPortfolioModal.js30
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/NewProjectModal.js30
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/NewScenarioModal.js50
-rw-r--r--opendc-web/opendc-web-ui/src/containers/modals/NewTopologyModal.js42
-rw-r--r--opendc-web/opendc-web-ui/src/containers/navigation/AppNavbarContainer.js12
-rw-r--r--opendc-web/opendc-web-ui/src/containers/projects/FilterLink.js19
-rw-r--r--opendc-web/opendc-web-ui/src/containers/projects/NewProjectButtonContainer.js13
-rw-r--r--opendc-web/opendc-web-ui/src/containers/projects/ProjectActions.js20
-rw-r--r--opendc-web/opendc-web-ui/src/containers/projects/VisibleProjectAuthList.js32
-rw-r--r--opendc-web/opendc-web-ui/src/index.js30
-rw-r--r--opendc-web/opendc-web-ui/src/index.sass52
-rw-r--r--opendc-web/opendc-web-ui/src/pages/App.js137
-rw-r--r--opendc-web/opendc-web-ui/src/pages/Home.js33
-rw-r--r--opendc-web/opendc-web-ui/src/pages/Home.sass9
-rw-r--r--opendc-web/opendc-web-ui/src/pages/NotFound.js14
-rw-r--r--opendc-web/opendc-web-ui/src/pages/NotFound.sass11
-rw-r--r--opendc-web/opendc-web-ui/src/pages/Profile.js35
-rw-r--r--opendc-web/opendc-web-ui/src/pages/Projects.js43
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/auth.js12
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/construction-mode.js52
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/current-ids.js54
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/index.js25
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/interaction-level.js61
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/map.js35
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/modals.js45
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/objects.js64
-rw-r--r--opendc-web/opendc-web-ui/src/reducers/project-list.js30
-rw-r--r--opendc-web/opendc-web-ui/src/routes/index.js40
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/index.js80
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/objects.js229
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/portfolios.js131
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/prefabs.js15
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/profile.js12
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/projects.js48
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/scenarios.js65
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/topology.js311
-rw-r--r--opendc-web/opendc-web-ui/src/sagas/users.js44
-rw-r--r--opendc-web/opendc-web-ui/src/shapes/index.js148
-rw-r--r--opendc-web/opendc-web-ui/src/shortcuts/keymap.js10
-rw-r--r--opendc-web/opendc-web-ui/src/store/configure-store.js35
-rw-r--r--opendc-web/opendc-web-ui/src/store/middlewares/dummy-middleware.js3
-rw-r--r--opendc-web/opendc-web-ui/src/store/middlewares/viewport-adjustment.js73
-rw-r--r--opendc-web/opendc-web-ui/src/style-globals/_mixins.sass21
-rw-r--r--opendc-web/opendc-web-ui/src/style-globals/_variables.sass31
-rw-r--r--opendc-web/opendc-web-ui/src/util/authorizations.js11
-rw-r--r--opendc-web/opendc-web-ui/src/util/available-metrics.js67
-rw-r--r--opendc-web/opendc-web-ui/src/util/colors.js29
-rw-r--r--opendc-web/opendc-web-ui/src/util/date-time.js93
-rw-r--r--opendc-web/opendc-web-ui/src/util/date-time.test.js35
-rw-r--r--opendc-web/opendc-web-ui/src/util/sidebar-space.js2
-rw-r--r--opendc-web/opendc-web-ui/src/util/state-utils.js6
-rw-r--r--opendc-web/opendc-web-ui/src/util/tile-calculations.js255
-rw-r--r--opendc-web/opendc-web-ui/src/util/timeline.js9
-rw-r--r--opendc-web/opendc-web-ui/src/util/unit-specifications.js102
241 files changed, 8335 insertions, 0 deletions
diff --git a/opendc-web/opendc-web-ui/src/actions/auth.js b/opendc-web/opendc-web-ui/src/actions/auth.js
new file mode 100644
index 00000000..38c1a782
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/auth.js
@@ -0,0 +1,23 @@
+export const LOG_IN = 'LOG_IN'
+export const LOG_IN_SUCCEEDED = 'LOG_IN_SUCCEEDED'
+export const LOG_OUT = 'LOG_OUT'
+
+export function logIn(payload) {
+ return {
+ type: LOG_IN,
+ payload,
+ }
+}
+
+export function logInSucceeded(payload) {
+ return {
+ type: LOG_IN_SUCCEEDED,
+ payload,
+ }
+}
+
+export function logOut() {
+ return {
+ type: LOG_OUT,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/interaction-level.js b/opendc-web/opendc-web-ui/src/actions/interaction-level.js
new file mode 100644
index 00000000..ff6b1fa3
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/interaction-level.js
@@ -0,0 +1,50 @@
+export const GO_FROM_BUILDING_TO_ROOM = 'GO_FROM_BUILDING_TO_ROOM'
+export const GO_FROM_ROOM_TO_RACK = 'GO_FROM_ROOM_TO_RACK'
+export const GO_FROM_RACK_TO_MACHINE = 'GO_FROM_RACK_TO_MACHINE'
+export const GO_DOWN_ONE_INTERACTION_LEVEL = 'GO_DOWN_ONE_INTERACTION_LEVEL'
+
+export function goFromBuildingToRoom(roomId) {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ if (interactionLevel.mode !== 'BUILDING') {
+ return
+ }
+
+ dispatch({
+ type: GO_FROM_BUILDING_TO_ROOM,
+ roomId,
+ })
+ }
+}
+
+export function goFromRoomToRack(tileId) {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ if (interactionLevel.mode !== 'ROOM') {
+ return
+ }
+ dispatch({
+ type: GO_FROM_ROOM_TO_RACK,
+ tileId,
+ })
+ }
+}
+
+export function goFromRackToMachine(position) {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ if (interactionLevel.mode !== 'RACK') {
+ return
+ }
+ dispatch({
+ type: GO_FROM_RACK_TO_MACHINE,
+ position,
+ })
+ }
+}
+
+export function goDownOneInteractionLevel() {
+ return {
+ type: GO_DOWN_ONE_INTERACTION_LEVEL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/map.js b/opendc-web/opendc-web-ui/src/actions/map.js
new file mode 100644
index 00000000..0d49d849
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/map.js
@@ -0,0 +1,82 @@
+import {
+ MAP_MAX_SCALE,
+ MAP_MIN_SCALE,
+ MAP_SCALE_PER_EVENT,
+ MAP_SIZE_IN_PIXELS,
+} from '../components/app/map/MapConstants'
+
+export const SET_MAP_POSITION = 'SET_MAP_POSITION'
+export const SET_MAP_DIMENSIONS = 'SET_MAP_DIMENSIONS'
+export const SET_MAP_SCALE = 'SET_MAP_SCALE'
+
+export function setMapPosition(x, y) {
+ return {
+ type: SET_MAP_POSITION,
+ x,
+ y,
+ }
+}
+
+export function setMapDimensions(width, height) {
+ return {
+ type: SET_MAP_DIMENSIONS,
+ width,
+ height,
+ }
+}
+
+export function setMapScale(scale) {
+ return {
+ type: SET_MAP_SCALE,
+ scale,
+ }
+}
+
+export function zoomInOnCenter(zoomIn) {
+ return (dispatch, getState) => {
+ const state = getState()
+
+ dispatch(zoomInOnPosition(zoomIn, state.map.dimensions.width / 2, state.map.dimensions.height / 2))
+ }
+}
+
+export function zoomInOnPosition(zoomIn, x, y) {
+ return (dispatch, getState) => {
+ const state = getState()
+
+ const centerPoint = {
+ x: x / state.map.scale - state.map.position.x / state.map.scale,
+ y: y / state.map.scale - state.map.position.y / state.map.scale,
+ }
+ const newScale = zoomIn ? state.map.scale * MAP_SCALE_PER_EVENT : state.map.scale / MAP_SCALE_PER_EVENT
+ const boundedScale = Math.min(Math.max(MAP_MIN_SCALE, newScale), MAP_MAX_SCALE)
+
+ const newX = -(centerPoint.x - x / boundedScale) * boundedScale
+ const newY = -(centerPoint.y - y / boundedScale) * boundedScale
+
+ dispatch(setMapPositionWithBoundsCheck(newX, newY))
+ dispatch(setMapScale(boundedScale))
+ }
+}
+
+export function setMapPositionWithBoundsCheck(x, y) {
+ return (dispatch, getState) => {
+ const state = getState()
+
+ const scaledMapSize = MAP_SIZE_IN_PIXELS * state.map.scale
+ const updatedX =
+ x > 0
+ ? 0
+ : x < -scaledMapSize + state.map.dimensions.width
+ ? -scaledMapSize + state.map.dimensions.width
+ : x
+ const updatedY =
+ y > 0
+ ? 0
+ : y < -scaledMapSize + state.map.dimensions.height
+ ? -scaledMapSize + state.map.dimensions.height
+ : y
+
+ dispatch(setMapPosition(updatedX, updatedY))
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/modals/portfolios.js b/opendc-web/opendc-web-ui/src/actions/modals/portfolios.js
new file mode 100644
index 00000000..f6dce2e3
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/modals/portfolios.js
@@ -0,0 +1,14 @@
+export const OPEN_NEW_PORTFOLIO_MODAL = 'OPEN_NEW_PORTFOLIO_MODAL'
+export const CLOSE_NEW_PORTFOLIO_MODAL = 'CLOSE_PORTFOLIO_MODAL'
+
+export function openNewPortfolioModal() {
+ return {
+ type: OPEN_NEW_PORTFOLIO_MODAL,
+ }
+}
+
+export function closeNewPortfolioModal() {
+ return {
+ type: CLOSE_NEW_PORTFOLIO_MODAL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/modals/prefabs.js b/opendc-web/opendc-web-ui/src/actions/modals/prefabs.js
new file mode 100644
index 00000000..826565d2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/modals/prefabs.js
@@ -0,0 +1,14 @@
+export const OPEN_NEW_PREFAB_MODAL = 'OPEN_NEW_PREFAB_MODAL'
+export const CLOSE_NEW_PREFAB_MODAL = 'CLOSE_PREFAB_MODAL'
+
+export function openNewPrefabModal() {
+ return {
+ type: OPEN_NEW_PREFAB_MODAL,
+ }
+}
+
+export function closeNewPrefabModal() {
+ return {
+ type: CLOSE_NEW_PREFAB_MODAL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/modals/profile.js b/opendc-web/opendc-web-ui/src/actions/modals/profile.js
new file mode 100644
index 00000000..39c72c03
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/modals/profile.js
@@ -0,0 +1,14 @@
+export const OPEN_DELETE_PROFILE_MODAL = 'OPEN_DELETE_PROFILE_MODAL'
+export const CLOSE_DELETE_PROFILE_MODAL = 'CLOSE_DELETE_PROFILE_MODAL'
+
+export function openDeleteProfileModal() {
+ return {
+ type: OPEN_DELETE_PROFILE_MODAL,
+ }
+}
+
+export function closeDeleteProfileModal() {
+ return {
+ type: CLOSE_DELETE_PROFILE_MODAL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/modals/projects.js b/opendc-web/opendc-web-ui/src/actions/modals/projects.js
new file mode 100644
index 00000000..d1043cbb
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/modals/projects.js
@@ -0,0 +1,14 @@
+export const OPEN_NEW_PROJECT_MODAL = 'OPEN_NEW_PROJECT_MODAL'
+export const CLOSE_NEW_PROJECT_MODAL = 'CLOSE_PROJECT_MODAL'
+
+export function openNewProjectModal() {
+ return {
+ type: OPEN_NEW_PROJECT_MODAL,
+ }
+}
+
+export function closeNewProjectModal() {
+ return {
+ type: CLOSE_NEW_PROJECT_MODAL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/modals/scenarios.js b/opendc-web/opendc-web-ui/src/actions/modals/scenarios.js
new file mode 100644
index 00000000..b71cb27b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/modals/scenarios.js
@@ -0,0 +1,14 @@
+export const OPEN_NEW_SCENARIO_MODAL = 'OPEN_NEW_SCENARIO_MODAL'
+export const CLOSE_NEW_SCENARIO_MODAL = 'CLOSE_SCENARIO_MODAL'
+
+export function openNewScenarioModal() {
+ return {
+ type: OPEN_NEW_SCENARIO_MODAL,
+ }
+}
+
+export function closeNewScenarioModal() {
+ return {
+ type: CLOSE_NEW_SCENARIO_MODAL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/modals/topology.js b/opendc-web/opendc-web-ui/src/actions/modals/topology.js
new file mode 100644
index 00000000..b5fecac1
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/modals/topology.js
@@ -0,0 +1,84 @@
+export const OPEN_NEW_TOPOLOGY_MODAL = 'OPEN_NEW_TOPOLOGY_MODAL'
+export const CLOSE_NEW_TOPOLOGY_MODAL = 'CLOSE_NEW_TOPOLOGY_MODAL'
+export const OPEN_EDIT_ROOM_NAME_MODAL = 'OPEN_EDIT_ROOM_NAME_MODAL'
+export const CLOSE_EDIT_ROOM_NAME_MODAL = 'CLOSE_EDIT_ROOM_NAME_MODAL'
+export const OPEN_DELETE_ROOM_MODAL = 'OPEN_DELETE_ROOM_MODAL'
+export const CLOSE_DELETE_ROOM_MODAL = 'CLOSE_DELETE_ROOM_MODAL'
+export const OPEN_EDIT_RACK_NAME_MODAL = 'OPEN_EDIT_RACK_NAME_MODAL'
+export const CLOSE_EDIT_RACK_NAME_MODAL = 'CLOSE_EDIT_RACK_NAME_MODAL'
+export const OPEN_DELETE_RACK_MODAL = 'OPEN_DELETE_RACK_MODAL'
+export const CLOSE_DELETE_RACK_MODAL = 'CLOSE_DELETE_RACK_MODAL'
+export const OPEN_DELETE_MACHINE_MODAL = 'OPEN_DELETE_MACHINE_MODAL'
+export const CLOSE_DELETE_MACHINE_MODAL = 'CLOSE_DELETE_MACHINE_MODAL'
+
+export function openNewTopologyModal() {
+ return {
+ type: OPEN_NEW_TOPOLOGY_MODAL,
+ }
+}
+
+export function closeNewTopologyModal() {
+ return {
+ type: CLOSE_NEW_TOPOLOGY_MODAL,
+ }
+}
+
+export function openEditRoomNameModal() {
+ return {
+ type: OPEN_EDIT_ROOM_NAME_MODAL,
+ }
+}
+
+export function closeEditRoomNameModal() {
+ return {
+ type: CLOSE_EDIT_ROOM_NAME_MODAL,
+ }
+}
+
+export function openDeleteRoomModal() {
+ return {
+ type: OPEN_DELETE_ROOM_MODAL,
+ }
+}
+
+export function closeDeleteRoomModal() {
+ return {
+ type: CLOSE_DELETE_ROOM_MODAL,
+ }
+}
+
+export function openEditRackNameModal() {
+ return {
+ type: OPEN_EDIT_RACK_NAME_MODAL,
+ }
+}
+
+export function closeEditRackNameModal() {
+ return {
+ type: CLOSE_EDIT_RACK_NAME_MODAL,
+ }
+}
+
+export function openDeleteRackModal() {
+ return {
+ type: OPEN_DELETE_RACK_MODAL,
+ }
+}
+
+export function closeDeleteRackModal() {
+ return {
+ type: CLOSE_DELETE_RACK_MODAL,
+ }
+}
+
+export function openDeleteMachineModal() {
+ return {
+ type: OPEN_DELETE_MACHINE_MODAL,
+ }
+}
+
+export function closeDeleteMachineModal() {
+ return {
+ type: CLOSE_DELETE_MACHINE_MODAL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/objects.js b/opendc-web/opendc-web-ui/src/actions/objects.js
new file mode 100644
index 00000000..7b648b18
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/objects.js
@@ -0,0 +1,41 @@
+export const ADD_TO_STORE = 'ADD_TO_STORE'
+export const ADD_PROP_TO_STORE_OBJECT = 'ADD_PROP_TO_STORE_OBJECT'
+export const ADD_ID_TO_STORE_OBJECT_LIST_PROP = 'ADD_ID_TO_STORE_OBJECT_LIST_PROP'
+export const REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP = 'REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP'
+
+export function addToStore(objectType, object) {
+ return {
+ type: ADD_TO_STORE,
+ objectType,
+ object,
+ }
+}
+
+export function addPropToStoreObject(objectType, objectId, propObject) {
+ return {
+ type: ADD_PROP_TO_STORE_OBJECT,
+ objectType,
+ objectId,
+ propObject,
+ }
+}
+
+export function addIdToStoreObjectListProp(objectType, objectId, propName, id) {
+ return {
+ type: ADD_ID_TO_STORE_OBJECT_LIST_PROP,
+ objectType,
+ objectId,
+ propName,
+ id,
+ }
+}
+
+export function removeIdFromStoreObjectListProp(objectType, objectId, propName, id) {
+ return {
+ type: REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP,
+ objectType,
+ objectId,
+ propName,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/portfolios.js b/opendc-web/opendc-web-ui/src/actions/portfolios.js
new file mode 100644
index 00000000..d37886d8
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/portfolios.js
@@ -0,0 +1,41 @@
+export const ADD_PORTFOLIO = 'ADD_PORTFOLIO'
+export const UPDATE_PORTFOLIO = 'UPDATE_PORTFOLIO'
+export const DELETE_PORTFOLIO = 'DELETE_PORTFOLIO'
+export const OPEN_PORTFOLIO_SUCCEEDED = 'OPEN_PORTFOLIO_SUCCEEDED'
+export const SET_CURRENT_PORTFOLIO = 'SET_CURRENT_PORTFOLIO'
+
+export function addPortfolio(portfolio) {
+ return {
+ type: ADD_PORTFOLIO,
+ portfolio,
+ }
+}
+
+export function updatePortfolio(portfolio) {
+ return {
+ type: UPDATE_PORTFOLIO,
+ portfolio,
+ }
+}
+
+export function deletePortfolio(id) {
+ return {
+ type: DELETE_PORTFOLIO,
+ id,
+ }
+}
+
+export function openPortfolioSucceeded(projectId, portfolioId) {
+ return {
+ type: OPEN_PORTFOLIO_SUCCEEDED,
+ projectId,
+ portfolioId,
+ }
+}
+
+export function setCurrentPortfolio(portfolioId) {
+ return {
+ type: SET_CURRENT_PORTFOLIO,
+ portfolioId,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/prefabs.js b/opendc-web/opendc-web-ui/src/actions/prefabs.js
new file mode 100644
index 00000000..c112feed
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/prefabs.js
@@ -0,0 +1,32 @@
+export const ADD_PREFAB = 'ADD_PREFAB'
+export const DELETE_PREFAB = 'DELETE_PREFAB'
+export const DELETE_PREFAB_SUCCEEDED = 'DELETE_PREFAB_SUCCEEDED'
+export const OPEN_PREFAB_SUCCEEDED = 'OPEN_PREFAB_SUCCEEDED'
+
+export function addPrefab(name) {
+ return {
+ type: ADD_PREFAB,
+ name,
+ }
+}
+
+export function deletePrefab(id) {
+ return {
+ type: DELETE_PREFAB,
+ id,
+ }
+}
+
+export function deletePrefabSucceeded(id) {
+ return {
+ type: DELETE_PREFAB_SUCCEEDED,
+ id,
+ }
+}
+
+export function openPrefabSucceeded(id) {
+ return {
+ type: OPEN_PREFAB_SUCCEEDED,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/projects.js b/opendc-web/opendc-web-ui/src/actions/projects.js
new file mode 100644
index 00000000..add0f242
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/projects.js
@@ -0,0 +1,52 @@
+export const SET_AUTH_VISIBILITY_FILTER = 'SET_AUTH_VISIBILITY_FILTER'
+export const ADD_PROJECT = 'ADD_PROJECT'
+export const ADD_PROJECT_SUCCEEDED = 'ADD_PROJECT_SUCCEEDED'
+export const DELETE_PROJECT = 'DELETE_PROJECT'
+export const DELETE_PROJECT_SUCCEEDED = 'DELETE_PROJECT_SUCCEEDED'
+export const OPEN_PROJECT_SUCCEEDED = 'OPEN_PROJECT_SUCCEEDED'
+
+export function setAuthVisibilityFilter(filter) {
+ return {
+ type: SET_AUTH_VISIBILITY_FILTER,
+ filter,
+ }
+}
+
+export function addProject(name) {
+ return (dispatch, getState) => {
+ const { auth } = getState()
+ dispatch({
+ type: ADD_PROJECT,
+ name,
+ userId: auth.userId,
+ })
+ }
+}
+
+export function addProjectSucceeded(authorization) {
+ return {
+ type: ADD_PROJECT_SUCCEEDED,
+ authorization,
+ }
+}
+
+export function deleteProject(id) {
+ return {
+ type: DELETE_PROJECT,
+ id,
+ }
+}
+
+export function deleteProjectSucceeded(id) {
+ return {
+ type: DELETE_PROJECT_SUCCEEDED,
+ id,
+ }
+}
+
+export function openProjectSucceeded(id) {
+ return {
+ type: OPEN_PROJECT_SUCCEEDED,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/scenarios.js b/opendc-web/opendc-web-ui/src/actions/scenarios.js
new file mode 100644
index 00000000..c8a90762
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/scenarios.js
@@ -0,0 +1,43 @@
+export const ADD_SCENARIO = 'ADD_SCENARIO'
+export const UPDATE_SCENARIO = 'UPDATE_SCENARIO'
+export const DELETE_SCENARIO = 'DELETE_SCENARIO'
+export const OPEN_SCENARIO_SUCCEEDED = 'OPEN_SCENARIO_SUCCEEDED'
+export const SET_CURRENT_SCENARIO = 'SET_CURRENT_SCENARIO'
+
+export function addScenario(scenario) {
+ return {
+ type: ADD_SCENARIO,
+ scenario,
+ }
+}
+
+export function updateScenario(scenario) {
+ return {
+ type: UPDATE_SCENARIO,
+ scenario,
+ }
+}
+
+export function deleteScenario(id) {
+ return {
+ type: DELETE_SCENARIO,
+ id,
+ }
+}
+
+export function openScenarioSucceeded(projectId, portfolioId, scenarioId) {
+ return {
+ type: OPEN_SCENARIO_SUCCEEDED,
+ projectId,
+ portfolioId,
+ scenarioId,
+ }
+}
+
+export function setCurrentScenario(portfolioId, scenarioId) {
+ return {
+ type: SET_CURRENT_SCENARIO,
+ portfolioId,
+ scenarioId,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/topologies.js b/opendc-web/opendc-web-ui/src/actions/topologies.js
new file mode 100644
index 00000000..dcce3b7d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/topologies.js
@@ -0,0 +1,17 @@
+export const ADD_TOPOLOGY = 'ADD_TOPOLOGY'
+export const DELETE_TOPOLOGY = 'DELETE_TOPOLOGY'
+
+export function addTopology(name, duplicateId) {
+ return {
+ type: ADD_TOPOLOGY,
+ name,
+ duplicateId,
+ }
+}
+
+export function deleteTopology(id) {
+ return {
+ type: DELETE_TOPOLOGY,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/topology/building.js b/opendc-web/opendc-web-ui/src/actions/topology/building.js
new file mode 100644
index 00000000..72deda6f
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/topology/building.js
@@ -0,0 +1,105 @@
+export const SET_CURRENT_TOPOLOGY = 'SET_CURRENT_TOPOLOGY'
+export const START_NEW_ROOM_CONSTRUCTION = 'START_NEW_ROOM_CONSTRUCTION'
+export const START_NEW_ROOM_CONSTRUCTION_SUCCEEDED = 'START_NEW_ROOM_CONSTRUCTION_SUCCEEDED'
+export const FINISH_NEW_ROOM_CONSTRUCTION = 'FINISH_NEW_ROOM_CONSTRUCTION'
+export const CANCEL_NEW_ROOM_CONSTRUCTION = 'CANCEL_NEW_ROOM_CONSTRUCTION'
+export const CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED = 'CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED'
+export const START_ROOM_EDIT = 'START_ROOM_EDIT'
+export const FINISH_ROOM_EDIT = 'FINISH_ROOM_EDIT'
+export const ADD_TILE = 'ADD_TILE'
+export const DELETE_TILE = 'DELETE_TILE'
+
+export function setCurrentTopology(topologyId) {
+ return {
+ type: SET_CURRENT_TOPOLOGY,
+ topologyId,
+ }
+}
+
+export function startNewRoomConstruction() {
+ return {
+ type: START_NEW_ROOM_CONSTRUCTION,
+ }
+}
+
+export function startNewRoomConstructionSucceeded(roomId) {
+ return {
+ type: START_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ roomId,
+ }
+}
+
+export function finishNewRoomConstruction() {
+ return (dispatch, getState) => {
+ const { objects, construction } = getState()
+ if (objects.room[construction.currentRoomInConstruction].tileIds.length === 0) {
+ dispatch(cancelNewRoomConstruction())
+ return
+ }
+
+ dispatch({
+ type: FINISH_NEW_ROOM_CONSTRUCTION,
+ })
+ }
+}
+
+export function cancelNewRoomConstruction() {
+ return {
+ type: CANCEL_NEW_ROOM_CONSTRUCTION,
+ }
+}
+
+export function cancelNewRoomConstructionSucceeded() {
+ return {
+ type: CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ }
+}
+
+export function startRoomEdit() {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ dispatch({
+ type: START_ROOM_EDIT,
+ roomId: interactionLevel.roomId,
+ })
+ }
+}
+
+export function finishRoomEdit() {
+ return {
+ type: FINISH_ROOM_EDIT,
+ }
+}
+
+export function toggleTileAtLocation(positionX, positionY) {
+ return (dispatch, getState) => {
+ const { objects, construction } = getState()
+
+ const tileIds = objects.room[construction.currentRoomInConstruction].tileIds
+ for (let index in tileIds) {
+ if (
+ objects.tile[tileIds[index]].positionX === positionX &&
+ objects.tile[tileIds[index]].positionY === positionY
+ ) {
+ dispatch(deleteTile(tileIds[index]))
+ return
+ }
+ }
+ dispatch(addTile(positionX, positionY))
+ }
+}
+
+export function addTile(positionX, positionY) {
+ return {
+ type: ADD_TILE,
+ positionX,
+ positionY,
+ }
+}
+
+export function deleteTile(tileId) {
+ return {
+ type: DELETE_TILE,
+ tileId,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/topology/machine.js b/opendc-web/opendc-web-ui/src/actions/topology/machine.js
new file mode 100644
index 00000000..17ccce5d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/topology/machine.js
@@ -0,0 +1,25 @@
+export const DELETE_MACHINE = 'DELETE_MACHINE'
+export const ADD_UNIT = 'ADD_UNIT'
+export const DELETE_UNIT = 'DELETE_UNIT'
+
+export function deleteMachine() {
+ return {
+ type: DELETE_MACHINE,
+ }
+}
+
+export function addUnit(unitType, id) {
+ return {
+ type: ADD_UNIT,
+ unitType,
+ id,
+ }
+}
+
+export function deleteUnit(unitType, index) {
+ return {
+ type: DELETE_UNIT,
+ unitType,
+ index,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/topology/rack.js b/opendc-web/opendc-web-ui/src/actions/topology/rack.js
new file mode 100644
index 00000000..b117402e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/topology/rack.js
@@ -0,0 +1,23 @@
+export const EDIT_RACK_NAME = 'EDIT_RACK_NAME'
+export const DELETE_RACK = 'DELETE_RACK'
+export const ADD_MACHINE = 'ADD_MACHINE'
+
+export function editRackName(name) {
+ return {
+ type: EDIT_RACK_NAME,
+ name,
+ }
+}
+
+export function deleteRack() {
+ return {
+ type: DELETE_RACK,
+ }
+}
+
+export function addMachine(position) {
+ return {
+ type: ADD_MACHINE,
+ position,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/topology/room.js b/opendc-web/opendc-web-ui/src/actions/topology/room.js
new file mode 100644
index 00000000..52cba680
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/topology/room.js
@@ -0,0 +1,48 @@
+import { findTileWithPosition } from '../../util/tile-calculations'
+
+export const EDIT_ROOM_NAME = 'EDIT_ROOM_NAME'
+export const DELETE_ROOM = 'DELETE_ROOM'
+export const START_RACK_CONSTRUCTION = 'START_RACK_CONSTRUCTION'
+export const STOP_RACK_CONSTRUCTION = 'STOP_RACK_CONSTRUCTION'
+export const ADD_RACK_TO_TILE = 'ADD_RACK_TO_TILE'
+
+export function editRoomName(name) {
+ return {
+ type: EDIT_ROOM_NAME,
+ name,
+ }
+}
+
+export function startRackConstruction() {
+ return {
+ type: START_RACK_CONSTRUCTION,
+ }
+}
+
+export function stopRackConstruction() {
+ return {
+ type: STOP_RACK_CONSTRUCTION,
+ }
+}
+
+export function addRackToTile(positionX, positionY) {
+ return (dispatch, getState) => {
+ const { objects, interactionLevel } = getState()
+ const currentRoom = objects.room[interactionLevel.roomId]
+ const tiles = currentRoom.tileIds.map((tileId) => objects.tile[tileId])
+ const tile = findTileWithPosition(tiles, positionX, positionY)
+
+ if (tile !== null) {
+ dispatch({
+ type: ADD_RACK_TO_TILE,
+ tileId: tile._id,
+ })
+ }
+ }
+}
+
+export function deleteRoom() {
+ return {
+ type: DELETE_ROOM,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/actions/users.js b/opendc-web/opendc-web-ui/src/actions/users.js
new file mode 100644
index 00000000..4868ac34
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/actions/users.js
@@ -0,0 +1,37 @@
+export const FETCH_AUTHORIZATIONS_OF_CURRENT_USER = 'FETCH_AUTHORIZATIONS_OF_CURRENT_USER'
+export const FETCH_AUTHORIZATIONS_OF_CURRENT_USER_SUCCEEDED = 'FETCH_AUTHORIZATIONS_OF_CURRENT_USER_SUCCEEDED'
+export const DELETE_CURRENT_USER = 'DELETE_CURRENT_USER'
+export const DELETE_CURRENT_USER_SUCCEEDED = 'DELETE_CURRENT_USER_SUCCEEDED'
+
+export function fetchAuthorizationsOfCurrentUser() {
+ return (dispatch, getState) => {
+ const { auth } = getState()
+ dispatch({
+ type: FETCH_AUTHORIZATIONS_OF_CURRENT_USER,
+ userId: auth.userId,
+ })
+ }
+}
+
+export function fetchAuthorizationsOfCurrentUserSucceeded(authorizationsOfCurrentUser) {
+ return {
+ type: FETCH_AUTHORIZATIONS_OF_CURRENT_USER_SUCCEEDED,
+ authorizationsOfCurrentUser,
+ }
+}
+
+export function deleteCurrentUser() {
+ return (dispatch, getState) => {
+ const { auth } = getState()
+ dispatch({
+ type: DELETE_CURRENT_USER,
+ userId: auth.userId,
+ })
+ }
+}
+
+export function deleteCurrentUserSucceeded() {
+ return {
+ type: DELETE_CURRENT_USER_SUCCEEDED,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/api/index.js b/opendc-web/opendc-web-ui/src/api/index.js
new file mode 100644
index 00000000..cefcb2c5
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/index.js
@@ -0,0 +1,13 @@
+import { sendSocketRequest } from './socket'
+
+export function sendRequest(request) {
+ return new Promise((resolve, reject) => {
+ sendSocketRequest(request, (response) => {
+ if (response.status.code === 200) {
+ resolve(response.content)
+ } else {
+ reject(response)
+ }
+ })
+ })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/portfolios.js b/opendc-web/opendc-web-ui/src/api/routes/portfolios.js
new file mode 100644
index 00000000..7c9ea02a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/portfolios.js
@@ -0,0 +1,42 @@
+import { deleteById, getById } from './util'
+import { sendRequest } from '../index'
+
+export function addPortfolio(projectId, portfolio) {
+ return sendRequest({
+ path: '/projects/{projectId}/portfolios',
+ method: 'POST',
+ parameters: {
+ body: {
+ portfolio,
+ },
+ path: {
+ projectId,
+ },
+ query: {},
+ },
+ })
+}
+
+export function getPortfolio(portfolioId) {
+ return getById('/portfolios/{portfolioId}', { portfolioId })
+}
+
+export function updatePortfolio(portfolioId, portfolio) {
+ return sendRequest({
+ path: '/portfolios/{projectId}',
+ method: 'POST',
+ parameters: {
+ body: {
+ portfolio,
+ },
+ path: {
+ portfolioId,
+ },
+ query: {},
+ },
+ })
+}
+
+export function deletePortfolio(portfolioId) {
+ return deleteById('/portfolios/{portfolioId}', { portfolioId })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/prefabs.js b/opendc-web/opendc-web-ui/src/api/routes/prefabs.js
new file mode 100644
index 00000000..8a1debfa
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/prefabs.js
@@ -0,0 +1,40 @@
+import { sendRequest } from '../index'
+import { deleteById, getById } from './util'
+
+export function getPrefab(prefabId) {
+ return getById('/prefabs/{prefabId}', { prefabId })
+}
+
+export function addPrefab(prefab) {
+ return sendRequest({
+ path: '/prefabs',
+ method: 'POST',
+ parameters: {
+ body: {
+ prefab,
+ },
+ path: {},
+ query: {},
+ },
+ })
+}
+
+export function updatePrefab(prefab) {
+ return sendRequest({
+ path: '/prefabs/{prefabId}',
+ method: 'PUT',
+ parameters: {
+ body: {
+ prefab,
+ },
+ path: {
+ prefabId: prefab._id,
+ },
+ query: {},
+ },
+ })
+}
+
+export function deletePrefab(prefabId) {
+ return deleteById('/prefabs/{prefabId}', { prefabId })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/projects.js b/opendc-web/opendc-web-ui/src/api/routes/projects.js
new file mode 100644
index 00000000..4109079c
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/projects.js
@@ -0,0 +1,40 @@
+import { sendRequest } from '../index'
+import { deleteById, getById } from './util'
+
+export function getProject(projectId) {
+ return getById('/projects/{projectId}', { projectId })
+}
+
+export function addProject(project) {
+ return sendRequest({
+ path: '/projects',
+ method: 'POST',
+ parameters: {
+ body: {
+ project,
+ },
+ path: {},
+ query: {},
+ },
+ })
+}
+
+export function updateProject(project) {
+ return sendRequest({
+ path: '/projects/{projectId}',
+ method: 'PUT',
+ parameters: {
+ body: {
+ project,
+ },
+ path: {
+ projectId: project._id,
+ },
+ query: {},
+ },
+ })
+}
+
+export function deleteProject(projectId) {
+ return deleteById('/projects/{projectId}', { projectId })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/scenarios.js b/opendc-web/opendc-web-ui/src/api/routes/scenarios.js
new file mode 100644
index 00000000..ab2e8b86
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/scenarios.js
@@ -0,0 +1,42 @@
+import { deleteById, getById } from './util'
+import { sendRequest } from '../index'
+
+export function addScenario(portfolioId, scenario) {
+ return sendRequest({
+ path: '/portfolios/{portfolioId}/scenarios',
+ method: 'POST',
+ parameters: {
+ body: {
+ scenario,
+ },
+ path: {
+ portfolioId,
+ },
+ query: {},
+ },
+ })
+}
+
+export function getScenario(scenarioId) {
+ return getById('/scenarios/{scenarioId}', { scenarioId })
+}
+
+export function updateScenario(scenarioId, scenario) {
+ return sendRequest({
+ path: '/scenarios/{projectId}',
+ method: 'POST',
+ parameters: {
+ body: {
+ scenario,
+ },
+ path: {
+ scenarioId,
+ },
+ query: {},
+ },
+ })
+}
+
+export function deleteScenario(scenarioId) {
+ return deleteById('/scenarios/{scenarioId}', { scenarioId })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/schedulers.js b/opendc-web/opendc-web-ui/src/api/routes/schedulers.js
new file mode 100644
index 00000000..4481fb2a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/schedulers.js
@@ -0,0 +1,5 @@
+import { getAll } from './util'
+
+export function getAllSchedulers() {
+ return getAll('/schedulers')
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/token-signin.js b/opendc-web/opendc-web-ui/src/api/routes/token-signin.js
new file mode 100644
index 00000000..d6cff570
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/token-signin.js
@@ -0,0 +1,10 @@
+export function performTokenSignIn(token) {
+ const apiUrl = process.env.REACT_APP_API_BASE_URL || ''
+
+ return fetch(`${apiUrl}/tokensignin`, {
+ method: 'POST',
+ body: new URLSearchParams({
+ idtoken: token,
+ }),
+ }).then((res) => res.json())
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/topologies.js b/opendc-web/opendc-web-ui/src/api/routes/topologies.js
new file mode 100644
index 00000000..a8f0d6b1
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/topologies.js
@@ -0,0 +1,42 @@
+import { deleteById, getById } from './util'
+import { sendRequest } from '../index'
+
+export function addTopology(topology) {
+ return sendRequest({
+ path: '/projects/{projectId}/topologies',
+ method: 'POST',
+ parameters: {
+ body: {
+ topology,
+ },
+ path: {
+ projectId: topology.projectId,
+ },
+ query: {},
+ },
+ })
+}
+
+export function getTopology(topologyId) {
+ return getById('/topologies/{topologyId}', { topologyId })
+}
+
+export function updateTopology(topology) {
+ return sendRequest({
+ path: '/topologies/{topologyId}',
+ method: 'PUT',
+ parameters: {
+ body: {
+ topology,
+ },
+ path: {
+ topologyId: topology._id,
+ },
+ query: {},
+ },
+ })
+}
+
+export function deleteTopology(topologyId) {
+ return deleteById('/topologies/{topologyId}', { topologyId })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/traces.js b/opendc-web/opendc-web-ui/src/api/routes/traces.js
new file mode 100644
index 00000000..67895a87
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/traces.js
@@ -0,0 +1,5 @@
+import { getAll } from './util'
+
+export function getAllTraces() {
+ return getAll('/traces')
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/users.js b/opendc-web/opendc-web-ui/src/api/routes/users.js
new file mode 100644
index 00000000..3028f3f7
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/users.js
@@ -0,0 +1,48 @@
+import { sendRequest } from '../index'
+import { deleteById } from './util'
+
+export function getUserByEmail(email) {
+ return sendRequest({
+ path: '/users',
+ method: 'GET',
+ parameters: {
+ body: {},
+ path: {},
+ query: {
+ email,
+ },
+ },
+ })
+}
+
+export function addUser(user) {
+ return sendRequest({
+ path: '/users',
+ method: 'POST',
+ parameters: {
+ body: {
+ user,
+ },
+ path: {},
+ query: {},
+ },
+ })
+}
+
+export function getUser(userId) {
+ return sendRequest({
+ path: '/users/{userId}',
+ method: 'GET',
+ parameters: {
+ body: {},
+ path: {
+ userId,
+ },
+ query: {},
+ },
+ })
+}
+
+export function deleteUser(userId) {
+ return deleteById('/users/{userId}', { userId })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/routes/util.js b/opendc-web/opendc-web-ui/src/api/routes/util.js
new file mode 100644
index 00000000..67e7173b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/routes/util.js
@@ -0,0 +1,37 @@
+import { sendRequest } from '../index'
+
+export function getAll(path) {
+ return sendRequest({
+ path,
+ method: 'GET',
+ parameters: {
+ body: {},
+ path: {},
+ query: {},
+ },
+ })
+}
+
+export function getById(path, pathObject) {
+ return sendRequest({
+ path,
+ method: 'GET',
+ parameters: {
+ body: {},
+ path: pathObject,
+ query: {},
+ },
+ })
+}
+
+export function deleteById(path, pathObject) {
+ return sendRequest({
+ path,
+ method: 'DELETE',
+ parameters: {
+ body: {},
+ path: pathObject,
+ query: {},
+ },
+ })
+}
diff --git a/opendc-web/opendc-web-ui/src/api/socket.js b/opendc-web/opendc-web-ui/src/api/socket.js
new file mode 100644
index 00000000..1c432167
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/api/socket.js
@@ -0,0 +1,50 @@
+import io from 'socket.io-client'
+import { getAuthToken } from '../auth/index'
+
+let socket
+let requestIdCounter = 0
+const callbacks = {}
+
+export function setupSocketConnection(onConnect) {
+ const apiUrl =
+ process.env.REACT_APP_API_BASE_URL ||
+ `${window.location.protocol}//${window.location.hostname}:${window.location.port}`
+
+ socket = io.connect(apiUrl)
+ socket.on('connect', onConnect)
+ socket.on('response', onSocketResponse)
+}
+
+export function sendSocketRequest(request, callback) {
+ if (!socket.connected) {
+ console.error('Attempted to send request over unconnected socket')
+ return
+ }
+
+ const newId = requestIdCounter++
+ callbacks[newId] = callback
+
+ request.id = newId
+ request.token = getAuthToken()
+
+ if (!request.isRootRoute) {
+ request.path = '/v2' + request.path
+ }
+
+ socket.emit('request', request)
+
+ if (process.env.NODE_ENV !== 'production') {
+ console.log('Sent socket request:', request)
+ }
+}
+
+function onSocketResponse(json) {
+ const response = JSON.parse(json)
+
+ if (process.env.NODE_ENV !== 'production') {
+ console.log('Received socket response:', response)
+ }
+
+ callbacks[response.id](response)
+ delete callbacks[response.id]
+}
diff --git a/opendc-web/opendc-web-ui/src/auth/index.js b/opendc-web/opendc-web-ui/src/auth/index.js
new file mode 100644
index 00000000..b5953990
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/auth/index.js
@@ -0,0 +1,57 @@
+import { LOG_IN_SUCCEEDED, LOG_OUT } from '../actions/auth'
+import { DELETE_CURRENT_USER_SUCCEEDED } from '../actions/users'
+
+const getAuthObject = () => {
+ const authItem = localStorage.getItem('auth')
+ if (!authItem || authItem === '{}') {
+ return undefined
+ }
+ return JSON.parse(authItem)
+}
+
+export const userIsLoggedIn = () => {
+ const authObj = getAuthObject()
+
+ if (!authObj || !authObj.googleId) {
+ return false
+ }
+
+ const currentTime = new Date().getTime()
+ return parseInt(authObj.expiresAt, 10) - currentTime > 0
+}
+
+export const getAuthToken = () => {
+ const authObj = getAuthObject()
+ if (!authObj) {
+ return undefined
+ }
+
+ return authObj.authToken
+}
+
+export const saveAuthLocalStorage = (payload) => {
+ localStorage.setItem('auth', JSON.stringify(payload))
+}
+
+export const clearAuthLocalStorage = () => {
+ localStorage.setItem('auth', '')
+}
+
+export const authRedirectMiddleware = (store) => (next) => (action) => {
+ switch (action.type) {
+ case LOG_IN_SUCCEEDED:
+ saveAuthLocalStorage(action.payload)
+ window.location.href = '/projects'
+ break
+ case LOG_OUT:
+ case DELETE_CURRENT_USER_SUCCEEDED:
+ clearAuthLocalStorage()
+ window.location.href = '/'
+ break
+ default:
+ next(action)
+ return
+ }
+
+ next(action)
+}
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
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/GrayContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/GrayContainer.js
new file mode 100644
index 00000000..9e4a6969
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/GrayContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { goDownOneInteractionLevel } from '../../../actions/interaction-level'
+import GrayLayer from '../../../components/app/map/elements/GrayLayer'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(goDownOneInteractionLevel()),
+ }
+}
+
+const GrayContainer = connect(undefined, mapDispatchToProps)(GrayLayer)
+
+export default GrayContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/MapStage.js b/opendc-web/opendc-web-ui/src/containers/app/map/MapStage.js
new file mode 100644
index 00000000..23c920b6
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/MapStage.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux'
+import { setMapDimensions, setMapPositionWithBoundsCheck, zoomInOnPosition } from '../../../actions/map'
+import MapStageComponent from '../../../components/app/map/MapStageComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ mapPosition: state.map.position,
+ mapDimensions: state.map.dimensions,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ zoomInOnPosition: (zoomIn, x, y) => dispatch(zoomInOnPosition(zoomIn, x, y)),
+ setMapPositionWithBoundsCheck: (x, y) => dispatch(setMapPositionWithBoundsCheck(x, y)),
+ setMapDimensions: (width, height) => dispatch(setMapDimensions(width, height)),
+ }
+}
+
+const MapStage = connect(mapStateToProps, mapDispatchToProps)(MapStageComponent)
+
+export default MapStage
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/RackContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/RackContainer.js
new file mode 100644
index 00000000..40077608
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/RackContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import RackGroup from '../../../components/app/map/groups/RackGroup'
+
+const mapStateToProps = (state) => {
+ return {
+ interactionLevel: state.interactionLevel,
+ }
+}
+
+const RackContainer = connect(mapStateToProps)(RackGroup)
+
+export default RackContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/RackEnergyFillContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/RackEnergyFillContainer.js
new file mode 100644
index 00000000..53746271
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/RackEnergyFillContainer.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux'
+import RackFillBar from '../../../components/app/map/elements/RackFillBar'
+
+const mapStateToProps = (state, ownProps) => {
+ let energyConsumptionTotal = 0
+ const rack = state.objects.rack[state.objects.tile[ownProps.tileId].rackId]
+ const machineIds = rack.machineIds
+ machineIds.forEach((machineId) => {
+ if (machineId !== null) {
+ const machine = state.objects.machine[machineId]
+ machine.cpuIds.forEach((id) => (energyConsumptionTotal += state.objects.cpu[id].energyConsumptionW))
+ machine.gpuIds.forEach((id) => (energyConsumptionTotal += state.objects.gpu[id].energyConsumptionW))
+ machine.memoryIds.forEach((id) => (energyConsumptionTotal += state.objects.memory[id].energyConsumptionW))
+ machine.storageIds.forEach((id) => (energyConsumptionTotal += state.objects.storage[id].energyConsumptionW))
+ }
+ })
+
+ return {
+ type: 'energy',
+ fillFraction: Math.min(1, energyConsumptionTotal / rack.powerCapacityW),
+ }
+}
+
+const RackSpaceFillContainer = connect(mapStateToProps)(RackFillBar)
+
+export default RackSpaceFillContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/RackSpaceFillContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/RackSpaceFillContainer.js
new file mode 100644
index 00000000..0509a5a5
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/RackSpaceFillContainer.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux'
+import RackFillBar from '../../../components/app/map/elements/RackFillBar'
+
+const mapStateToProps = (state, ownProps) => {
+ const machineIds = state.objects.rack[state.objects.tile[ownProps.tileId].rackId].machineIds
+ return {
+ type: 'space',
+ fillFraction: machineIds.filter((id) => id !== null).length / machineIds.length,
+ }
+}
+
+const RackSpaceFillContainer = connect(mapStateToProps)(RackFillBar)
+
+export default RackSpaceFillContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/RoomContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/RoomContainer.js
new file mode 100644
index 00000000..91bf4e5d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/RoomContainer.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux'
+import { goFromBuildingToRoom } from '../../../actions/interaction-level'
+import RoomGroup from '../../../components/app/map/groups/RoomGroup'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ interactionLevel: state.interactionLevel,
+ currentRoomInConstruction: state.construction.currentRoomInConstruction,
+ room: state.objects.room[ownProps.roomId],
+ }
+}
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onClick: () => dispatch(goFromBuildingToRoom(ownProps.roomId)),
+ }
+}
+
+const RoomContainer = connect(mapStateToProps, mapDispatchToProps)(RoomGroup)
+
+export default RoomContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/TileContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/TileContainer.js
new file mode 100644
index 00000000..04d6c8d6
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/TileContainer.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux'
+import { goFromRoomToRack } from '../../../actions/interaction-level'
+import TileGroup from '../../../components/app/map/groups/TileGroup'
+
+const mapStateToProps = (state, ownProps) => {
+ const tile = state.objects.tile[ownProps.tileId]
+
+ return {
+ interactionLevel: state.interactionLevel,
+ tile,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: (tile) => {
+ if (tile.rackId) {
+ dispatch(goFromRoomToRack(tile._id))
+ }
+ },
+ }
+}
+
+const TileContainer = connect(mapStateToProps, mapDispatchToProps)(TileGroup)
+
+export default TileContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/TopologyContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/TopologyContainer.js
new file mode 100644
index 00000000..de43a151
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/TopologyContainer.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux'
+import TopologyGroup from '../../../components/app/map/groups/TopologyGroup'
+
+const mapStateToProps = (state) => {
+ if (state.currentTopologyId === '-1') {
+ return {}
+ }
+
+ return {
+ topology: state.objects.topology[state.currentTopologyId],
+ interactionLevel: state.interactionLevel,
+ }
+}
+
+const TopologyContainer = connect(mapStateToProps)(TopologyGroup)
+
+export default TopologyContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/WallContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/WallContainer.js
new file mode 100644
index 00000000..67f8a242
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/WallContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import WallGroup from '../../../components/app/map/groups/WallGroup'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ tiles: state.objects.room[ownProps.roomId].tileIds.map((tileId) => state.objects.tile[tileId]),
+ }
+}
+
+const WallContainer = connect(mapStateToProps)(WallGroup)
+
+export default WallContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/controls/ScaleIndicatorContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/controls/ScaleIndicatorContainer.js
new file mode 100644
index 00000000..fa3b9d22
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/controls/ScaleIndicatorContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import ScaleIndicatorComponent from '../../../../components/app/map/controls/ScaleIndicatorComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ scale: state.map.scale,
+ }
+}
+
+const ScaleIndicatorContainer = connect(mapStateToProps)(ScaleIndicatorComponent)
+
+export default ScaleIndicatorContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/controls/ZoomControlContainer.js b/opendc-web/opendc-web-ui/src/containers/app/map/controls/ZoomControlContainer.js
new file mode 100644
index 00000000..ddc68cc7
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/controls/ZoomControlContainer.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux'
+import { zoomInOnCenter } from '../../../../actions/map'
+import ZoomControlComponent from '../../../../components/app/map/controls/ZoomControlComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ mapScale: state.map.scale,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ zoomInOnCenter: (zoomIn) => dispatch(zoomInOnCenter(zoomIn)),
+ }
+}
+
+const ZoomControlContainer = connect(mapStateToProps, mapDispatchToProps)(ZoomControlComponent)
+
+export default ZoomControlContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/layers/MapLayer.js b/opendc-web/opendc-web-ui/src/containers/app/map/layers/MapLayer.js
new file mode 100644
index 00000000..8596cb9c
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/layers/MapLayer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import MapLayerComponent from '../../../../components/app/map/layers/MapLayerComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ mapPosition: state.map.position,
+ mapScale: state.map.scale,
+ }
+}
+
+const MapLayer = connect(mapStateToProps)(MapLayerComponent)
+
+export default MapLayer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/layers/ObjectHoverLayer.js b/opendc-web/opendc-web-ui/src/containers/app/map/layers/ObjectHoverLayer.js
new file mode 100644
index 00000000..a4927862
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/layers/ObjectHoverLayer.js
@@ -0,0 +1,33 @@
+import { connect } from 'react-redux'
+import { addRackToTile } from '../../../../actions/topology/room'
+import ObjectHoverLayerComponent from '../../../../components/app/map/layers/ObjectHoverLayerComponent'
+import { findTileWithPosition } from '../../../../util/tile-calculations'
+
+const mapStateToProps = (state) => {
+ return {
+ mapPosition: state.map.position,
+ mapScale: state.map.scale,
+ isEnabled: () => state.construction.inRackConstructionMode,
+ isValid: (x, y) => {
+ if (state.interactionLevel.mode !== 'ROOM') {
+ return false
+ }
+
+ const currentRoom = state.objects.room[state.interactionLevel.roomId]
+ const tiles = currentRoom.tileIds.map((tileId) => state.objects.tile[tileId])
+ const tile = findTileWithPosition(tiles, x, y)
+
+ return !(tile === null || tile.rackId)
+ },
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: (x, y) => dispatch(addRackToTile(x, y)),
+ }
+}
+
+const ObjectHoverLayer = connect(mapStateToProps, mapDispatchToProps)(ObjectHoverLayerComponent)
+
+export default ObjectHoverLayer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/map/layers/RoomHoverLayer.js b/opendc-web/opendc-web-ui/src/containers/app/map/layers/RoomHoverLayer.js
new file mode 100644
index 00000000..66404f9e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/map/layers/RoomHoverLayer.js
@@ -0,0 +1,46 @@
+import { connect } from 'react-redux'
+import { toggleTileAtLocation } from '../../../../actions/topology/building'
+import RoomHoverLayerComponent from '../../../../components/app/map/layers/RoomHoverLayerComponent'
+import {
+ deriveValidNextTilePositions,
+ findPositionInPositions,
+ findPositionInRooms,
+} from '../../../../util/tile-calculations'
+
+const mapStateToProps = (state) => {
+ return {
+ mapPosition: state.map.position,
+ mapScale: state.map.scale,
+ isEnabled: () => state.construction.currentRoomInConstruction !== '-1',
+ isValid: (x, y) => {
+ const newRoom = Object.assign({}, state.objects.room[state.construction.currentRoomInConstruction])
+ const oldRooms = Object.keys(state.objects.room)
+ .map((id) => Object.assign({}, state.objects.room[id]))
+ .filter(
+ (room) =>
+ state.objects.topology[state.currentTopologyId].roomIds.indexOf(room._id) !== -1 &&
+ room._id !== state.construction.currentRoomInConstruction
+ )
+
+ ;[...oldRooms, newRoom].forEach((room) => {
+ room.tiles = room.tileIds.map((tileId) => state.objects.tile[tileId])
+ })
+ if (newRoom.tileIds.length === 0) {
+ return findPositionInRooms(oldRooms, x, y) === -1
+ }
+
+ const validNextPositions = deriveValidNextTilePositions(oldRooms, newRoom.tiles)
+ return findPositionInPositions(validNextPositions, x, y) !== -1
+ },
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: (x, y) => dispatch(toggleTileAtLocation(x, y)),
+ }
+}
+
+const RoomHoverLayer = connect(mapStateToProps, mapDispatchToProps)(RoomHoverLayerComponent)
+
+export default RoomHoverLayer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/results/PortfolioResultsContainer.js b/opendc-web/opendc-web-ui/src/containers/app/results/PortfolioResultsContainer.js
new file mode 100644
index 00000000..4b430e54
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/results/PortfolioResultsContainer.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux'
+import PortfolioResultsComponent from '../../../components/app/results/PortfolioResultsComponent'
+
+const mapStateToProps = (state) => {
+ if (
+ state.currentPortfolioId === '-1' ||
+ !state.objects.portfolio[state.currentPortfolioId] ||
+ state.objects.portfolio[state.currentPortfolioId].scenarioIds
+ .map((scenarioId) => state.objects.scenario[scenarioId])
+ .some((s) => s === undefined)
+ ) {
+ return {
+ portfolio: undefined,
+ scenarios: [],
+ }
+ }
+
+ return {
+ portfolio: state.objects.portfolio[state.currentPortfolioId],
+ scenarios: state.objects.portfolio[state.currentPortfolioId].scenarioIds.map(
+ (scenarioId) => state.objects.scenario[scenarioId]
+ ),
+ }
+}
+
+const PortfolioResultsContainer = connect(mapStateToProps)(PortfolioResultsComponent)
+
+export default PortfolioResultsContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/PortfolioListContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/PortfolioListContainer.js
new file mode 100644
index 00000000..b32c8b1d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/PortfolioListContainer.js
@@ -0,0 +1,45 @@
+import { connect } from 'react-redux'
+import { withRouter } from 'react-router-dom'
+import PortfolioListComponent from '../../../../components/app/sidebars/project/PortfolioListComponent'
+import { deletePortfolio, setCurrentPortfolio } from '../../../../actions/portfolios'
+import { openNewPortfolioModal } from '../../../../actions/modals/portfolios'
+import { getState } from '../../../../util/state-utils'
+import { setCurrentTopology } from '../../../../actions/topology/building'
+
+const mapStateToProps = (state) => {
+ let portfolios = state.objects.project[state.currentProjectId]
+ ? state.objects.project[state.currentProjectId].portfolioIds.map((t) => state.objects.portfolio[t])
+ : []
+ if (portfolios.filter((t) => !t).length > 0) {
+ portfolios = []
+ }
+
+ return {
+ currentProjectId: state.currentProjectId,
+ currentPortfolioId: state.currentPortfolioId,
+ portfolios,
+ }
+}
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onNewPortfolio: () => {
+ dispatch(openNewPortfolioModal())
+ },
+ onChoosePortfolio: (portfolioId) => {
+ dispatch(setCurrentPortfolio(portfolioId))
+ },
+ onDeletePortfolio: async (id) => {
+ if (id) {
+ const state = await getState(dispatch)
+ dispatch(deletePortfolio(id))
+ dispatch(setCurrentTopology(state.objects.project[state.currentProjectId].topologyIds[0]))
+ ownProps.history.push(`/projects/${state.currentProjectId}`)
+ }
+ },
+ }
+}
+
+const PortfolioListContainer = withRouter(connect(mapStateToProps, mapDispatchToProps)(PortfolioListComponent))
+
+export default PortfolioListContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ProjectSidebarContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ProjectSidebarContainer.js
new file mode 100644
index 00000000..49001099
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ProjectSidebarContainer.js
@@ -0,0 +1,10 @@
+import React from 'react'
+import { withRouter } from 'react-router-dom'
+import ProjectSidebarComponent from '../../../../components/app/sidebars/project/ProjectSidebarComponent'
+import { isCollapsible } from '../../../../util/sidebar-space'
+
+const ProjectSidebarContainer = withRouter(({ location, ...props }) => (
+ <ProjectSidebarComponent collapsible={isCollapsible(location)} {...props} />
+))
+
+export default ProjectSidebarContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ScenarioListContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ScenarioListContainer.js
new file mode 100644
index 00000000..415e2792
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/ScenarioListContainer.js
@@ -0,0 +1,41 @@
+import { connect } from 'react-redux'
+import ScenarioListComponent from '../../../../components/app/sidebars/project/ScenarioListComponent'
+import { openNewScenarioModal } from '../../../../actions/modals/scenarios'
+import { deleteScenario, setCurrentScenario } from '../../../../actions/scenarios'
+import { setCurrentPortfolio } from '../../../../actions/portfolios'
+
+const mapStateToProps = (state, ownProps) => {
+ let scenarios = state.objects.portfolio[ownProps.portfolioId]
+ ? state.objects.portfolio[ownProps.portfolioId].scenarioIds.map((t) => state.objects.scenario[t])
+ : []
+ if (scenarios.filter((t) => !t).length > 0) {
+ scenarios = []
+ }
+
+ return {
+ currentProjectId: state.currentProjectId,
+ currentScenarioId: state.currentScenarioId,
+ scenarios,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onNewScenario: (currentPortfolioId) => {
+ dispatch(setCurrentPortfolio(currentPortfolioId))
+ dispatch(openNewScenarioModal())
+ },
+ onChooseScenario: (portfolioId, scenarioId) => {
+ dispatch(setCurrentScenario(portfolioId, scenarioId))
+ },
+ onDeleteScenario: (id) => {
+ if (id) {
+ dispatch(deleteScenario(id))
+ }
+ },
+ }
+}
+
+const ScenarioListContainer = connect(mapStateToProps, mapDispatchToProps)(ScenarioListComponent)
+
+export default ScenarioListContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/TopologyListContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/TopologyListContainer.js
new file mode 100644
index 00000000..e1de18f9
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/project/TopologyListContainer.js
@@ -0,0 +1,46 @@
+import { connect } from 'react-redux'
+import TopologyListComponent from '../../../../components/app/sidebars/project/TopologyListComponent'
+import { setCurrentTopology } from '../../../../actions/topology/building'
+import { openNewTopologyModal } from '../../../../actions/modals/topology'
+import { withRouter } from 'react-router-dom'
+import { getState } from '../../../../util/state-utils'
+import { deleteTopology } from '../../../../actions/topologies'
+
+const mapStateToProps = (state) => {
+ let topologies = state.objects.project[state.currentProjectId]
+ ? state.objects.project[state.currentProjectId].topologyIds.map((t) => state.objects.topology[t])
+ : []
+ if (topologies.filter((t) => !t).length > 0) {
+ topologies = []
+ }
+
+ return {
+ currentTopologyId: state.currentTopologyId,
+ topologies,
+ }
+}
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onChooseTopology: async (id) => {
+ dispatch(setCurrentTopology(id))
+ const state = await getState(dispatch)
+ ownProps.history.push(`/projects/${state.currentProjectId}`)
+ },
+ onNewTopology: () => {
+ dispatch(openNewTopologyModal())
+ },
+ onDeleteTopology: async (id) => {
+ if (id) {
+ const state = await getState(dispatch)
+ dispatch(deleteTopology(id))
+ dispatch(setCurrentTopology(state.objects.project[state.currentProjectId].topologyIds[0]))
+ ownProps.history.push(`/projects/${state.currentProjectId}`)
+ }
+ },
+ }
+}
+
+const TopologyListContainer = withRouter(connect(mapStateToProps, mapDispatchToProps)(TopologyListComponent))
+
+export default TopologyListContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/TopologySidebarContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/TopologySidebarContainer.js
new file mode 100644
index 00000000..fe7c02fd
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/TopologySidebarContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import TopologySidebarComponent from '../../../../components/app/sidebars/topology/TopologySidebarComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ interactionLevel: state.interactionLevel,
+ }
+}
+
+const TopologySidebarContainer = connect(mapStateToProps)(TopologySidebarComponent)
+
+export default TopologySidebarContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js
new file mode 100644
index 00000000..a0b52e56
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js
@@ -0,0 +1,5 @@
+import BuildingSidebarComponent from '../../../../../components/app/sidebars/topology/building/BuildingSidebarComponent'
+
+const BuildingSidebarContainer = BuildingSidebarComponent
+
+export default BuildingSidebarContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js
new file mode 100644
index 00000000..ea9e9e60
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux'
+import {
+ cancelNewRoomConstruction,
+ finishNewRoomConstruction,
+ startNewRoomConstruction,
+} from '../../../../../actions/topology/building'
+import StartNewRoomConstructionComponent from '../../../../../components/app/sidebars/topology/building/NewRoomConstructionComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ currentRoomInConstruction: state.construction.currentRoomInConstruction,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onStart: () => dispatch(startNewRoomConstruction()),
+ onFinish: () => dispatch(finishNewRoomConstruction()),
+ onCancel: () => dispatch(cancelNewRoomConstruction()),
+ }
+}
+
+const NewRoomConstructionButton = connect(mapStateToProps, mapDispatchToProps)(StartNewRoomConstructionComponent)
+
+export default NewRoomConstructionButton
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/BackToRackContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/BackToRackContainer.js
new file mode 100644
index 00000000..24287ab0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/BackToRackContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { goDownOneInteractionLevel } from '../../../../../actions/interaction-level'
+import BackToRackComponent from '../../../../../components/app/sidebars/topology/machine/BackToRackComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(goDownOneInteractionLevel()),
+ }
+}
+
+const BackToRackContainer = connect(undefined, mapDispatchToProps)(BackToRackComponent)
+
+export default BackToRackContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js
new file mode 100644
index 00000000..65e683e6
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { openDeleteMachineModal } from '../../../../../actions/modals/topology'
+import DeleteMachineComponent from '../../../../../components/app/sidebars/topology/machine/DeleteMachineComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(openDeleteMachineModal()),
+ }
+}
+
+const DeleteMachineContainer = connect(undefined, mapDispatchToProps)(DeleteMachineComponent)
+
+export default DeleteMachineContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineNameContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineNameContainer.js
new file mode 100644
index 00000000..1cf35b05
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineNameContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import MachineNameComponent from '../../../../../components/app/sidebars/topology/machine/MachineNameComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ position: state.interactionLevel.position,
+ }
+}
+
+const MachineNameContainer = connect(mapStateToProps)(MachineNameComponent)
+
+export default MachineNameContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js
new file mode 100644
index 00000000..b04e3118
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux'
+import MachineSidebarComponent from '../../../../../components/app/sidebars/topology/machine/MachineSidebarComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ machineId:
+ state.objects.rack[state.objects.tile[state.interactionLevel.tileId].rackId].machineIds[
+ state.interactionLevel.position - 1
+ ],
+ }
+}
+
+const MachineSidebarContainer = connect(mapStateToProps)(MachineSidebarComponent)
+
+export default MachineSidebarContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitAddContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitAddContainer.js
new file mode 100644
index 00000000..29e48016
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitAddContainer.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux'
+import { addUnit } from '../../../../../actions/topology/machine'
+import UnitAddComponent from '../../../../../components/app/sidebars/topology/machine/UnitAddComponent'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ units: Object.values(state.objects[ownProps.unitType]),
+ }
+}
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onAdd: (id) => dispatch(addUnit(ownProps.unitType, id)),
+ }
+}
+
+const UnitAddContainer = connect(mapStateToProps, mapDispatchToProps)(UnitAddComponent)
+
+export default UnitAddContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitContainer.js
new file mode 100644
index 00000000..f334f9f2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitContainer.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux'
+import { deleteUnit } from '../../../../../actions/topology/machine'
+import UnitComponent from '../../../../../components/app/sidebars/topology/machine/UnitComponent'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ unit: state.objects[ownProps.unitType][ownProps.unitId],
+ index: ownProps.unitId,
+ }
+}
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onDelete: () => dispatch(deleteUnit(ownProps.unitType, ownProps.index)),
+ }
+}
+
+const UnitContainer = connect(mapStateToProps, mapDispatchToProps)(UnitComponent)
+
+export default UnitContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitListContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitListContainer.js
new file mode 100644
index 00000000..f382ff74
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitListContainer.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux'
+import UnitListComponent from '../../../../../components/app/sidebars/topology/machine/UnitListComponent'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ unitIds:
+ state.objects.machine[
+ state.objects.rack[state.objects.tile[state.interactionLevel.tileId].rackId].machineIds[
+ state.interactionLevel.position - 1
+ ]
+ ][ownProps.unitType + 'Ids'],
+ }
+}
+
+const UnitListContainer = connect(mapStateToProps)(UnitListComponent)
+
+export default UnitListContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js
new file mode 100644
index 00000000..00fe4067
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js
@@ -0,0 +1,5 @@
+import UnitTabsComponent from '../../../../../components/app/sidebars/topology/machine/UnitTabsComponent'
+
+const UnitTabsContainer = UnitTabsComponent
+
+export default UnitTabsContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/AddPrefabContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/AddPrefabContainer.js
new file mode 100644
index 00000000..c941e745
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/AddPrefabContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { addPrefab } from '../../../../../actions/prefabs'
+import AddPrefabComponent from '../../../../../components/app/sidebars/topology/rack/AddPrefabComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(addPrefab('name')),
+ }
+}
+
+const AddPrefabContainer = connect(undefined, mapDispatchToProps)(AddPrefabComponent)
+
+export default AddPrefabContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js
new file mode 100644
index 00000000..58c3b082
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { goDownOneInteractionLevel } from '../../../../../actions/interaction-level'
+import BackToRoomComponent from '../../../../../components/app/sidebars/topology/rack/BackToRoomComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(goDownOneInteractionLevel()),
+ }
+}
+
+const BackToRoomContainer = connect(undefined, mapDispatchToProps)(BackToRoomComponent)
+
+export default BackToRoomContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js
new file mode 100644
index 00000000..8229a359
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { openDeleteRackModal } from '../../../../../actions/modals/topology'
+import DeleteRackComponent from '../../../../../components/app/sidebars/topology/rack/DeleteRackComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(openDeleteRackModal()),
+ }
+}
+
+const DeleteRackContainer = connect(undefined, mapDispatchToProps)(DeleteRackComponent)
+
+export default DeleteRackContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js
new file mode 100644
index 00000000..cf341da9
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { addMachine } from '../../../../../actions/topology/rack'
+import EmptySlotComponent from '../../../../../components/app/sidebars/topology/rack/EmptySlotComponent'
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onAdd: () => dispatch(addMachine(ownProps.position)),
+ }
+}
+
+const EmptySlotContainer = connect(undefined, mapDispatchToProps)(EmptySlotComponent)
+
+export default EmptySlotContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineContainer.js
new file mode 100644
index 00000000..fe12827d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineContainer.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux'
+import { goFromRackToMachine } from '../../../../../actions/interaction-level'
+import MachineComponent from '../../../../../components/app/sidebars/topology/rack/MachineComponent'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ machine: state.objects.machine[ownProps.machineId],
+ }
+}
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onClick: () => dispatch(goFromRackToMachine(ownProps.position)),
+ }
+}
+
+const MachineContainer = connect(mapStateToProps, mapDispatchToProps)(MachineComponent)
+
+export default MachineContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineListContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineListContainer.js
new file mode 100644
index 00000000..bc5a285a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/MachineListContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import MachineListComponent from '../../../../../components/app/sidebars/topology/rack/MachineListComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ machineIds: state.objects.rack[state.objects.tile[state.interactionLevel.tileId].rackId].machineIds,
+ }
+}
+
+const MachineListContainer = connect(mapStateToProps)(MachineListComponent)
+
+export default MachineListContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackNameContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackNameContainer.js
new file mode 100644
index 00000000..504dbc61
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackNameContainer.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux'
+import { openEditRackNameModal } from '../../../../../actions/modals/topology'
+import RackNameComponent from '../../../../../components/app/sidebars/topology/rack/RackNameComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ rackName: state.objects.rack[state.objects.tile[state.interactionLevel.tileId].rackId].name,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onEdit: () => dispatch(openEditRackNameModal()),
+ }
+}
+
+const RackNameContainer = connect(mapStateToProps, mapDispatchToProps)(RackNameComponent)
+
+export default RackNameContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js
new file mode 100644
index 00000000..453d7e41
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import RackSidebarComponent from '../../../../../components/app/sidebars/topology/rack/RackSidebarComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ rackId: state.objects.tile[state.interactionLevel.tileId].rackId,
+ }
+}
+
+const RackSidebarContainer = connect(mapStateToProps)(RackSidebarComponent)
+
+export default RackSidebarContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js
new file mode 100644
index 00000000..4c1ab99d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { goDownOneInteractionLevel } from '../../../../../actions/interaction-level'
+import BackToBuildingComponent from '../../../../../components/app/sidebars/topology/room/BackToBuildingComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(goDownOneInteractionLevel()),
+ }
+}
+
+const BackToBuildingContainer = connect(undefined, mapDispatchToProps)(BackToBuildingComponent)
+
+export default BackToBuildingContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js
new file mode 100644
index 00000000..636fa5c5
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { openDeleteRoomModal } from '../../../../../actions/modals/topology'
+import DeleteRoomComponent from '../../../../../components/app/sidebars/topology/room/DeleteRoomComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(openDeleteRoomModal()),
+ }
+}
+
+const DeleteRoomContainer = connect(undefined, mapDispatchToProps)(DeleteRoomComponent)
+
+export default DeleteRoomContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/EditRoomContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/EditRoomContainer.js
new file mode 100644
index 00000000..d17a45d1
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/EditRoomContainer.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux'
+import { finishRoomEdit, startRoomEdit } from '../../../../../actions/topology/building'
+import EditRoomComponent from '../../../../../components/app/sidebars/topology/room/EditRoomComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ isEditing: state.construction.currentRoomInConstruction !== '-1',
+ isInRackConstructionMode: state.construction.inRackConstructionMode,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onEdit: () => dispatch(startRoomEdit()),
+ onFinish: () => dispatch(finishRoomEdit()),
+ }
+}
+
+const EditRoomContainer = connect(mapStateToProps, mapDispatchToProps)(EditRoomComponent)
+
+export default EditRoomContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RackConstructionContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RackConstructionContainer.js
new file mode 100644
index 00000000..cd8319de
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RackConstructionContainer.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux'
+import { startRackConstruction, stopRackConstruction } from '../../../../../actions/topology/room'
+import RackConstructionComponent from '../../../../../components/app/sidebars/topology/room/RackConstructionComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ inRackConstructionMode: state.construction.inRackConstructionMode,
+ isEditingRoom: state.construction.currentRoomInConstruction !== '-1',
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onStart: () => dispatch(startRackConstruction()),
+ onStop: () => dispatch(stopRackConstruction()),
+ }
+}
+
+const RackConstructionContainer = connect(mapStateToProps, mapDispatchToProps)(RackConstructionComponent)
+
+export default RackConstructionContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomNameContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomNameContainer.js
new file mode 100644
index 00000000..cab16016
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomNameContainer.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux'
+import { openEditRoomNameModal } from '../../../../../actions/modals/topology'
+import RoomNameComponent from '../../../../../components/app/sidebars/topology/room/RoomNameComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ roomName: state.objects.room[state.interactionLevel.roomId].name,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onEdit: () => dispatch(openEditRoomNameModal()),
+ }
+}
+
+const RoomNameContainer = connect(mapStateToProps, mapDispatchToProps)(RoomNameComponent)
+
+export default RoomNameContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js
new file mode 100644
index 00000000..8c3ca8ab
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import RoomSidebarComponent from '../../../../../components/app/sidebars/topology/room/RoomSidebarComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ roomId: state.interactionLevel.roomId,
+ }
+}
+
+const RoomSidebarContainer = connect(mapStateToProps)(RoomSidebarComponent)
+
+export default RoomSidebarContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/auth/Login.js b/opendc-web/opendc-web-ui/src/containers/auth/Login.js
new file mode 100644
index 00000000..2f9726bf
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/auth/Login.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import GoogleLogin from 'react-google-login'
+import { connect } from 'react-redux'
+import { logIn } from '../../actions/auth'
+
+class LoginContainer extends React.Component {
+ static propTypes = {
+ visible: PropTypes.bool.isRequired,
+ onLogin: PropTypes.func.isRequired,
+ }
+
+ onAuthResponse(response) {
+ this.props.onLogin({
+ email: response.getBasicProfile().getEmail(),
+ givenName: response.getBasicProfile().getGivenName(),
+ familyName: response.getBasicProfile().getFamilyName(),
+ googleId: response.googleId,
+ authToken: response.getAuthResponse().id_token,
+ expiresAt: response.getAuthResponse().expires_at,
+ })
+ }
+
+ onAuthFailure(error) {
+ console.error(error)
+ }
+
+ render() {
+ if (!this.props.visible) {
+ return <span />
+ }
+
+ return (
+ <GoogleLogin
+ clientId={process.env.REACT_APP_OAUTH_CLIENT_ID}
+ onSuccess={this.onAuthResponse.bind(this)}
+ onFailure={this.onAuthFailure.bind(this)}
+ render={(renderProps) => (
+ <span onClick={renderProps.onClick} className="login btn btn-primary">
+ <span className="fa fa-google" /> Login with Google
+ </span>
+ )}
+ ></GoogleLogin>
+ )
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ visible: ownProps.visible,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onLogin: (payload) => dispatch(logIn(payload)),
+ }
+}
+
+const Login = connect(mapStateToProps, mapDispatchToProps)(LoginContainer)
+
+export default Login
diff --git a/opendc-web/opendc-web-ui/src/containers/auth/Logout.js b/opendc-web/opendc-web-ui/src/containers/auth/Logout.js
new file mode 100644
index 00000000..22400381
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/auth/Logout.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { logOut } from '../../actions/auth'
+import LogoutButton from '../../components/navigation/LogoutButton'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onLogout: () => dispatch(logOut()),
+ }
+}
+
+const Logout = connect(undefined, mapDispatchToProps)(LogoutButton)
+
+export default Logout
diff --git a/opendc-web/opendc-web-ui/src/containers/auth/ProfileName.js b/opendc-web/opendc-web-ui/src/containers/auth/ProfileName.js
new file mode 100644
index 00000000..06da75ab
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/auth/ProfileName.js
@@ -0,0 +1,14 @@
+import React from 'react'
+import { connect } from 'react-redux'
+
+const mapStateToProps = (state) => {
+ return {
+ text: state.auth.givenName + ' ' + state.auth.familyName,
+ }
+}
+
+const SpanElement = ({ text }) => <span>{text}</span>
+
+const ProfileName = connect(mapStateToProps)(SpanElement)
+
+export default ProfileName
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/DeleteMachineModal.js b/opendc-web/opendc-web-ui/src/containers/modals/DeleteMachineModal.js
new file mode 100644
index 00000000..f30febdb
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/DeleteMachineModal.js
@@ -0,0 +1,35 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { closeDeleteMachineModal } from '../../actions/modals/topology'
+import { deleteMachine } from '../../actions/topology/machine'
+import ConfirmationModal from '../../components/modals/ConfirmationModal'
+
+const DeleteMachineModalComponent = ({ visible, callback }) => (
+ <ConfirmationModal
+ title="Delete this machine"
+ message="Are you sure you want to delete this machine?"
+ show={visible}
+ callback={callback}
+ />
+)
+
+const mapStateToProps = (state) => {
+ return {
+ visible: state.modals.deleteMachineModalVisible,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (isConfirmed) => {
+ if (isConfirmed) {
+ dispatch(deleteMachine())
+ }
+ dispatch(closeDeleteMachineModal())
+ },
+ }
+}
+
+const DeleteMachineModal = connect(mapStateToProps, mapDispatchToProps)(DeleteMachineModalComponent)
+
+export default DeleteMachineModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/DeleteProfileModal.js b/opendc-web/opendc-web-ui/src/containers/modals/DeleteProfileModal.js
new file mode 100644
index 00000000..e7c4014d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/DeleteProfileModal.js
@@ -0,0 +1,35 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { closeDeleteProfileModal } from '../../actions/modals/profile'
+import { deleteCurrentUser } from '../../actions/users'
+import ConfirmationModal from '../../components/modals/ConfirmationModal'
+
+const DeleteProfileModalComponent = ({ visible, callback }) => (
+ <ConfirmationModal
+ title="Delete my account"
+ message="Are you sure you want to delete your OpenDC account?"
+ show={visible}
+ callback={callback}
+ />
+)
+
+const mapStateToProps = (state) => {
+ return {
+ visible: state.modals.deleteProfileModalVisible,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (isConfirmed) => {
+ if (isConfirmed) {
+ dispatch(deleteCurrentUser())
+ }
+ dispatch(closeDeleteProfileModal())
+ },
+ }
+}
+
+const DeleteProfileModal = connect(mapStateToProps, mapDispatchToProps)(DeleteProfileModalComponent)
+
+export default DeleteProfileModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/DeleteRackModal.js b/opendc-web/opendc-web-ui/src/containers/modals/DeleteRackModal.js
new file mode 100644
index 00000000..0cb22a7e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/DeleteRackModal.js
@@ -0,0 +1,35 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { closeDeleteRackModal } from '../../actions/modals/topology'
+import { deleteRack } from '../../actions/topology/rack'
+import ConfirmationModal from '../../components/modals/ConfirmationModal'
+
+const DeleteRackModalComponent = ({ visible, callback }) => (
+ <ConfirmationModal
+ title="Delete this rack"
+ message="Are you sure you want to delete this rack?"
+ show={visible}
+ callback={callback}
+ />
+)
+
+const mapStateToProps = (state) => {
+ return {
+ visible: state.modals.deleteRackModalVisible,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (isConfirmed) => {
+ if (isConfirmed) {
+ dispatch(deleteRack())
+ }
+ dispatch(closeDeleteRackModal())
+ },
+ }
+}
+
+const DeleteRackModal = connect(mapStateToProps, mapDispatchToProps)(DeleteRackModalComponent)
+
+export default DeleteRackModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/DeleteRoomModal.js b/opendc-web/opendc-web-ui/src/containers/modals/DeleteRoomModal.js
new file mode 100644
index 00000000..1f6eef92
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/DeleteRoomModal.js
@@ -0,0 +1,35 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { closeDeleteRoomModal } from '../../actions/modals/topology'
+import { deleteRoom } from '../../actions/topology/room'
+import ConfirmationModal from '../../components/modals/ConfirmationModal'
+
+const DeleteRoomModalComponent = ({ visible, callback }) => (
+ <ConfirmationModal
+ title="Delete this room"
+ message="Are you sure you want to delete this room?"
+ show={visible}
+ callback={callback}
+ />
+)
+
+const mapStateToProps = (state) => {
+ return {
+ visible: state.modals.deleteRoomModalVisible,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (isConfirmed) => {
+ if (isConfirmed) {
+ dispatch(deleteRoom())
+ }
+ dispatch(closeDeleteRoomModal())
+ },
+ }
+}
+
+const DeleteRoomModal = connect(mapStateToProps, mapDispatchToProps)(DeleteRoomModalComponent)
+
+export default DeleteRoomModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/EditRackNameModal.js b/opendc-web/opendc-web-ui/src/containers/modals/EditRackNameModal.js
new file mode 100644
index 00000000..9128f449
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/EditRackNameModal.js
@@ -0,0 +1,40 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { closeEditRackNameModal } from '../../actions/modals/topology'
+import { editRackName } from '../../actions/topology/rack'
+import TextInputModal from '../../components/modals/TextInputModal'
+
+const EditRackNameModalComponent = ({ visible, previousName, callback }) => (
+ <TextInputModal
+ title="Edit rack name"
+ label="Rack name"
+ show={visible}
+ initialValue={previousName}
+ callback={callback}
+ />
+)
+
+const mapStateToProps = (state) => {
+ return {
+ visible: state.modals.editRackNameModalVisible,
+ previousName:
+ state.interactionLevel.mode === 'RACK'
+ ? state.objects.rack[state.objects.tile[state.interactionLevel.tileId].rackId].name
+ : '',
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (name) => {
+ if (name) {
+ dispatch(editRackName(name))
+ }
+ dispatch(closeEditRackNameModal())
+ },
+ }
+}
+
+const EditRackNameModal = connect(mapStateToProps, mapDispatchToProps)(EditRackNameModalComponent)
+
+export default EditRackNameModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/EditRoomNameModal.js b/opendc-web/opendc-web-ui/src/containers/modals/EditRoomNameModal.js
new file mode 100644
index 00000000..8032a5d1
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/EditRoomNameModal.js
@@ -0,0 +1,38 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { closeEditRoomNameModal } from '../../actions/modals/topology'
+import { editRoomName } from '../../actions/topology/room'
+import TextInputModal from '../../components/modals/TextInputModal'
+
+const EditRoomNameModalComponent = ({ visible, previousName, callback }) => (
+ <TextInputModal
+ title="Edit room name"
+ label="Room name"
+ show={visible}
+ initialValue={previousName}
+ callback={callback}
+ />
+)
+
+const mapStateToProps = (state) => {
+ return {
+ visible: state.modals.editRoomNameModalVisible,
+ previousName:
+ state.interactionLevel.mode === 'ROOM' ? state.objects.room[state.interactionLevel.roomId].name : '',
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (name) => {
+ if (name) {
+ dispatch(editRoomName(name))
+ }
+ dispatch(closeEditRoomNameModal())
+ },
+ }
+}
+
+const EditRoomNameModal = connect(mapStateToProps, mapDispatchToProps)(EditRoomNameModalComponent)
+
+export default EditRoomNameModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/NewPortfolioModal.js b/opendc-web/opendc-web-ui/src/containers/modals/NewPortfolioModal.js
new file mode 100644
index 00000000..6cf12d8e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/NewPortfolioModal.js
@@ -0,0 +1,30 @@
+import { connect } from 'react-redux'
+import NewPortfolioModalComponent from '../../components/modals/custom-components/NewPortfolioModalComponent'
+import { addPortfolio } from '../../actions/portfolios'
+import { closeNewPortfolioModal } from '../../actions/modals/portfolios'
+
+const mapStateToProps = (state) => {
+ return {
+ show: state.modals.newPortfolioModalVisible,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (name, targets) => {
+ if (name) {
+ dispatch(
+ addPortfolio({
+ name,
+ targets,
+ })
+ )
+ }
+ dispatch(closeNewPortfolioModal())
+ },
+ }
+}
+
+const NewPortfolioModal = connect(mapStateToProps, mapDispatchToProps)(NewPortfolioModalComponent)
+
+export default NewPortfolioModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/NewProjectModal.js b/opendc-web/opendc-web-ui/src/containers/modals/NewProjectModal.js
new file mode 100644
index 00000000..d306dc45
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/NewProjectModal.js
@@ -0,0 +1,30 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { closeNewProjectModal } from '../../actions/modals/projects'
+import { addProject } from '../../actions/projects'
+import TextInputModal from '../../components/modals/TextInputModal'
+
+const NewProjectModalComponent = ({ visible, callback }) => (
+ <TextInputModal title="New Project" label="Project title" show={visible} callback={callback} />
+)
+
+const mapStateToProps = (state) => {
+ return {
+ visible: state.modals.newProjectModalVisible,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (text) => {
+ if (text) {
+ dispatch(addProject(text))
+ }
+ dispatch(closeNewProjectModal())
+ },
+ }
+}
+
+const NewProjectModal = connect(mapStateToProps, mapDispatchToProps)(NewProjectModalComponent)
+
+export default NewProjectModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/NewScenarioModal.js b/opendc-web/opendc-web-ui/src/containers/modals/NewScenarioModal.js
new file mode 100644
index 00000000..7d774fa4
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/NewScenarioModal.js
@@ -0,0 +1,50 @@
+import { connect } from 'react-redux'
+import NewScenarioModalComponent from '../../components/modals/custom-components/NewScenarioModalComponent'
+import { addScenario } from '../../actions/scenarios'
+import { closeNewScenarioModal } from '../../actions/modals/scenarios'
+
+const mapStateToProps = (state) => {
+ let topologies =
+ state.currentProjectId !== '-1'
+ ? state.objects.project[state.currentProjectId].topologyIds.map((t) => state.objects.topology[t])
+ : []
+ if (topologies.filter((t) => !t).length > 0) {
+ topologies = []
+ }
+
+ return {
+ show: state.modals.newScenarioModalVisible,
+ currentPortfolioId: state.currentPortfolioId,
+ currentPortfolioScenarioIds:
+ state.currentPortfolioId !== '-1' && state.objects.portfolio[state.currentPortfolioId]
+ ? state.objects.portfolio[state.currentPortfolioId].scenarioIds
+ : [],
+ traces: Object.values(state.objects.trace),
+ topologies,
+ schedulers: Object.values(state.objects.scheduler),
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ callback: (name, portfolioId, trace, topology, operational) => {
+ if (name) {
+ dispatch(
+ addScenario({
+ portfolioId,
+ name,
+ trace,
+ topology,
+ operational,
+ })
+ )
+ }
+
+ dispatch(closeNewScenarioModal())
+ },
+ }
+}
+
+const NewScenarioModal = connect(mapStateToProps, mapDispatchToProps)(NewScenarioModalComponent)
+
+export default NewScenarioModal
diff --git a/opendc-web/opendc-web-ui/src/containers/modals/NewTopologyModal.js b/opendc-web/opendc-web-ui/src/containers/modals/NewTopologyModal.js
new file mode 100644
index 00000000..0acf6cf2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/modals/NewTopologyModal.js
@@ -0,0 +1,42 @@
+import { connect } from 'react-redux'
+import NewTopologyModalComponent from '../../components/modals/custom-components/NewTopologyModalComponent'
+import { closeNewTopologyModal } from '../../actions/modals/topology'
+import { addTopology } from '../../actions/topologies'
+
+const mapStateToProps = (state) => {
+ let topologies = state.objects.project[state.currentProjectId]
+ ? state.objects.project[state.currentProjectId].topologyIds.map((t) => state.objects.topology[t])
+ : []
+ if (topologies.filter((t) => !t).length > 0) {
+ topologies = []
+ }
+
+ return {
+ show: state.modals.changeTopologyModalVisible,
+ topologies,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onCreateTopology: (name) => {
+ if (name) {
+ dispatch(addTopology(name, undefined))
+ }
+ dispatch(closeNewTopologyModal())
+ },
+ onDuplicateTopology: (name, id) => {
+ if (name) {
+ dispatch(addTopology(name, id))
+ }
+ dispatch(closeNewTopologyModal())
+ },
+ onCancel: () => {
+ dispatch(closeNewTopologyModal())
+ },
+ }
+}
+
+const NewTopologyModal = connect(mapStateToProps, mapDispatchToProps)(NewTopologyModalComponent)
+
+export default NewTopologyModal
diff --git a/opendc-web/opendc-web-ui/src/containers/navigation/AppNavbarContainer.js b/opendc-web/opendc-web-ui/src/containers/navigation/AppNavbarContainer.js
new file mode 100644
index 00000000..845d54e1
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/navigation/AppNavbarContainer.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import AppNavbarComponent from '../../components/navigation/AppNavbarComponent'
+
+const mapStateToProps = (state) => {
+ return {
+ project: state.currentProjectId !== '-1' ? state.objects.project[state.currentProjectId] : undefined,
+ }
+}
+
+const AppNavbarContainer = connect(mapStateToProps)(AppNavbarComponent)
+
+export default AppNavbarContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/projects/FilterLink.js b/opendc-web/opendc-web-ui/src/containers/projects/FilterLink.js
new file mode 100644
index 00000000..dfd6affe
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/projects/FilterLink.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux'
+import { setAuthVisibilityFilter } from '../../actions/projects'
+import FilterButton from '../../components/projects/FilterButton'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ active: state.projectList.authVisibilityFilter === ownProps.filter,
+ }
+}
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onClick: () => dispatch(setAuthVisibilityFilter(ownProps.filter)),
+ }
+}
+
+const FilterLink = connect(mapStateToProps, mapDispatchToProps)(FilterButton)
+
+export default FilterLink
diff --git a/opendc-web/opendc-web-ui/src/containers/projects/NewProjectButtonContainer.js b/opendc-web/opendc-web-ui/src/containers/projects/NewProjectButtonContainer.js
new file mode 100644
index 00000000..ffd4a4a3
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/projects/NewProjectButtonContainer.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux'
+import { openNewProjectModal } from '../../actions/modals/projects'
+import NewProjectButtonComponent from '../../components/projects/NewProjectButtonComponent'
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onClick: () => dispatch(openNewProjectModal()),
+ }
+}
+
+const NewProjectButtonContainer = connect(undefined, mapDispatchToProps)(NewProjectButtonComponent)
+
+export default NewProjectButtonContainer
diff --git a/opendc-web/opendc-web-ui/src/containers/projects/ProjectActions.js b/opendc-web/opendc-web-ui/src/containers/projects/ProjectActions.js
new file mode 100644
index 00000000..8bcbb7fd
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/projects/ProjectActions.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux'
+import { deleteProject } from '../../actions/projects'
+import ProjectActionButtons from '../../components/projects/ProjectActionButtons'
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ projectId: ownProps.projectId,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onViewUsers: (id) => {}, // TODO implement user viewing
+ onDelete: (id) => dispatch(deleteProject(id)),
+ }
+}
+
+const ProjectActions = connect(mapStateToProps, mapDispatchToProps)(ProjectActionButtons)
+
+export default ProjectActions
diff --git a/opendc-web/opendc-web-ui/src/containers/projects/VisibleProjectAuthList.js b/opendc-web/opendc-web-ui/src/containers/projects/VisibleProjectAuthList.js
new file mode 100644
index 00000000..f0010540
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/containers/projects/VisibleProjectAuthList.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux'
+import ProjectList from '../../components/projects/ProjectAuthList'
+
+const getVisibleProjectAuths = (projectAuths, filter) => {
+ switch (filter) {
+ case 'SHOW_ALL':
+ return projectAuths
+ case 'SHOW_OWN':
+ return projectAuths.filter((projectAuth) => projectAuth.authorizationLevel === 'OWN')
+ case 'SHOW_SHARED':
+ return projectAuths.filter((projectAuth) => projectAuth.authorizationLevel !== 'OWN')
+ default:
+ return projectAuths
+ }
+}
+
+const mapStateToProps = (state) => {
+ const denormalizedAuthorizations = state.projectList.authorizationsOfCurrentUser.map((authorizationIds) => {
+ const authorization = state.objects.authorization[authorizationIds]
+ authorization.user = state.objects.user[authorization.userId]
+ authorization.project = state.objects.project[authorization.projectId]
+ return authorization
+ })
+
+ return {
+ authorizations: getVisibleProjectAuths(denormalizedAuthorizations, state.projectList.authVisibilityFilter),
+ }
+}
+
+const VisibleProjectAuthList = connect(mapStateToProps)(ProjectList)
+
+export default VisibleProjectAuthList
diff --git a/opendc-web/opendc-web-ui/src/index.js b/opendc-web/opendc-web-ui/src/index.js
new file mode 100644
index 00000000..3517147e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/index.js
@@ -0,0 +1,30 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import * as Sentry from '@sentry/react'
+import { Integrations } from '@sentry/tracing'
+import { Provider } from 'react-redux'
+import { setupSocketConnection } from './api/socket'
+import './index.sass'
+import Routes from './routes'
+import configureStore from './store/configure-store'
+
+setupSocketConnection(() => {
+ const store = configureStore()
+
+ // Initialize Sentry if the user has configured a DSN
+ if (process.env.REACT_APP_SENTRY_DSN) {
+ Sentry.init({
+ environment: process.env.NODE_ENV,
+ dsn: process.env.REACT_APP_SENTRY_DSN,
+ integrations: [new Integrations.BrowserTracing()],
+ tracesSampleRate: 0.1,
+ })
+ }
+
+ ReactDOM.render(
+ <Provider store={store}>
+ <Routes />
+ </Provider>,
+ document.getElementById('root')
+ )
+})
diff --git a/opendc-web/opendc-web-ui/src/index.sass b/opendc-web/opendc-web-ui/src/index.sass
new file mode 100644
index 00000000..a78f7a19
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/index.sass
@@ -0,0 +1,52 @@
+@import "~bootstrap/scss/bootstrap"
+
+@import ./style-globals/_mixins.sass
+@import ./style-globals/_variables.sass
+
+html, body, #root
+ margin: 0
+ padding: 0
+ width: 100%
+ height: 100%
+
+ font-family: Roboto, Helvetica, Verdana, sans-serif
+ background: #eee
+
+ // Scroll padding for top navbar
+ scroll-padding-top: 60px
+
+.full-height
+ position: relative
+ height: 100% !important
+
+.page-container
+ padding-top: 60px
+
+.text-page-container
+ padding-top: 80px
+ display: flex
+ flex-flow: column
+
+.vertically-expanding-container
+ flex: 1 1 auto
+ overflow-y: auto
+
+.bottom-btn-container
+ flex: 0 1 auto
+ padding: 20px 0
+
+.btn, .list-group-item-action, .clickable
+ +clickable
+
+.btn-circle
+ +border-radius(50%)
+
+a, a:hover
+ text-decoration: none
+
+.app-page-container
+ padding-left: $side-bar-width
+ padding-top: 15px
+
+.w-70
+ width: 70% !important
diff --git a/opendc-web/opendc-web-ui/src/pages/App.js b/opendc-web/opendc-web-ui/src/pages/App.js
new file mode 100644
index 00000000..cbc805b8
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/pages/App.js
@@ -0,0 +1,137 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import DocumentTitle from 'react-document-title'
+import { connect } from 'react-redux'
+import { ShortcutManager } from 'react-shortcuts'
+import { openPortfolioSucceeded } from '../actions/portfolios'
+import { openProjectSucceeded } from '../actions/projects'
+import ToolPanelComponent from '../components/app/map/controls/ToolPanelComponent'
+import LoadingScreen from '../components/app/map/LoadingScreen'
+import ScaleIndicatorContainer from '../containers/app/map/controls/ScaleIndicatorContainer'
+import MapStage from '../containers/app/map/MapStage'
+import TopologySidebarContainer from '../containers/app/sidebars/topology/TopologySidebarContainer'
+import DeleteMachineModal from '../containers/modals/DeleteMachineModal'
+import DeleteRackModal from '../containers/modals/DeleteRackModal'
+import DeleteRoomModal from '../containers/modals/DeleteRoomModal'
+import EditRackNameModal from '../containers/modals/EditRackNameModal'
+import EditRoomNameModal from '../containers/modals/EditRoomNameModal'
+import KeymapConfiguration from '../shortcuts/keymap'
+import NewTopologyModal from '../containers/modals/NewTopologyModal'
+import AppNavbarContainer from '../containers/navigation/AppNavbarContainer'
+import ProjectSidebarContainer from '../containers/app/sidebars/project/ProjectSidebarContainer'
+import { openScenarioSucceeded } from '../actions/scenarios'
+import NewPortfolioModal from '../containers/modals/NewPortfolioModal'
+import NewScenarioModal from '../containers/modals/NewScenarioModal'
+import PortfolioResultsContainer from '../containers/app/results/PortfolioResultsContainer'
+
+const shortcutManager = new ShortcutManager(KeymapConfiguration)
+
+class AppComponent extends React.Component {
+ static propTypes = {
+ projectId: PropTypes.string.isRequired,
+ portfolioId: PropTypes.string,
+ scenarioId: PropTypes.string,
+ projectName: PropTypes.string,
+ }
+ static childContextTypes = {
+ shortcuts: PropTypes.object.isRequired,
+ }
+
+ componentDidMount() {
+ if (this.props.scenarioId) {
+ this.props.openScenarioSucceeded(this.props.projectId, this.props.portfolioId, this.props.scenarioId)
+ } else if (this.props.portfolioId) {
+ this.props.openPortfolioSucceeded(this.props.projectId, this.props.portfolioId)
+ } else {
+ this.props.openProjectSucceeded(this.props.projectId)
+ }
+ }
+
+ getChildContext() {
+ return {
+ shortcuts: shortcutManager,
+ }
+ }
+
+ render() {
+ const constructionElements = this.props.topologyIsLoading ? (
+ <div className="full-height d-flex align-items-center justify-content-center">
+ <LoadingScreen />
+ </div>
+ ) : (
+ <div className="full-height">
+ <MapStage />
+ <ScaleIndicatorContainer />
+ <ToolPanelComponent />
+ <ProjectSidebarContainer />
+ <TopologySidebarContainer />
+ </div>
+ )
+
+ const portfolioElements = (
+ <div className="full-height app-page-container">
+ <ProjectSidebarContainer />
+ <div className="container-fluid full-height">
+ <PortfolioResultsContainer />
+ </div>
+ </div>
+ )
+
+ const scenarioElements = (
+ <div className="full-height app-page-container">
+ <ProjectSidebarContainer />
+ <div className="container-fluid full-height">
+ <h2>Scenario loading</h2>
+ </div>
+ </div>
+ )
+
+ return (
+ <DocumentTitle
+ title={this.props.projectName ? this.props.projectName + ' - OpenDC' : 'Simulation - OpenDC'}
+ >
+ <div className="page-container full-height">
+ <AppNavbarContainer fullWidth={true} />
+ {this.props.scenarioId
+ ? scenarioElements
+ : this.props.portfolioId
+ ? portfolioElements
+ : constructionElements}
+ <NewTopologyModal />
+ <NewPortfolioModal />
+ <NewScenarioModal />
+ <EditRoomNameModal />
+ <DeleteRoomModal />
+ <EditRackNameModal />
+ <DeleteRackModal />
+ <DeleteMachineModal />
+ </div>
+ </DocumentTitle>
+ )
+ }
+}
+
+const mapStateToProps = (state) => {
+ let projectName = undefined
+ if (state.currentProjectId !== '-1' && state.objects.project[state.currentProjectId]) {
+ projectName = state.objects.project[state.currentProjectId].name
+ }
+
+ return {
+ topologyIsLoading: state.currentTopologyId === '-1',
+ projectName,
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ openProjectSucceeded: (projectId) => dispatch(openProjectSucceeded(projectId)),
+ openPortfolioSucceeded: (projectId, portfolioId) => dispatch(openPortfolioSucceeded(projectId, portfolioId)),
+ openScenarioSucceeded: (projectId, portfolioId, scenarioId) =>
+ dispatch(openScenarioSucceeded(projectId, portfolioId, scenarioId)),
+ }
+}
+
+const App = connect(mapStateToProps, mapDispatchToProps)(AppComponent)
+
+export default App
diff --git a/opendc-web/opendc-web-ui/src/pages/Home.js b/opendc-web/opendc-web-ui/src/pages/Home.js
new file mode 100644
index 00000000..6fc940c0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/pages/Home.js
@@ -0,0 +1,33 @@
+import React from 'react'
+import DocumentTitle from 'react-document-title'
+import ContactSection from '../components/home/ContactSection'
+import IntroSection from '../components/home/IntroSection'
+import JumbotronHeader from '../components/home/JumbotronHeader'
+import ModelingSection from '../components/home/ModelingSection'
+import SimulationSection from '../components/home/SimulationSection'
+import StakeholderSection from '../components/home/StakeholderSection'
+import TeamSection from '../components/home/TeamSection'
+import TechnologiesSection from '../components/home/TechnologiesSection'
+import HomeNavbar from '../components/navigation/HomeNavbar'
+import './Home.sass'
+
+function Home() {
+ return (
+ <div>
+ <HomeNavbar />
+ <div className="body-wrapper page-container">
+ <JumbotronHeader />
+ <IntroSection />
+ <StakeholderSection />
+ <ModelingSection />
+ <SimulationSection />
+ <TechnologiesSection />
+ <TeamSection />
+ <ContactSection />
+ <DocumentTitle title="OpenDC" />
+ </div>
+ </div>
+ )
+}
+
+export default Home
diff --git a/opendc-web/opendc-web-ui/src/pages/Home.sass b/opendc-web/opendc-web-ui/src/pages/Home.sass
new file mode 100644
index 00000000..79cb9698
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/pages/Home.sass
@@ -0,0 +1,9 @@
+.body-wrapper
+ position: relative
+ overflow-y: hidden
+
+.intro-section, .modeling-section, .technologies-section
+ background-color: #fff
+
+.stakeholder-section, .simulation-section, .team-section
+ background-color: #f2f2f2
diff --git a/opendc-web/opendc-web-ui/src/pages/NotFound.js b/opendc-web/opendc-web-ui/src/pages/NotFound.js
new file mode 100644
index 00000000..72be7342
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/pages/NotFound.js
@@ -0,0 +1,14 @@
+import React from 'react'
+import DocumentTitle from 'react-document-title'
+import TerminalWindow from '../components/not-found/TerminalWindow'
+import './NotFound.sass'
+
+const NotFound = () => (
+ <DocumentTitle title="Page Not Found - OpenDC">
+ <div className="not-found-backdrop">
+ <TerminalWindow />
+ </div>
+ </DocumentTitle>
+)
+
+export default NotFound
diff --git a/opendc-web/opendc-web-ui/src/pages/NotFound.sass b/opendc-web/opendc-web-ui/src/pages/NotFound.sass
new file mode 100644
index 00000000..59231f7a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/pages/NotFound.sass
@@ -0,0 +1,11 @@
+.not-found-backdrop
+ position: absolute
+ left: 0
+ top: 0
+
+ margin: 0
+ padding: 0
+ width: 100%
+ height: 100%
+
+ background-image: linear-gradient(135deg, #00678a, #008fbf, #00A6D6)
diff --git a/opendc-web/opendc-web-ui/src/pages/Profile.js b/opendc-web/opendc-web-ui/src/pages/Profile.js
new file mode 100644
index 00000000..0d94b519
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/pages/Profile.js
@@ -0,0 +1,35 @@
+import React from 'react'
+import DocumentTitle from 'react-document-title'
+import { connect } from 'react-redux'
+import { openDeleteProfileModal } from '../actions/modals/profile'
+import DeleteProfileModal from '../containers/modals/DeleteProfileModal'
+import AppNavbarContainer from '../containers/navigation/AppNavbarContainer'
+
+const ProfileContainer = ({ onDelete }) => (
+ <DocumentTitle title="My Profile - OpenDC">
+ <div className="full-height">
+ <AppNavbarContainer fullWidth={false} />
+ <div className="container text-page-container full-height">
+ <button className="btn btn-danger mb-2 ml-auto mr-auto" style={{ maxWidth: 300 }} onClick={onDelete}>
+ Delete my account on OpenDC
+ </button>
+ <p className="text-muted text-center">
+ This does not delete your Google account, but simply disconnects it from the OpenDC platform and
+ deletes any project info that is associated with you (projects you own and any authorizations you
+ may have on other projects).
+ </p>
+ </div>
+ <DeleteProfileModal />
+ </div>
+ </DocumentTitle>
+)
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ onDelete: () => dispatch(openDeleteProfileModal()),
+ }
+}
+
+const Profile = connect(undefined, mapDispatchToProps)(ProfileContainer)
+
+export default Profile
diff --git a/opendc-web/opendc-web-ui/src/pages/Projects.js b/opendc-web/opendc-web-ui/src/pages/Projects.js
new file mode 100644
index 00000000..bb54aaa5
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/pages/Projects.js
@@ -0,0 +1,43 @@
+import React from 'react'
+import DocumentTitle from 'react-document-title'
+import { connect } from 'react-redux'
+import { openNewProjectModal } from '../actions/modals/projects'
+import { fetchAuthorizationsOfCurrentUser } from '../actions/users'
+import ProjectFilterPanel from '../components/projects/FilterPanel'
+import NewProjectModal from '../containers/modals/NewProjectModal'
+import NewProjectButtonContainer from '../containers/projects/NewProjectButtonContainer'
+import VisibleProjectList from '../containers/projects/VisibleProjectAuthList'
+import AppNavbarContainer from '../containers/navigation/AppNavbarContainer'
+
+class ProjectsContainer extends React.Component {
+ componentDidMount() {
+ this.props.fetchAuthorizationsOfCurrentUser()
+ }
+
+ render() {
+ return (
+ <DocumentTitle title="My Projects - OpenDC">
+ <div className="full-height">
+ <AppNavbarContainer fullWidth={false} />
+ <div className="container text-page-container full-height">
+ <ProjectFilterPanel />
+ <VisibleProjectList />
+ <NewProjectButtonContainer />
+ </div>
+ <NewProjectModal />
+ </div>
+ </DocumentTitle>
+ )
+ }
+}
+
+const mapDispatchToProps = (dispatch) => {
+ return {
+ fetchAuthorizationsOfCurrentUser: () => dispatch(fetchAuthorizationsOfCurrentUser()),
+ openNewProjectModal: () => dispatch(openNewProjectModal()),
+ }
+}
+
+const Projects = connect(undefined, mapDispatchToProps)(ProjectsContainer)
+
+export default Projects
diff --git a/opendc-web/opendc-web-ui/src/reducers/auth.js b/opendc-web/opendc-web-ui/src/reducers/auth.js
new file mode 100644
index 00000000..399a4b10
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/auth.js
@@ -0,0 +1,12 @@
+import { LOG_IN_SUCCEEDED, LOG_OUT } from '../actions/auth'
+
+export function auth(state = {}, action) {
+ switch (action.type) {
+ case LOG_IN_SUCCEEDED:
+ return action.payload
+ case LOG_OUT:
+ return {}
+ default:
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/reducers/construction-mode.js b/opendc-web/opendc-web-ui/src/reducers/construction-mode.js
new file mode 100644
index 00000000..257dddd2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/construction-mode.js
@@ -0,0 +1,52 @@
+import { combineReducers } from 'redux'
+import { GO_DOWN_ONE_INTERACTION_LEVEL } from '../actions/interaction-level'
+import {
+ CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ FINISH_NEW_ROOM_CONSTRUCTION,
+ FINISH_ROOM_EDIT,
+ SET_CURRENT_TOPOLOGY,
+ START_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ START_ROOM_EDIT,
+} from '../actions/topology/building'
+import { DELETE_ROOM, START_RACK_CONSTRUCTION, STOP_RACK_CONSTRUCTION } from '../actions/topology/room'
+import { OPEN_PORTFOLIO_SUCCEEDED } from '../actions/portfolios'
+import { OPEN_SCENARIO_SUCCEEDED } from '../actions/scenarios'
+
+export function currentRoomInConstruction(state = '-1', action) {
+ switch (action.type) {
+ case START_NEW_ROOM_CONSTRUCTION_SUCCEEDED:
+ return action.roomId
+ case START_ROOM_EDIT:
+ return action.roomId
+ case CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED:
+ case FINISH_NEW_ROOM_CONSTRUCTION:
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ case FINISH_ROOM_EDIT:
+ case SET_CURRENT_TOPOLOGY:
+ case DELETE_ROOM:
+ return '-1'
+ default:
+ return state
+ }
+}
+
+export function inRackConstructionMode(state = false, action) {
+ switch (action.type) {
+ case START_RACK_CONSTRUCTION:
+ return true
+ case STOP_RACK_CONSTRUCTION:
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ case GO_DOWN_ONE_INTERACTION_LEVEL:
+ return false
+ default:
+ return state
+ }
+}
+
+export const construction = combineReducers({
+ currentRoomInConstruction,
+ inRackConstructionMode,
+})
diff --git a/opendc-web/opendc-web-ui/src/reducers/current-ids.js b/opendc-web/opendc-web-ui/src/reducers/current-ids.js
new file mode 100644
index 00000000..9b46aa60
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/current-ids.js
@@ -0,0 +1,54 @@
+import { OPEN_PORTFOLIO_SUCCEEDED, SET_CURRENT_PORTFOLIO } from '../actions/portfolios'
+import { OPEN_PROJECT_SUCCEEDED } from '../actions/projects'
+import { SET_CURRENT_TOPOLOGY } from '../actions/topology/building'
+import { OPEN_SCENARIO_SUCCEEDED, SET_CURRENT_SCENARIO } from '../actions/scenarios'
+
+export function currentTopologyId(state = '-1', action) {
+ switch (action.type) {
+ case SET_CURRENT_TOPOLOGY:
+ return action.topologyId
+ default:
+ return state
+ }
+}
+
+export function currentProjectId(state = '-1', action) {
+ switch (action.type) {
+ case OPEN_PROJECT_SUCCEEDED:
+ return action.id
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ return action.projectId
+ default:
+ return state
+ }
+}
+
+export function currentPortfolioId(state = '-1', action) {
+ switch (action.type) {
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case SET_CURRENT_PORTFOLIO:
+ case SET_CURRENT_SCENARIO:
+ return action.portfolioId
+ case OPEN_SCENARIO_SUCCEEDED:
+ return action.portfolioId
+ case OPEN_PROJECT_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ return '-1'
+ default:
+ return state
+ }
+}
+export function currentScenarioId(state = '-1', action) {
+ switch (action.type) {
+ case OPEN_SCENARIO_SUCCEEDED:
+ case SET_CURRENT_SCENARIO:
+ return action.scenarioId
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ case OPEN_PROJECT_SUCCEEDED:
+ return '-1'
+ default:
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/reducers/index.js b/opendc-web/opendc-web-ui/src/reducers/index.js
new file mode 100644
index 00000000..787d5a74
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/index.js
@@ -0,0 +1,25 @@
+import { combineReducers } from 'redux'
+import { auth } from './auth'
+import { construction } from './construction-mode'
+import { currentPortfolioId, currentProjectId, currentScenarioId, currentTopologyId } from './current-ids'
+import { interactionLevel } from './interaction-level'
+import { map } from './map'
+import { modals } from './modals'
+import { objects } from './objects'
+import { projectList } from './project-list'
+
+const rootReducer = combineReducers({
+ objects,
+ modals,
+ projectList,
+ construction,
+ map,
+ currentProjectId,
+ currentTopologyId,
+ currentPortfolioId,
+ currentScenarioId,
+ interactionLevel,
+ auth,
+})
+
+export default rootReducer
diff --git a/opendc-web/opendc-web-ui/src/reducers/interaction-level.js b/opendc-web/opendc-web-ui/src/reducers/interaction-level.js
new file mode 100644
index 00000000..eafcb269
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/interaction-level.js
@@ -0,0 +1,61 @@
+import { OPEN_PORTFOLIO_SUCCEEDED } from '../actions/portfolios'
+import {
+ GO_DOWN_ONE_INTERACTION_LEVEL,
+ GO_FROM_BUILDING_TO_ROOM,
+ GO_FROM_RACK_TO_MACHINE,
+ GO_FROM_ROOM_TO_RACK,
+} from '../actions/interaction-level'
+import { OPEN_PROJECT_SUCCEEDED } from '../actions/projects'
+import { SET_CURRENT_TOPOLOGY } from '../actions/topology/building'
+import { OPEN_SCENARIO_SUCCEEDED } from '../actions/scenarios'
+
+export function interactionLevel(state = { mode: 'BUILDING' }, action) {
+ switch (action.type) {
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ case OPEN_PROJECT_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ return {
+ mode: 'BUILDING',
+ }
+ case GO_FROM_BUILDING_TO_ROOM:
+ return {
+ mode: 'ROOM',
+ roomId: action.roomId,
+ }
+ case GO_FROM_ROOM_TO_RACK:
+ return {
+ mode: 'RACK',
+ roomId: state.roomId,
+ tileId: action.tileId,
+ }
+ case GO_FROM_RACK_TO_MACHINE:
+ return {
+ mode: 'MACHINE',
+ roomId: state.roomId,
+ tileId: state.tileId,
+ position: action.position,
+ }
+ case GO_DOWN_ONE_INTERACTION_LEVEL:
+ if (state.mode === 'ROOM') {
+ return {
+ mode: 'BUILDING',
+ }
+ } else if (state.mode === 'RACK') {
+ return {
+ mode: 'ROOM',
+ roomId: state.roomId,
+ }
+ } else if (state.mode === 'MACHINE') {
+ return {
+ mode: 'RACK',
+ roomId: state.roomId,
+ tileId: state.tileId,
+ }
+ } else {
+ return state
+ }
+ default:
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/reducers/map.js b/opendc-web/opendc-web-ui/src/reducers/map.js
new file mode 100644
index 00000000..de712c15
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/map.js
@@ -0,0 +1,35 @@
+import { combineReducers } from 'redux'
+import { SET_MAP_DIMENSIONS, SET_MAP_POSITION, SET_MAP_SCALE } from '../actions/map'
+
+export function position(state = { x: 0, y: 0 }, action) {
+ switch (action.type) {
+ case SET_MAP_POSITION:
+ return { x: action.x, y: action.y }
+ default:
+ return state
+ }
+}
+
+export function dimensions(state = { width: 600, height: 400 }, action) {
+ switch (action.type) {
+ case SET_MAP_DIMENSIONS:
+ return { width: action.width, height: action.height }
+ default:
+ return state
+ }
+}
+
+export function scale(state = 1, action) {
+ switch (action.type) {
+ case SET_MAP_SCALE:
+ return action.scale
+ default:
+ return state
+ }
+}
+
+export const map = combineReducers({
+ position,
+ dimensions,
+ scale,
+})
diff --git a/opendc-web/opendc-web-ui/src/reducers/modals.js b/opendc-web/opendc-web-ui/src/reducers/modals.js
new file mode 100644
index 00000000..a7656373
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/modals.js
@@ -0,0 +1,45 @@
+import { combineReducers } from 'redux'
+import { CLOSE_DELETE_PROFILE_MODAL, OPEN_DELETE_PROFILE_MODAL } from '../actions/modals/profile'
+import { CLOSE_NEW_PROJECT_MODAL, OPEN_NEW_PROJECT_MODAL } from '../actions/modals/projects'
+import {
+ CLOSE_NEW_TOPOLOGY_MODAL,
+ CLOSE_DELETE_MACHINE_MODAL,
+ CLOSE_DELETE_RACK_MODAL,
+ CLOSE_DELETE_ROOM_MODAL,
+ CLOSE_EDIT_RACK_NAME_MODAL,
+ CLOSE_EDIT_ROOM_NAME_MODAL,
+ OPEN_NEW_TOPOLOGY_MODAL,
+ OPEN_DELETE_MACHINE_MODAL,
+ OPEN_DELETE_RACK_MODAL,
+ OPEN_DELETE_ROOM_MODAL,
+ OPEN_EDIT_RACK_NAME_MODAL,
+ OPEN_EDIT_ROOM_NAME_MODAL,
+} from '../actions/modals/topology'
+import { CLOSE_NEW_PORTFOLIO_MODAL, OPEN_NEW_PORTFOLIO_MODAL } from '../actions/modals/portfolios'
+import { CLOSE_NEW_SCENARIO_MODAL, OPEN_NEW_SCENARIO_MODAL } from '../actions/modals/scenarios'
+
+function modal(openAction, closeAction) {
+ return function (state = false, action) {
+ switch (action.type) {
+ case openAction:
+ return true
+ case closeAction:
+ return false
+ default:
+ return state
+ }
+ }
+}
+
+export const modals = combineReducers({
+ newProjectModalVisible: modal(OPEN_NEW_PROJECT_MODAL, CLOSE_NEW_PROJECT_MODAL),
+ deleteProfileModalVisible: modal(OPEN_DELETE_PROFILE_MODAL, CLOSE_DELETE_PROFILE_MODAL),
+ changeTopologyModalVisible: modal(OPEN_NEW_TOPOLOGY_MODAL, CLOSE_NEW_TOPOLOGY_MODAL),
+ editRoomNameModalVisible: modal(OPEN_EDIT_ROOM_NAME_MODAL, CLOSE_EDIT_ROOM_NAME_MODAL),
+ deleteRoomModalVisible: modal(OPEN_DELETE_ROOM_MODAL, CLOSE_DELETE_ROOM_MODAL),
+ editRackNameModalVisible: modal(OPEN_EDIT_RACK_NAME_MODAL, CLOSE_EDIT_RACK_NAME_MODAL),
+ deleteRackModalVisible: modal(OPEN_DELETE_RACK_MODAL, CLOSE_DELETE_RACK_MODAL),
+ deleteMachineModalVisible: modal(OPEN_DELETE_MACHINE_MODAL, CLOSE_DELETE_MACHINE_MODAL),
+ newPortfolioModalVisible: modal(OPEN_NEW_PORTFOLIO_MODAL, CLOSE_NEW_PORTFOLIO_MODAL),
+ newScenarioModalVisible: modal(OPEN_NEW_SCENARIO_MODAL, CLOSE_NEW_SCENARIO_MODAL),
+})
diff --git a/opendc-web/opendc-web-ui/src/reducers/objects.js b/opendc-web/opendc-web-ui/src/reducers/objects.js
new file mode 100644
index 00000000..1f721b2e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/objects.js
@@ -0,0 +1,64 @@
+import { combineReducers } from 'redux'
+import {
+ ADD_ID_TO_STORE_OBJECT_LIST_PROP,
+ ADD_PROP_TO_STORE_OBJECT,
+ ADD_TO_STORE,
+ REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP,
+} from '../actions/objects'
+import { CPU_UNITS, GPU_UNITS, MEMORY_UNITS, STORAGE_UNITS } from '../util/unit-specifications'
+
+export const objects = combineReducers({
+ project: object('project'),
+ user: object('user'),
+ authorization: objectWithId('authorization', (object) => [object.userId, object.projectId]),
+ cpu: object('cpu', CPU_UNITS),
+ gpu: object('gpu', GPU_UNITS),
+ memory: object('memory', MEMORY_UNITS),
+ storage: object('storage', STORAGE_UNITS),
+ machine: object('machine'),
+ rack: object('rack'),
+ tile: object('tile'),
+ room: object('room'),
+ topology: object('topology'),
+ trace: object('trace'),
+ scheduler: object('scheduler'),
+ portfolio: object('portfolio'),
+ scenario: object('scenario'),
+ prefab: object('prefab'),
+})
+
+function object(type, defaultState = {}) {
+ return objectWithId(type, (object) => object._id, defaultState)
+}
+
+function objectWithId(type, getId, defaultState = {}) {
+ return (state = defaultState, action) => {
+ if (action.objectType !== type) {
+ return state
+ }
+
+ if (action.type === ADD_TO_STORE) {
+ return Object.assign({}, state, {
+ [getId(action.object)]: action.object,
+ })
+ } else if (action.type === ADD_PROP_TO_STORE_OBJECT) {
+ return Object.assign({}, state, {
+ [action.objectId]: Object.assign({}, state[action.objectId], action.propObject),
+ })
+ } else if (action.type === ADD_ID_TO_STORE_OBJECT_LIST_PROP) {
+ return Object.assign({}, state, {
+ [action.objectId]: Object.assign({}, state[action.objectId], {
+ [action.propName]: [...state[action.objectId][action.propName], action.id],
+ }),
+ })
+ } else if (action.type === REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP) {
+ return Object.assign({}, state, {
+ [action.objectId]: Object.assign({}, state[action.objectId], {
+ [action.propName]: state[action.objectId][action.propName].filter((id) => id !== action.id),
+ }),
+ })
+ }
+
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/reducers/project-list.js b/opendc-web/opendc-web-ui/src/reducers/project-list.js
new file mode 100644
index 00000000..1f1aa8d0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/reducers/project-list.js
@@ -0,0 +1,30 @@
+import { combineReducers } from 'redux'
+import { ADD_PROJECT_SUCCEEDED, DELETE_PROJECT_SUCCEEDED, SET_AUTH_VISIBILITY_FILTER } from '../actions/projects'
+import { FETCH_AUTHORIZATIONS_OF_CURRENT_USER_SUCCEEDED } from '../actions/users'
+
+export function authorizationsOfCurrentUser(state = [], action) {
+ switch (action.type) {
+ case FETCH_AUTHORIZATIONS_OF_CURRENT_USER_SUCCEEDED:
+ return action.authorizationsOfCurrentUser
+ case ADD_PROJECT_SUCCEEDED:
+ return [...state, action.authorization]
+ case DELETE_PROJECT_SUCCEEDED:
+ return state.filter((authorization) => authorization[1] !== action.id)
+ default:
+ return state
+ }
+}
+
+export function authVisibilityFilter(state = 'SHOW_ALL', action) {
+ switch (action.type) {
+ case SET_AUTH_VISIBILITY_FILTER:
+ return action.filter
+ default:
+ return state
+ }
+}
+
+export const projectList = combineReducers({
+ authorizationsOfCurrentUser,
+ authVisibilityFilter,
+})
diff --git a/opendc-web/opendc-web-ui/src/routes/index.js b/opendc-web/opendc-web-ui/src/routes/index.js
new file mode 100644
index 00000000..4291a046
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/routes/index.js
@@ -0,0 +1,40 @@
+import React from 'react'
+import { BrowserRouter, Redirect, Route, Switch } from 'react-router-dom'
+import { userIsLoggedIn } from '../auth/index'
+import App from '../pages/App'
+import Home from '../pages/Home'
+import NotFound from '../pages/NotFound'
+import Profile from '../pages/Profile'
+import Projects from '../pages/Projects'
+
+const ProtectedComponent = (component) => () => (userIsLoggedIn() ? component : <Redirect to="/" />)
+const AppComponent = ({ match }) =>
+ userIsLoggedIn() ? (
+ <App
+ projectId={match.params.projectId}
+ portfolioId={match.params.portfolioId}
+ scenarioId={match.params.scenarioId}
+ />
+ ) : (
+ <Redirect to="/" />
+ )
+
+const Routes = () => (
+ <BrowserRouter>
+ <Switch>
+ <Route exact path="/" component={Home} />
+ <Route exact path="/projects" render={ProtectedComponent(<Projects />)} />
+ <Route exact path="/projects/:projectId" component={AppComponent} />
+ <Route exact path="/projects/:projectId/portfolios/:portfolioId" component={AppComponent} />
+ <Route
+ exact
+ path="/projects/:projectId/portfolios/:portfolioId/scenarios/:scenarioId"
+ component={AppComponent}
+ />
+ <Route exact path="/profile" render={ProtectedComponent(<Profile />)} />
+ <Route path="/*" component={NotFound} />
+ </Switch>
+ </BrowserRouter>
+)
+
+export default Routes
diff --git a/opendc-web/opendc-web-ui/src/sagas/index.js b/opendc-web/opendc-web-ui/src/sagas/index.js
new file mode 100644
index 00000000..6332b2fb
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/index.js
@@ -0,0 +1,80 @@
+import { takeEvery } from 'redux-saga/effects'
+import { LOG_IN } from '../actions/auth'
+import { ADD_PORTFOLIO, DELETE_PORTFOLIO, OPEN_PORTFOLIO_SUCCEEDED, UPDATE_PORTFOLIO } from '../actions/portfolios'
+import { ADD_PROJECT, DELETE_PROJECT, OPEN_PROJECT_SUCCEEDED } from '../actions/projects'
+import {
+ ADD_TILE,
+ CANCEL_NEW_ROOM_CONSTRUCTION,
+ DELETE_TILE,
+ START_NEW_ROOM_CONSTRUCTION,
+} from '../actions/topology/building'
+import { ADD_UNIT, DELETE_MACHINE, DELETE_UNIT } from '../actions/topology/machine'
+import { ADD_MACHINE, DELETE_RACK, EDIT_RACK_NAME } from '../actions/topology/rack'
+import { ADD_RACK_TO_TILE, DELETE_ROOM, EDIT_ROOM_NAME } from '../actions/topology/room'
+import { DELETE_CURRENT_USER, FETCH_AUTHORIZATIONS_OF_CURRENT_USER } from '../actions/users'
+import { onAddPortfolio, onDeletePortfolio, onOpenPortfolioSucceeded, onUpdatePortfolio } from './portfolios'
+import { onDeleteCurrentUser } from './profile'
+import { onOpenProjectSucceeded, onProjectAdd, onProjectDelete } from './projects'
+import {
+ onAddMachine,
+ onAddRackToTile,
+ onAddTile,
+ onAddTopology,
+ onAddUnit,
+ onCancelNewRoomConstruction,
+ onDeleteMachine,
+ onDeleteRack,
+ onDeleteRoom,
+ onDeleteTile,
+ onDeleteTopology,
+ onDeleteUnit,
+ onEditRackName,
+ onEditRoomName,
+ onStartNewRoomConstruction,
+} from './topology'
+import { onFetchAuthorizationsOfCurrentUser, onFetchLoggedInUser } from './users'
+import { ADD_TOPOLOGY, DELETE_TOPOLOGY } from '../actions/topologies'
+import { ADD_SCENARIO, DELETE_SCENARIO, OPEN_SCENARIO_SUCCEEDED, UPDATE_SCENARIO } from '../actions/scenarios'
+import { onAddScenario, onDeleteScenario, onOpenScenarioSucceeded, onUpdateScenario } from './scenarios'
+import { onAddPrefab } from './prefabs'
+import { ADD_PREFAB } from '../actions/prefabs'
+
+export default function* rootSaga() {
+ yield takeEvery(LOG_IN, onFetchLoggedInUser)
+
+ yield takeEvery(FETCH_AUTHORIZATIONS_OF_CURRENT_USER, onFetchAuthorizationsOfCurrentUser)
+ yield takeEvery(ADD_PROJECT, onProjectAdd)
+ yield takeEvery(DELETE_PROJECT, onProjectDelete)
+
+ yield takeEvery(DELETE_CURRENT_USER, onDeleteCurrentUser)
+
+ yield takeEvery(OPEN_PROJECT_SUCCEEDED, onOpenProjectSucceeded)
+ yield takeEvery(OPEN_PORTFOLIO_SUCCEEDED, onOpenPortfolioSucceeded)
+ yield takeEvery(OPEN_SCENARIO_SUCCEEDED, onOpenScenarioSucceeded)
+
+ yield takeEvery(ADD_TOPOLOGY, onAddTopology)
+ yield takeEvery(DELETE_TOPOLOGY, onDeleteTopology)
+ yield takeEvery(START_NEW_ROOM_CONSTRUCTION, onStartNewRoomConstruction)
+ yield takeEvery(CANCEL_NEW_ROOM_CONSTRUCTION, onCancelNewRoomConstruction)
+ yield takeEvery(ADD_TILE, onAddTile)
+ yield takeEvery(DELETE_TILE, onDeleteTile)
+ yield takeEvery(EDIT_ROOM_NAME, onEditRoomName)
+ yield takeEvery(DELETE_ROOM, onDeleteRoom)
+ yield takeEvery(EDIT_RACK_NAME, onEditRackName)
+ yield takeEvery(DELETE_RACK, onDeleteRack)
+ yield takeEvery(ADD_RACK_TO_TILE, onAddRackToTile)
+ yield takeEvery(ADD_MACHINE, onAddMachine)
+ yield takeEvery(DELETE_MACHINE, onDeleteMachine)
+ yield takeEvery(ADD_UNIT, onAddUnit)
+ yield takeEvery(DELETE_UNIT, onDeleteUnit)
+
+ yield takeEvery(ADD_PORTFOLIO, onAddPortfolio)
+ yield takeEvery(UPDATE_PORTFOLIO, onUpdatePortfolio)
+ yield takeEvery(DELETE_PORTFOLIO, onDeletePortfolio)
+
+ yield takeEvery(ADD_SCENARIO, onAddScenario)
+ yield takeEvery(UPDATE_SCENARIO, onUpdateScenario)
+ yield takeEvery(DELETE_SCENARIO, onDeleteScenario)
+
+ yield takeEvery(ADD_PREFAB, onAddPrefab)
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/objects.js b/opendc-web/opendc-web-ui/src/sagas/objects.js
new file mode 100644
index 00000000..313d9976
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/objects.js
@@ -0,0 +1,229 @@
+import { call, put, select } from 'redux-saga/effects'
+import { addToStore } from '../actions/objects'
+import { getAllSchedulers } from '../api/routes/schedulers'
+import { getProject } from '../api/routes/projects'
+import { getAllTraces } from '../api/routes/traces'
+import { getUser } from '../api/routes/users'
+import { getTopology, updateTopology } from '../api/routes/topologies'
+import { uuid } from 'uuidv4'
+
+export const OBJECT_SELECTORS = {
+ project: (state) => state.objects.project,
+ user: (state) => state.objects.user,
+ authorization: (state) => state.objects.authorization,
+ portfolio: (state) => state.objects.portfolio,
+ scenario: (state) => state.objects.scenario,
+ cpu: (state) => state.objects.cpu,
+ gpu: (state) => state.objects.gpu,
+ memory: (state) => state.objects.memory,
+ storage: (state) => state.objects.storage,
+ machine: (state) => state.objects.machine,
+ rack: (state) => state.objects.rack,
+ tile: (state) => state.objects.tile,
+ room: (state) => state.objects.room,
+ topology: (state) => state.objects.topology,
+}
+
+function* fetchAndStoreObject(objectType, id, apiCall) {
+ const objectStore = yield select(OBJECT_SELECTORS[objectType])
+ let object = objectStore[id]
+ if (!object) {
+ object = yield apiCall
+ yield put(addToStore(objectType, object))
+ }
+ return object
+}
+
+function* fetchAndStoreObjects(objectType, apiCall) {
+ const objects = yield apiCall
+ for (let object of objects) {
+ yield put(addToStore(objectType, object))
+ }
+ return objects
+}
+
+export const fetchAndStoreProject = (id) => fetchAndStoreObject('project', id, call(getProject, id))
+
+export const fetchAndStoreUser = (id) => fetchAndStoreObject('user', id, call(getUser, id))
+
+export const fetchAndStoreTopology = function* (id) {
+ const topologyStore = yield select(OBJECT_SELECTORS['topology'])
+ const roomStore = yield select(OBJECT_SELECTORS['room'])
+ const tileStore = yield select(OBJECT_SELECTORS['tile'])
+ const rackStore = yield select(OBJECT_SELECTORS['rack'])
+ const machineStore = yield select(OBJECT_SELECTORS['machine'])
+
+ let topology = topologyStore[id]
+ if (!topology) {
+ const fullTopology = yield call(getTopology, id)
+
+ for (let roomIdx in fullTopology.rooms) {
+ const fullRoom = fullTopology.rooms[roomIdx]
+
+ generateIdIfNotPresent(fullRoom)
+
+ if (!roomStore[fullRoom._id]) {
+ for (let tileIdx in fullRoom.tiles) {
+ const fullTile = fullRoom.tiles[tileIdx]
+
+ generateIdIfNotPresent(fullTile)
+
+ if (!tileStore[fullTile._id]) {
+ if (fullTile.rack) {
+ const fullRack = fullTile.rack
+
+ generateIdIfNotPresent(fullRack)
+
+ if (!rackStore[fullRack._id]) {
+ for (let machineIdx in fullRack.machines) {
+ const fullMachine = fullRack.machines[machineIdx]
+
+ generateIdIfNotPresent(fullMachine)
+
+ if (!machineStore[fullMachine._id]) {
+ let machine = (({ _id, position, cpus, gpus, memories, storages }) => ({
+ _id,
+ rackId: fullRack._id,
+ position,
+ cpuIds: cpus.map((u) => u._id),
+ gpuIds: gpus.map((u) => u._id),
+ memoryIds: memories.map((u) => u._id),
+ storageIds: storages.map((u) => u._id),
+ }))(fullMachine)
+ yield put(addToStore('machine', machine))
+ }
+ }
+
+ const filledSlots = new Array(fullRack.capacity).fill(null)
+ fullRack.machines.forEach(
+ (machine) => (filledSlots[machine.position - 1] = machine._id)
+ )
+ let rack = (({ _id, name, capacity, powerCapacityW }) => ({
+ _id,
+ name,
+ capacity,
+ powerCapacityW,
+ machineIds: filledSlots,
+ }))(fullRack)
+ yield put(addToStore('rack', rack))
+ }
+ }
+
+ let tile = (({ _id, positionX, positionY, rack }) => ({
+ _id,
+ roomId: fullRoom._id,
+ positionX,
+ positionY,
+ rackId: rack ? rack._id : undefined,
+ }))(fullTile)
+ yield put(addToStore('tile', tile))
+ }
+ }
+
+ let room = (({ _id, name, tiles }) => ({ _id, name, tileIds: tiles.map((t) => t._id) }))(fullRoom)
+ yield put(addToStore('room', room))
+ }
+ }
+
+ topology = (({ _id, name, rooms }) => ({ _id, name, roomIds: rooms.map((r) => r._id) }))(fullTopology)
+ yield put(addToStore('topology', topology))
+
+ // TODO consider pushing the IDs
+ }
+
+ return topology
+}
+
+const generateIdIfNotPresent = (obj) => {
+ if (!obj._id) {
+ obj._id = uuid()
+ }
+}
+
+export const updateTopologyOnServer = function* (id) {
+ const topology = yield getTopologyAsObject(id, true)
+
+ yield call(updateTopology, topology)
+}
+
+export const getTopologyAsObject = function* (id, keepIds) {
+ const topologyStore = yield select(OBJECT_SELECTORS['topology'])
+ const rooms = yield getAllRooms(topologyStore[id].roomIds, keepIds)
+ return {
+ _id: keepIds ? id : undefined,
+ name: topologyStore[id].name,
+ rooms: rooms,
+ }
+}
+
+export const getAllRooms = function* (roomIds, keepIds) {
+ const roomStore = yield select(OBJECT_SELECTORS['room'])
+
+ let rooms = []
+
+ for (let id of roomIds) {
+ let tiles = yield getAllRoomTiles(roomStore[id], keepIds)
+ rooms.push({
+ _id: keepIds ? id : undefined,
+ name: roomStore[id].name,
+ tiles: tiles,
+ })
+ }
+ return rooms
+}
+
+export const getAllRoomTiles = function* (roomStore, keepIds) {
+ let tiles = []
+
+ for (let id of roomStore.tileIds) {
+ tiles.push(yield getTileById(id, keepIds))
+ }
+ return tiles
+}
+
+export const getTileById = function* (id, keepIds) {
+ const tileStore = yield select(OBJECT_SELECTORS['tile'])
+ return {
+ _id: keepIds ? id : undefined,
+ positionX: tileStore[id].positionX,
+ positionY: tileStore[id].positionY,
+ rack: !tileStore[id].rackId ? undefined : yield getRackById(tileStore[id].rackId, keepIds),
+ }
+}
+
+export const getRackById = function* (id, keepIds) {
+ const rackStore = yield select(OBJECT_SELECTORS['rack'])
+ const machineStore = yield select(OBJECT_SELECTORS['machine'])
+ const cpuStore = yield select(OBJECT_SELECTORS['cpu'])
+ const gpuStore = yield select(OBJECT_SELECTORS['gpu'])
+ const memoryStore = yield select(OBJECT_SELECTORS['memory'])
+ const storageStore = yield select(OBJECT_SELECTORS['storage'])
+
+ return {
+ _id: keepIds ? rackStore[id]._id : undefined,
+ name: rackStore[id].name,
+ capacity: rackStore[id].capacity,
+ powerCapacityW: rackStore[id].powerCapacityW,
+ machines: rackStore[id].machineIds
+ .filter((m) => m !== null)
+ .map((machineId) => ({
+ _id: keepIds ? machineId : undefined,
+ position: machineStore[machineId].position,
+ cpus: machineStore[machineId].cpuIds.map((id) => cpuStore[id]),
+ gpus: machineStore[machineId].gpuIds.map((id) => gpuStore[id]),
+ memories: machineStore[machineId].memoryIds.map((id) => memoryStore[id]),
+ storages: machineStore[machineId].storageIds.map((id) => storageStore[id]),
+ })),
+ }
+}
+
+export const fetchAndStoreAllTraces = () => fetchAndStoreObjects('trace', call(getAllTraces))
+
+export const fetchAndStoreAllSchedulers = function* () {
+ const objects = yield call(getAllSchedulers)
+ for (let object of objects) {
+ object._id = object.name
+ yield put(addToStore('scheduler', object))
+ }
+ return objects
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/portfolios.js b/opendc-web/opendc-web-ui/src/sagas/portfolios.js
new file mode 100644
index 00000000..ed9bfd29
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/portfolios.js
@@ -0,0 +1,131 @@
+import { call, put, select, delay } from 'redux-saga/effects'
+import { addPropToStoreObject, addToStore } from '../actions/objects'
+import { addPortfolio, deletePortfolio, getPortfolio, updatePortfolio } from '../api/routes/portfolios'
+import { getProject } from '../api/routes/projects'
+import { fetchAndStoreAllSchedulers, fetchAndStoreAllTraces } from './objects'
+import { fetchAndStoreAllTopologiesOfProject } from './topology'
+import { getScenario } from '../api/routes/scenarios'
+
+export function* onOpenPortfolioSucceeded(action) {
+ try {
+ const project = yield call(getProject, action.projectId)
+ yield put(addToStore('project', project))
+ yield fetchAndStoreAllTopologiesOfProject(project._id)
+ yield fetchPortfoliosOfProject()
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+
+ yield watchForPortfolioResults()
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* watchForPortfolioResults() {
+ try {
+ const currentPortfolioId = yield select((state) => state.currentPortfolioId)
+ let unfinishedScenarios = yield getCurrentUnfinishedScenarios()
+
+ while (unfinishedScenarios.length > 0) {
+ yield delay(3000)
+ yield fetchPortfolioWithScenarios(currentPortfolioId)
+ unfinishedScenarios = yield getCurrentUnfinishedScenarios()
+ }
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* getCurrentUnfinishedScenarios() {
+ try {
+ const currentPortfolioId = yield select((state) => state.currentPortfolioId)
+ const scenarioIds = yield select((state) => state.objects.portfolio[currentPortfolioId].scenarioIds)
+ const scenarioObjects = yield select((state) => state.objects.scenario)
+ const scenarios = scenarioIds.map((s) => scenarioObjects[s])
+ return scenarios.filter((s) => !s || s.simulation.state === 'QUEUED' || s.simulation.state === 'RUNNING')
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* fetchPortfoliosOfProject() {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+ const currentProject = yield select((state) => state.objects.project[currentProjectId])
+
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+
+ for (let i in currentProject.portfolioIds) {
+ yield fetchPortfolioWithScenarios(currentProject.portfolioIds[i])
+ }
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* fetchPortfolioWithScenarios(portfolioId) {
+ try {
+ const portfolio = yield call(getPortfolio, portfolioId)
+ yield put(addToStore('portfolio', portfolio))
+
+ for (let i in portfolio.scenarioIds) {
+ const scenario = yield call(getScenario, portfolio.scenarioIds[i])
+ yield put(addToStore('scenario', scenario))
+ }
+ return portfolio
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddPortfolio(action) {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+
+ const portfolio = yield call(
+ addPortfolio,
+ currentProjectId,
+ Object.assign({}, action.portfolio, {
+ projectId: currentProjectId,
+ scenarioIds: [],
+ })
+ )
+ yield put(addToStore('portfolio', portfolio))
+
+ const portfolioIds = yield select((state) => state.objects.project[currentProjectId].portfolioIds)
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ portfolioIds: portfolioIds.concat([portfolio._id]),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onUpdatePortfolio(action) {
+ try {
+ const portfolio = yield call(updatePortfolio, action.portfolio._id, action.portfolio)
+ yield put(addToStore('portfolio', portfolio))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeletePortfolio(action) {
+ try {
+ yield call(deletePortfolio, action.id)
+
+ const currentProjectId = yield select((state) => state.currentProjectId)
+ const portfolioIds = yield select((state) => state.objects.project[currentProjectId].portfolioIds)
+
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ portfolioIds: portfolioIds.filter((id) => id !== action.id),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/prefabs.js b/opendc-web/opendc-web-ui/src/sagas/prefabs.js
new file mode 100644
index 00000000..16cf3d62
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/prefabs.js
@@ -0,0 +1,15 @@
+import { call, put, select } from 'redux-saga/effects'
+import { addToStore } from '../actions/objects'
+import { addPrefab } from '../api/routes/prefabs'
+import { getRackById } from './objects'
+
+export function* onAddPrefab(action) {
+ try {
+ const currentRackId = yield select((state) => state.objects.tile[state.interactionLevel.tileId].rackId)
+ const currentRackJson = yield getRackById(currentRackId, false)
+ const prefab = yield call(addPrefab, { name: action.name, rack: currentRackJson })
+ yield put(addToStore('prefab', prefab))
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/profile.js b/opendc-web/opendc-web-ui/src/sagas/profile.js
new file mode 100644
index 00000000..e914ba56
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/profile.js
@@ -0,0 +1,12 @@
+import { call, put } from 'redux-saga/effects'
+import { deleteCurrentUserSucceeded } from '../actions/users'
+import { deleteUser } from '../api/routes/users'
+
+export function* onDeleteCurrentUser(action) {
+ try {
+ yield call(deleteUser, action.userId)
+ yield put(deleteCurrentUserSucceeded())
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/projects.js b/opendc-web/opendc-web-ui/src/sagas/projects.js
new file mode 100644
index 00000000..fdeea132
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/projects.js
@@ -0,0 +1,48 @@
+import { call, put } from 'redux-saga/effects'
+import { addToStore } from '../actions/objects'
+import { addProjectSucceeded, deleteProjectSucceeded } from '../actions/projects'
+import { addProject, deleteProject, getProject } from '../api/routes/projects'
+import { fetchAndStoreAllTopologiesOfProject } from './topology'
+import { fetchAndStoreAllSchedulers, fetchAndStoreAllTraces } from './objects'
+import { fetchPortfoliosOfProject } from './portfolios'
+
+export function* onOpenProjectSucceeded(action) {
+ try {
+ const project = yield call(getProject, action.id)
+ yield put(addToStore('project', project))
+
+ yield fetchAndStoreAllTopologiesOfProject(action.id, true)
+ yield fetchPortfoliosOfProject()
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onProjectAdd(action) {
+ try {
+ const project = yield call(addProject, { name: action.name })
+ yield put(addToStore('project', project))
+
+ const authorization = {
+ projectId: project._id,
+ userId: action.userId,
+ authorizationLevel: 'OWN',
+ project,
+ }
+ yield put(addToStore('authorization', authorization))
+ yield put(addProjectSucceeded([authorization.userId, authorization.projectId]))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onProjectDelete(action) {
+ try {
+ yield call(deleteProject, action.id)
+ yield put(deleteProjectSucceeded(action.id))
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/scenarios.js b/opendc-web/opendc-web-ui/src/sagas/scenarios.js
new file mode 100644
index 00000000..59223610
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/scenarios.js
@@ -0,0 +1,65 @@
+import { call, put, select } from 'redux-saga/effects'
+import { addPropToStoreObject, addToStore } from '../actions/objects'
+import { getProject } from '../api/routes/projects'
+import { fetchAndStoreAllSchedulers, fetchAndStoreAllTraces } from './objects'
+import { fetchAndStoreAllTopologiesOfProject } from './topology'
+import { addScenario, deleteScenario, updateScenario } from '../api/routes/scenarios'
+import { fetchPortfolioWithScenarios, watchForPortfolioResults } from './portfolios'
+
+export function* onOpenScenarioSucceeded(action) {
+ try {
+ const project = yield call(getProject, action.projectId)
+ yield put(addToStore('project', project))
+ yield fetchAndStoreAllTopologiesOfProject(project._id)
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+ yield fetchPortfolioWithScenarios(action.portfolioId)
+
+ // TODO Fetch scenario-specific metrics
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddScenario(action) {
+ try {
+ const scenario = yield call(addScenario, action.scenario.portfolioId, action.scenario)
+ yield put(addToStore('scenario', scenario))
+
+ const scenarioIds = yield select((state) => state.objects.portfolio[action.scenario.portfolioId].scenarioIds)
+ yield put(
+ addPropToStoreObject('portfolio', action.scenario.portfolioId, {
+ scenarioIds: scenarioIds.concat([scenario._id]),
+ })
+ )
+ yield watchForPortfolioResults()
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onUpdateScenario(action) {
+ try {
+ const scenario = yield call(updateScenario, action.scenario._id, action.scenario)
+ yield put(addToStore('scenario', scenario))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteScenario(action) {
+ try {
+ yield call(deleteScenario, action.id)
+
+ const currentPortfolioId = yield select((state) => state.currentPortfolioId)
+ const scenarioIds = yield select((state) => state.objects.portfolio[currentPortfolioId].scenarioIds)
+
+ yield put(
+ addPropToStoreObject('scenario', currentPortfolioId, {
+ scenarioIds: scenarioIds.filter((id) => id !== action.id),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/topology.js b/opendc-web/opendc-web-ui/src/sagas/topology.js
new file mode 100644
index 00000000..bba1ebb1
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/topology.js
@@ -0,0 +1,311 @@
+import { call, put, select } from 'redux-saga/effects'
+import { goDownOneInteractionLevel } from '../actions/interaction-level'
+import {
+ addIdToStoreObjectListProp,
+ addPropToStoreObject,
+ addToStore,
+ removeIdFromStoreObjectListProp,
+} from '../actions/objects'
+import {
+ cancelNewRoomConstructionSucceeded,
+ setCurrentTopology,
+ startNewRoomConstructionSucceeded,
+} from '../actions/topology/building'
+import {
+ DEFAULT_RACK_POWER_CAPACITY,
+ DEFAULT_RACK_SLOT_CAPACITY,
+ MAX_NUM_UNITS_PER_MACHINE,
+} from '../components/app/map/MapConstants'
+import { fetchAndStoreTopology, getTopologyAsObject, updateTopologyOnServer } from './objects'
+import { uuid } from 'uuidv4'
+import { addTopology, deleteTopology } from '../api/routes/topologies'
+
+export function* fetchAndStoreAllTopologiesOfProject(projectId, setTopology = false) {
+ try {
+ const project = yield select((state) => state.objects.project[projectId])
+
+ for (let i in project.topologyIds) {
+ yield fetchAndStoreTopology(project.topologyIds[i])
+ }
+
+ if (setTopology) {
+ yield put(setCurrentTopology(project.topologyIds[0]))
+ }
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddTopology(action) {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+
+ let topologyToBeCreated
+ if (action.duplicateId) {
+ topologyToBeCreated = yield getTopologyAsObject(action.duplicateId, false)
+ topologyToBeCreated = Object.assign({}, topologyToBeCreated, {
+ name: action.name,
+ })
+ } else {
+ topologyToBeCreated = { name: action.name, rooms: [] }
+ }
+
+ const topology = yield call(
+ addTopology,
+ Object.assign({}, topologyToBeCreated, {
+ projectId: currentProjectId,
+ })
+ )
+ yield fetchAndStoreTopology(topology._id)
+
+ const topologyIds = yield select((state) => state.objects.project[currentProjectId].topologyIds)
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ topologyIds: topologyIds.concat([topology._id]),
+ })
+ )
+ yield put(setCurrentTopology(topology._id))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteTopology(action) {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+ const topologyIds = yield select((state) => state.objects.project[currentProjectId].topologyIds)
+ const currentTopologyId = yield select((state) => state.currentTopologyId)
+ if (currentTopologyId === action.id) {
+ yield put(setCurrentTopology(topologyIds.filter((t) => t !== action.id)[0]))
+ }
+
+ yield call(deleteTopology, action.id)
+
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ topologyIds: topologyIds.filter((id) => id !== action.id),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onStartNewRoomConstruction() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const room = {
+ _id: uuid(),
+ name: 'Room',
+ topologyId,
+ tileIds: [],
+ }
+ yield put(addToStore('room', room))
+ yield put(addIdToStoreObjectListProp('topology', topologyId, 'roomIds', room._id))
+ yield updateTopologyOnServer(topologyId)
+ yield put(startNewRoomConstructionSucceeded(room._id))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onCancelNewRoomConstruction() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.construction.currentRoomInConstruction)
+ yield put(removeIdFromStoreObjectListProp('topology', topologyId, 'roomIds', roomId))
+ // TODO remove room from store, too
+ yield updateTopologyOnServer(topologyId)
+ yield put(cancelNewRoomConstructionSucceeded())
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddTile(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.construction.currentRoomInConstruction)
+ const tile = {
+ _id: uuid(),
+ roomId,
+ positionX: action.positionX,
+ positionY: action.positionY,
+ }
+ yield put(addToStore('tile', tile))
+ yield put(addIdToStoreObjectListProp('room', roomId, 'tileIds', tile._id))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteTile(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.construction.currentRoomInConstruction)
+ yield put(removeIdFromStoreObjectListProp('room', roomId, 'tileIds', action.tileId))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onEditRoomName(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.interactionLevel.roomId)
+ const room = Object.assign({}, yield select((state) => state.objects.room[roomId]))
+ room.name = action.name
+ yield put(addPropToStoreObject('room', roomId, { name: action.name }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteRoom() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.interactionLevel.roomId)
+ yield put(goDownOneInteractionLevel())
+ yield put(removeIdFromStoreObjectListProp('topology', topologyId, 'roomIds', roomId))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onEditRackName(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const rackId = yield select((state) => state.objects.tile[state.interactionLevel.tileId].rackId)
+ const rack = Object.assign({}, yield select((state) => state.objects.rack[rackId]))
+ rack.name = action.name
+ yield put(addPropToStoreObject('rack', rackId, { name: action.name }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteRack() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ yield put(goDownOneInteractionLevel())
+ yield put(addPropToStoreObject('tile', tileId, { rackId: undefined }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddRackToTile(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const rack = {
+ _id: uuid(),
+ name: 'Rack',
+ capacity: DEFAULT_RACK_SLOT_CAPACITY,
+ powerCapacityW: DEFAULT_RACK_POWER_CAPACITY,
+ }
+ rack.machineIds = new Array(rack.capacity).fill(null)
+ yield put(addToStore('rack', rack))
+ yield put(addPropToStoreObject('tile', action.tileId, { rackId: rack._id }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddMachine(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const rackId = yield select((state) => state.objects.tile[state.interactionLevel.tileId].rackId)
+ const rack = yield select((state) => state.objects.rack[rackId])
+
+ const machine = {
+ _id: uuid(),
+ rackId,
+ position: action.position,
+ cpuIds: [],
+ gpuIds: [],
+ memoryIds: [],
+ storageIds: [],
+ }
+ yield put(addToStore('machine', machine))
+
+ const machineIds = [...rack.machineIds]
+ machineIds[machine.position - 1] = machine._id
+ yield put(addPropToStoreObject('rack', rackId, { machineIds }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteMachine() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ const position = yield select((state) => state.interactionLevel.position)
+ const rack = yield select((state) => state.objects.rack[state.objects.tile[tileId].rackId])
+ const machineIds = [...rack.machineIds]
+ machineIds[position - 1] = null
+ yield put(goDownOneInteractionLevel())
+ yield put(addPropToStoreObject('rack', rack._id, { machineIds }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddUnit(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ const position = yield select((state) => state.interactionLevel.position)
+ const machine = yield select(
+ (state) =>
+ state.objects.machine[state.objects.rack[state.objects.tile[tileId].rackId].machineIds[position - 1]]
+ )
+
+ if (machine[action.unitType + 'Ids'].length >= MAX_NUM_UNITS_PER_MACHINE) {
+ return
+ }
+
+ const units = [...machine[action.unitType + 'Ids'], action.id]
+ yield put(
+ addPropToStoreObject('machine', machine._id, {
+ [action.unitType + 'Ids']: units,
+ })
+ )
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteUnit(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ const position = yield select((state) => state.interactionLevel.position)
+ const machine = yield select(
+ (state) =>
+ state.objects.machine[state.objects.rack[state.objects.tile[tileId].rackId].machineIds[position - 1]]
+ )
+ const unitIds = machine[action.unitType + 'Ids'].slice()
+ unitIds.splice(action.index, 1)
+
+ yield put(
+ addPropToStoreObject('machine', machine._id, {
+ [action.unitType + 'Ids']: unitIds,
+ })
+ )
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/sagas/users.js b/opendc-web/opendc-web-ui/src/sagas/users.js
new file mode 100644
index 00000000..74e652f6
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/sagas/users.js
@@ -0,0 +1,44 @@
+import { call, put } from 'redux-saga/effects'
+import { logInSucceeded } from '../actions/auth'
+import { addToStore } from '../actions/objects'
+import { fetchAuthorizationsOfCurrentUserSucceeded } from '../actions/users'
+import { performTokenSignIn } from '../api/routes/token-signin'
+import { addUser } from '../api/routes/users'
+import { saveAuthLocalStorage } from '../auth/index'
+import { fetchAndStoreProject, fetchAndStoreUser } from './objects'
+
+export function* onFetchLoggedInUser(action) {
+ try {
+ const tokenResponse = yield call(performTokenSignIn, action.payload.authToken)
+
+ let userId = tokenResponse.userId
+
+ if (tokenResponse.isNewUser) {
+ saveAuthLocalStorage({ authToken: action.payload.authToken })
+ const newUser = yield call(addUser, action.payload)
+ userId = newUser._id
+ }
+
+ yield put(logInSucceeded(Object.assign({ userId }, action.payload)))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onFetchAuthorizationsOfCurrentUser(action) {
+ try {
+ const user = yield call(fetchAndStoreUser, action.userId)
+
+ for (const authorization of user.authorizations) {
+ authorization.userId = action.userId
+ yield put(addToStore('authorization', authorization))
+ yield fetchAndStoreProject(authorization.projectId)
+ }
+
+ const authorizationIds = user.authorizations.map((authorization) => [action.userId, authorization.projectId])
+
+ yield put(fetchAuthorizationsOfCurrentUserSucceeded(authorizationIds))
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/shapes/index.js b/opendc-web/opendc-web-ui/src/shapes/index.js
new file mode 100644
index 00000000..9fab6f5d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/shapes/index.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types'
+
+const Shapes = {}
+
+Shapes.User = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ googleId: PropTypes.string.isRequired,
+ email: PropTypes.string.isRequired,
+ givenName: PropTypes.string.isRequired,
+ familyName: PropTypes.string.isRequired,
+ authorizations: PropTypes.array.isRequired,
+})
+
+Shapes.Project = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ datetimeCreated: PropTypes.string.isRequired,
+ datetimeLastEdited: PropTypes.string.isRequired,
+ topologyIds: PropTypes.array.isRequired,
+ portfolioIds: PropTypes.array.isRequired,
+})
+
+Shapes.Authorization = PropTypes.shape({
+ userId: PropTypes.string.isRequired,
+ user: Shapes.User,
+ projectId: PropTypes.string.isRequired,
+ project: Shapes.Project,
+ authorizationLevel: PropTypes.string.isRequired,
+})
+
+Shapes.ProcessingUnit = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ clockRateMhz: PropTypes.number.isRequired,
+ numberOfCores: PropTypes.number.isRequired,
+ energyConsumptionW: PropTypes.number.isRequired,
+})
+
+Shapes.StorageUnit = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ speedMbPerS: PropTypes.number.isRequired,
+ sizeMb: PropTypes.number.isRequired,
+ energyConsumptionW: PropTypes.number.isRequired,
+})
+
+Shapes.Machine = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ rackId: PropTypes.string.isRequired,
+ position: PropTypes.number.isRequired,
+ cpuIds: PropTypes.arrayOf(PropTypes.string.isRequired),
+ cpus: PropTypes.arrayOf(Shapes.ProcessingUnit),
+ gpuIds: PropTypes.arrayOf(PropTypes.string.isRequired),
+ gpus: PropTypes.arrayOf(Shapes.ProcessingUnit),
+ memoryIds: PropTypes.arrayOf(PropTypes.string.isRequired),
+ memories: PropTypes.arrayOf(Shapes.StorageUnit),
+ storageIds: PropTypes.arrayOf(PropTypes.string.isRequired),
+ storages: PropTypes.arrayOf(Shapes.StorageUnit),
+})
+
+Shapes.Rack = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ capacity: PropTypes.number.isRequired,
+ powerCapacityW: PropTypes.number.isRequired,
+ machines: PropTypes.arrayOf(Shapes.Machine),
+})
+
+Shapes.Tile = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ roomId: PropTypes.string.isRequired,
+ positionX: PropTypes.number.isRequired,
+ positionY: PropTypes.number.isRequired,
+ rackId: PropTypes.string,
+ rack: Shapes.Rack,
+})
+
+Shapes.Room = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ tiles: PropTypes.arrayOf(Shapes.Tile),
+})
+
+Shapes.Topology = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ rooms: PropTypes.arrayOf(Shapes.Room),
+})
+
+Shapes.Scheduler = PropTypes.shape({
+ name: PropTypes.string.isRequired,
+})
+
+Shapes.Trace = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+})
+
+Shapes.Portfolio = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ projectId: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ scenarioIds: PropTypes.arrayOf(PropTypes.string).isRequired,
+ targets: PropTypes.shape({
+ enabledMetrics: PropTypes.arrayOf(PropTypes.string).isRequired,
+ repeatsPerScenario: PropTypes.number.isRequired,
+ }).isRequired,
+})
+
+Shapes.Scenario = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ portfolioId: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ simulation: PropTypes.shape({
+ state: PropTypes.string.isRequired,
+ }).isRequired,
+ trace: PropTypes.shape({
+ traceId: PropTypes.string.isRequired,
+ trace: Shapes.Trace,
+ loadSamplingFraction: PropTypes.number.isRequired,
+ }).isRequired,
+ topology: PropTypes.shape({
+ topologyId: PropTypes.string.isRequired,
+ topology: Shapes.Topology,
+ }).isRequired,
+ operational: PropTypes.shape({
+ failuresEnabled: PropTypes.bool.isRequired,
+ performanceInterferenceEnabled: PropTypes.bool.isRequired,
+ schedulerName: PropTypes.string.isRequired,
+ scheduler: Shapes.Scheduler,
+ }).isRequired,
+ results: PropTypes.object,
+})
+
+Shapes.WallSegment = PropTypes.shape({
+ startPosX: PropTypes.number.isRequired,
+ startPosY: PropTypes.number.isRequired,
+ isHorizontal: PropTypes.bool.isRequired,
+ length: PropTypes.number.isRequired,
+})
+
+Shapes.InteractionLevel = PropTypes.shape({
+ mode: PropTypes.string.isRequired,
+ roomId: PropTypes.string,
+ rackId: PropTypes.string,
+})
+
+export default Shapes
diff --git a/opendc-web/opendc-web-ui/src/shortcuts/keymap.js b/opendc-web/opendc-web-ui/src/shortcuts/keymap.js
new file mode 100644
index 00000000..797340d7
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/shortcuts/keymap.js
@@ -0,0 +1,10 @@
+const KeymapConfiguration = {
+ MAP: {
+ MOVE_LEFT: ['a', 'left'],
+ MOVE_RIGHT: ['d', 'right'],
+ MOVE_UP: ['w', 'up'],
+ MOVE_DOWN: ['s', 'down'],
+ },
+}
+
+export default KeymapConfiguration
diff --git a/opendc-web/opendc-web-ui/src/store/configure-store.js b/opendc-web/opendc-web-ui/src/store/configure-store.js
new file mode 100644
index 00000000..d8f343ed
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/store/configure-store.js
@@ -0,0 +1,35 @@
+import { applyMiddleware, compose, createStore } from 'redux'
+import persistState from 'redux-localstorage'
+import { createLogger } from 'redux-logger'
+import createSagaMiddleware from 'redux-saga'
+import thunk from 'redux-thunk'
+import { authRedirectMiddleware } from '../auth/index'
+import rootReducer from '../reducers/index'
+import rootSaga from '../sagas/index'
+import { dummyMiddleware } from './middlewares/dummy-middleware'
+import { viewportAdjustmentMiddleware } from './middlewares/viewport-adjustment'
+
+const sagaMiddleware = createSagaMiddleware()
+
+let logger
+if (process.env.NODE_ENV !== 'production') {
+ logger = createLogger()
+}
+
+const middlewares = [
+ process.env.NODE_ENV === 'production' ? dummyMiddleware : logger,
+ thunk,
+ sagaMiddleware,
+ authRedirectMiddleware,
+ viewportAdjustmentMiddleware,
+]
+
+export let store = undefined
+
+export default function configureStore() {
+ const configuredStore = createStore(rootReducer, compose(persistState('auth'), applyMiddleware(...middlewares)))
+ sagaMiddleware.run(rootSaga)
+ store = configuredStore
+
+ return configuredStore
+}
diff --git a/opendc-web/opendc-web-ui/src/store/middlewares/dummy-middleware.js b/opendc-web/opendc-web-ui/src/store/middlewares/dummy-middleware.js
new file mode 100644
index 00000000..5ba35691
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/store/middlewares/dummy-middleware.js
@@ -0,0 +1,3 @@
+export const dummyMiddleware = (store) => (next) => (action) => {
+ next(action)
+}
diff --git a/opendc-web/opendc-web-ui/src/store/middlewares/viewport-adjustment.js b/opendc-web/opendc-web-ui/src/store/middlewares/viewport-adjustment.js
new file mode 100644
index 00000000..b4472c54
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/store/middlewares/viewport-adjustment.js
@@ -0,0 +1,73 @@
+import { SET_MAP_DIMENSIONS, setMapPosition, setMapScale } from '../../actions/map'
+import { SET_CURRENT_TOPOLOGY } from '../../actions/topology/building'
+import {
+ MAP_MAX_SCALE,
+ MAP_MIN_SCALE,
+ SIDEBAR_WIDTH,
+ TILE_SIZE_IN_PIXELS,
+ VIEWPORT_PADDING,
+} from '../../components/app/map/MapConstants'
+import { calculateRoomListBounds } from '../../util/tile-calculations'
+
+export const viewportAdjustmentMiddleware = (store) => (next) => (action) => {
+ const state = store.getState()
+
+ let topologyId = '-1'
+ let mapDimensions = {}
+ if (action.type === SET_CURRENT_TOPOLOGY && action.topologyId !== '-1') {
+ topologyId = action.topologyId
+ mapDimensions = state.map.dimensions
+ } else if (action.type === SET_MAP_DIMENSIONS && state.currentTopologyId !== '-1') {
+ topologyId = state.currentTopologyId
+ mapDimensions = { width: action.width, height: action.height }
+ }
+
+ if (topologyId !== '-1') {
+ const roomIds = state.objects.topology[topologyId].roomIds
+ const rooms = roomIds.map((id) => Object.assign({}, state.objects.room[id]))
+ rooms.forEach((room) => (room.tiles = room.tileIds.map((tileId) => state.objects.tile[tileId])))
+
+ let hasNoTiles = true
+ for (let i in rooms) {
+ if (rooms[i].tiles.length > 0) {
+ hasNoTiles = false
+ break
+ }
+ }
+
+ if (!hasNoTiles) {
+ const viewportParams = calculateParametersToZoomInOnRooms(rooms, mapDimensions.width, mapDimensions.height)
+ store.dispatch(setMapPosition(viewportParams.newX, viewportParams.newY))
+ store.dispatch(setMapScale(viewportParams.newScale))
+ }
+ }
+
+ next(action)
+}
+
+function calculateParametersToZoomInOnRooms(rooms, mapWidth, mapHeight) {
+ const bounds = calculateRoomListBounds(rooms)
+ const newScale = calculateNewScale(bounds, mapWidth, mapHeight)
+
+ // Coordinates of the center of the room, relative to the global origin of the map
+ const roomCenterCoordinates = {
+ x: bounds.center.x * TILE_SIZE_IN_PIXELS * newScale,
+ y: bounds.center.y * TILE_SIZE_IN_PIXELS * newScale,
+ }
+
+ const newX = -roomCenterCoordinates.x + mapWidth / 2
+ const newY = -roomCenterCoordinates.y + mapHeight / 2
+
+ return { newScale, newX, newY }
+}
+
+function calculateNewScale(bounds, mapWidth, mapHeight) {
+ const width = bounds.max.x - bounds.min.x
+ const height = bounds.max.y - bounds.min.y
+
+ const scaleX = (mapWidth - 2 * SIDEBAR_WIDTH) / (width * TILE_SIZE_IN_PIXELS + 2 * VIEWPORT_PADDING)
+ const scaleY = mapHeight / (height * TILE_SIZE_IN_PIXELS + 2 * VIEWPORT_PADDING)
+ const newScale = Math.min(scaleX, scaleY)
+
+ return Math.min(Math.max(MAP_MIN_SCALE, newScale), MAP_MAX_SCALE)
+}
diff --git a/opendc-web/opendc-web-ui/src/style-globals/_mixins.sass b/opendc-web/opendc-web-ui/src/style-globals/_mixins.sass
new file mode 100644
index 00000000..d0a8d1ac
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/style-globals/_mixins.sass
@@ -0,0 +1,21 @@
+=transition($property, $time)
+ -webkit-transition: $property $time
+ -moz-transition: $property $time
+ -o-transition: $property $time
+ transition: $property $time
+
+=user-select
+ -webkit-user-select: none
+ -moz-user-select: none
+ -ms-user-select: none
+ user-select: none
+
+=border-radius($length)
+ -webkit-border-radius: $length
+ -moz-border-radius: $length
+ border-radius: $length
+
+/* General Button Abstractions */
+=clickable
+ cursor: pointer
+ +user-select
diff --git a/opendc-web/opendc-web-ui/src/style-globals/_variables.sass b/opendc-web/opendc-web-ui/src/style-globals/_variables.sass
new file mode 100644
index 00000000..7553caa0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/style-globals/_variables.sass
@@ -0,0 +1,31 @@
+// Sizes and Margins
+$document-padding: 20px
+$inter-element-margin: 5px
+$standard-border-radius: 5px
+$side-menu-width: 350px
+$color-indicator-width: 140px
+
+$global-padding: 30px
+$side-bar-width: 350px
+$navbar-height: 50px
+$navbar-padding: 10px
+
+// Durations
+$transition-length: 150ms
+
+// Colors
+$gray-very-dark: #5c5c5c
+$gray-dark: #aaa
+$gray-semi-dark: #bbb
+$gray-semi-light: #ccc
+$gray-light: #ddd
+$gray-very-light: #eee
+$blue: #00A6D6
+$blue-dark: #0087b5
+$blue-very-dark: #006182
+$blue-light: #deebf7
+
+// Media queries
+$screen-sm: 768px
+$screen-md: 992px
+$screen-lg: 1200px
diff --git a/opendc-web/opendc-web-ui/src/util/authorizations.js b/opendc-web/opendc-web-ui/src/util/authorizations.js
new file mode 100644
index 00000000..4086b35d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/authorizations.js
@@ -0,0 +1,11 @@
+export const AUTH_ICON_MAP = {
+ OWN: 'home',
+ EDIT: 'pencil',
+ VIEW: 'eye',
+}
+
+export const AUTH_DESCRIPTION_MAP = {
+ OWN: 'Own',
+ EDIT: 'Can Edit',
+ VIEW: 'Can View',
+}
diff --git a/opendc-web/opendc-web-ui/src/util/available-metrics.js b/opendc-web/opendc-web-ui/src/util/available-metrics.js
new file mode 100644
index 00000000..807bc0c1
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/available-metrics.js
@@ -0,0 +1,67 @@
+export const AVAILABLE_METRICS = [
+ 'total_overcommitted_burst',
+ 'total_power_draw',
+ 'total_failure_vm_slices',
+ 'total_granted_burst',
+ 'total_interfered_burst',
+ 'total_requested_burst',
+ 'mean_cpu_usage',
+ 'mean_cpu_demand',
+ 'mean_num_deployed_images',
+ 'max_num_deployed_images',
+ 'total_vms_submitted',
+ 'total_vms_queued',
+ 'total_vms_finished',
+ 'total_vms_failed',
+]
+
+export const METRIC_NAMES_SHORT = {
+ total_overcommitted_burst: 'Overcomm. CPU Cycles',
+ total_granted_burst: 'Granted CPU Cycles',
+ total_requested_burst: 'Requested CPU Cycles',
+ total_interfered_burst: 'Interfered CPU Cycles',
+ total_power_draw: 'Total Power Consumption',
+ mean_cpu_usage: 'Mean Host CPU Usage',
+ mean_cpu_demand: 'Mean Host CPU Demand',
+ mean_num_deployed_images: 'Mean Num. Deployed Images Per Host',
+ max_num_deployed_images: 'Max. Num. Deployed Images Per Host',
+ total_failure_vm_slices: 'Total Num. Failed VM Slices',
+ total_vms_submitted: 'Total Num. VMs Submitted',
+ total_vms_queued: 'Max. Num. VMs Queued',
+ total_vms_finished: 'Max. Num. VMs Finished',
+ total_vms_failed: 'Max. Num. VMs Failed',
+}
+
+export const METRIC_NAMES = {
+ total_overcommitted_burst: 'Overcommitted CPU Cycles',
+ total_granted_burst: 'Granted CPU Cycles',
+ total_requested_burst: 'Requested CPU Cycles',
+ total_interfered_burst: 'Interfered CPU Cycles',
+ total_power_draw: 'Total Power Consumption',
+ mean_cpu_usage: 'Mean Host CPU Usage',
+ mean_cpu_demand: 'Mean Host CPU Demand',
+ mean_num_deployed_images: 'Mean Number of Deployed Images Per Host',
+ max_num_deployed_images: 'Maximum Number Deployed Images Per Host',
+ total_failure_vm_slices: 'Total Number Failed VM Slices',
+ total_vms_submitted: 'Total Number VMs Submitted',
+ total_vms_queued: 'Maximum Number VMs Queued',
+ total_vms_finished: 'Maximum Number VMs Finished',
+ total_vms_failed: 'Maximum Number VMs Failed',
+}
+
+export const METRIC_UNITS = {
+ total_overcommitted_burst: 'MFLOP',
+ total_granted_burst: 'MFLOP',
+ total_requested_burst: 'MFLOP',
+ total_interfered_burst: 'MFLOP',
+ total_power_draw: 'Wh',
+ mean_cpu_usage: 'MHz',
+ mean_cpu_demand: 'MHz',
+ mean_num_deployed_images: 'VMs',
+ max_num_deployed_images: 'VMs',
+ total_failure_vm_slices: 'VM Slices',
+ total_vms_submitted: 'VMs',
+ total_vms_queued: 'VMs',
+ total_vms_finished: 'VMs',
+ total_vms_failed: 'VMs',
+}
diff --git a/opendc-web/opendc-web-ui/src/util/colors.js b/opendc-web/opendc-web-ui/src/util/colors.js
new file mode 100644
index 00000000..34468503
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/colors.js
@@ -0,0 +1,29 @@
+export const GRID_COLOR = 'rgba(0, 0, 0, 0.5)'
+export const BACKDROP_COLOR = 'rgba(255, 255, 255, 1)'
+export const WALL_COLOR = 'rgba(0, 0, 0, 1)'
+
+export const ROOM_DEFAULT_COLOR = 'rgba(150, 150, 150, 1)'
+export const ROOM_IN_CONSTRUCTION_COLOR = 'rgba(51, 153, 255, 1)'
+export const ROOM_HOVER_VALID_COLOR = 'rgba(51, 153, 255, 1)'
+export const ROOM_HOVER_INVALID_COLOR = 'rgba(255, 102, 0, 1)'
+export const ROOM_NAME_COLOR = 'rgba(245, 245, 245, 1)'
+export const ROOM_TYPE_COLOR = 'rgba(245, 245, 245, 1)'
+
+export const TILE_PLUS_COLOR = 'rgba(0, 0, 0, 1)'
+
+export const OBJECT_BORDER_COLOR = 'rgba(0, 0, 0, 1)'
+
+export const RACK_BACKGROUND_COLOR = 'rgba(170, 170, 170, 1)'
+export const RACK_SPACE_BAR_BACKGROUND_COLOR = 'rgba(222, 235, 247, 0.6)'
+export const RACK_SPACE_BAR_FILL_COLOR = 'rgba(91, 155, 213, 0.7)'
+export const RACK_ENERGY_BAR_BACKGROUND_COLOR = 'rgba(255, 242, 204, 0.6)'
+export const RACK_ENERGY_BAR_FILL_COLOR = 'rgba(244, 215, 0, 0.7)'
+export const COOLING_ITEM_BACKGROUND_COLOR = 'rgba(40, 50, 230, 1)'
+export const PSU_BACKGROUND_COLOR = 'rgba(230, 50, 60, 1)'
+
+export const GRAYED_OUT_AREA_COLOR = 'rgba(0, 0, 0, 0.6)'
+
+export const SIM_LOW_COLOR = 'rgba(197, 224, 180, 1)'
+export const SIM_MID_LOW_COLOR = 'rgba(255, 230, 153, 1)'
+export const SIM_MID_HIGH_COLOR = 'rgba(248, 203, 173, 1)'
+export const SIM_HIGH_COLOR = 'rgba(249, 165, 165, 1)'
diff --git a/opendc-web/opendc-web-ui/src/util/date-time.js b/opendc-web/opendc-web-ui/src/util/date-time.js
new file mode 100644
index 00000000..66efdf5b
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/date-time.js
@@ -0,0 +1,93 @@
+/**
+ * Parses and formats the given date-time string representation.
+ *
+ * The format assumed is "YYYY-MM-DDTHH:MM:SS".
+ *
+ * @param dateTimeString A string expressing a date and a time, in the above mentioned format.
+ * @returns {string} A human-friendly string version of that date and time.
+ */
+export function parseAndFormatDateTime(dateTimeString) {
+ return formatDateTime(parseDateTime(dateTimeString))
+}
+
+/**
+ * Parses date-time string representations and returns a parsed object.
+ *
+ * The format assumed is "YYYY-MM-DDTHH:MM:SS".
+ *
+ * @param dateTimeString A string expressing a date and a time, in the above mentioned format.
+ * @returns {object} A Date object with the parsed date and time information as content.
+ */
+export function parseDateTime(dateTimeString) {
+ return new Date(dateTimeString + '.000Z')
+}
+
+/**
+ * Serializes the given date and time value to a human-friendly string.
+ *
+ * @param dateTime An object representation of a date and time.
+ * @returns {string} A human-friendly string version of that date and time.
+ */
+export function formatDateTime(dateTime) {
+ let date
+ const currentDate = new Date()
+
+ date =
+ addPaddingToTwo(dateTime.getDay()) +
+ '/' +
+ addPaddingToTwo(dateTime.getMonth()) +
+ '/' +
+ addPaddingToTwo(dateTime.getFullYear())
+
+ if (dateTime.getFullYear() === currentDate.getFullYear() && dateTime.getMonth() === currentDate.getMonth()) {
+ if (dateTime.getDate() === currentDate.getDate()) {
+ date = 'Today'
+ } else if (dateTime.getDate() === currentDate.getDate() - 1) {
+ date = 'Yesterday'
+ }
+ }
+
+ return date + ', ' + addPaddingToTwo(dateTime.getHours()) + ':' + addPaddingToTwo(dateTime.getMinutes())
+}
+
+/**
+ * Formats the given number of seconds/ticks to a formatted time representation.
+ *
+ * @param seconds The number of seconds.
+ * @returns {string} A string representation of that amount of second, in the from of HH:MM:SS.
+ */
+export function convertSecondsToFormattedTime(seconds) {
+ if (seconds <= 0) {
+ return '0s'
+ }
+
+ let hour = Math.floor(seconds / 3600)
+ let minute = Math.floor(seconds / 60) % 60
+ let second = seconds % 60
+
+ hour = isNaN(hour) ? 0 : hour
+ minute = isNaN(minute) ? 0 : minute
+ second = isNaN(second) ? 0 : second
+
+ if (hour === 0 && minute === 0) {
+ return second + 's'
+ } else if (hour === 0) {
+ return minute + 'm' + addPaddingToTwo(second) + 's'
+ } else {
+ return hour + 'h' + addPaddingToTwo(minute) + 'm' + addPaddingToTwo(second) + 's'
+ }
+}
+
+/**
+ * Pads the given integer to have at least two digits.
+ *
+ * @param integer An integer to be padded.
+ * @returns {string} A string containing the padded integer.
+ */
+function addPaddingToTwo(integer) {
+ if (integer < 10) {
+ return '0' + integer.toString()
+ } else {
+ return integer.toString()
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/util/date-time.test.js b/opendc-web/opendc-web-ui/src/util/date-time.test.js
new file mode 100644
index 00000000..3d95eba6
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/date-time.test.js
@@ -0,0 +1,35 @@
+import { convertSecondsToFormattedTime, parseDateTime } from './date-time'
+
+describe('date-time parsing', () => {
+ it('reads components properly', () => {
+ const dateString = '2017-09-27T20:55:01'
+ const parsedDate = parseDateTime(dateString)
+
+ expect(parsedDate.getUTCFullYear()).toEqual(2017)
+ expect(parsedDate.getUTCMonth()).toEqual(8)
+ expect(parsedDate.getUTCDate()).toEqual(27)
+ expect(parsedDate.getUTCHours()).toEqual(20)
+ expect(parsedDate.getUTCMinutes()).toEqual(55)
+ expect(parsedDate.getUTCSeconds()).toEqual(1)
+ })
+})
+
+describe('tick formatting', () => {
+ it("returns '0s' for numbers <= 0", () => {
+ expect(convertSecondsToFormattedTime(-1)).toEqual('0s')
+ expect(convertSecondsToFormattedTime(0)).toEqual('0s')
+ })
+ it('returns only seconds for values under a minute', () => {
+ expect(convertSecondsToFormattedTime(1)).toEqual('1s')
+ expect(convertSecondsToFormattedTime(59)).toEqual('59s')
+ })
+ it('returns seconds and minutes for values under an hour', () => {
+ expect(convertSecondsToFormattedTime(60)).toEqual('1m00s')
+ expect(convertSecondsToFormattedTime(61)).toEqual('1m01s')
+ expect(convertSecondsToFormattedTime(3599)).toEqual('59m59s')
+ })
+ it('returns full time for values over an hour', () => {
+ expect(convertSecondsToFormattedTime(3600)).toEqual('1h00m00s')
+ expect(convertSecondsToFormattedTime(3601)).toEqual('1h00m01s')
+ })
+})
diff --git a/opendc-web/opendc-web-ui/src/util/sidebar-space.js b/opendc-web/opendc-web-ui/src/util/sidebar-space.js
new file mode 100644
index 00000000..ef09d40a
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/sidebar-space.js
@@ -0,0 +1,2 @@
+export const isCollapsible = (location) =>
+ location.pathname.indexOf('portfolios') === -1 && location.pathname.indexOf('scenarios') === -1
diff --git a/opendc-web/opendc-web-ui/src/util/state-utils.js b/opendc-web/opendc-web-ui/src/util/state-utils.js
new file mode 100644
index 00000000..e5b695c3
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/state-utils.js
@@ -0,0 +1,6 @@
+export const getState = (dispatch) =>
+ new Promise((resolve) => {
+ dispatch((dispatch, getState) => {
+ resolve(getState())
+ })
+ })
diff --git a/opendc-web/opendc-web-ui/src/util/tile-calculations.js b/opendc-web/opendc-web-ui/src/util/tile-calculations.js
new file mode 100644
index 00000000..764ae6ac
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/tile-calculations.js
@@ -0,0 +1,255 @@
+export function deriveWallLocations(tiles) {
+ const { verticalWalls, horizontalWalls } = getWallSegments(tiles)
+ return mergeWallSegments(verticalWalls, horizontalWalls)
+}
+
+function getWallSegments(tiles) {
+ const verticalWalls = {}
+ const horizontalWalls = {}
+
+ tiles.forEach((tile) => {
+ const x = tile.positionX,
+ y = tile.positionY
+
+ for (let dX = -1; dX <= 1; dX++) {
+ for (let dY = -1; dY <= 1; dY++) {
+ if (Math.abs(dX) === Math.abs(dY)) {
+ continue
+ }
+
+ let doInsert = true
+ for (let tileIndex in tiles) {
+ if (tiles[tileIndex].positionX === x + dX && tiles[tileIndex].positionY === y + dY) {
+ doInsert = false
+ break
+ }
+ }
+ if (!doInsert) {
+ continue
+ }
+
+ if (dX === -1) {
+ if (verticalWalls[x] === undefined) {
+ verticalWalls[x] = []
+ }
+ if (verticalWalls[x].indexOf(y) === -1) {
+ verticalWalls[x].push(y)
+ }
+ } else if (dX === 1) {
+ if (verticalWalls[x + 1] === undefined) {
+ verticalWalls[x + 1] = []
+ }
+ if (verticalWalls[x + 1].indexOf(y) === -1) {
+ verticalWalls[x + 1].push(y)
+ }
+ } else if (dY === -1) {
+ if (horizontalWalls[y] === undefined) {
+ horizontalWalls[y] = []
+ }
+ if (horizontalWalls[y].indexOf(x) === -1) {
+ horizontalWalls[y].push(x)
+ }
+ } else if (dY === 1) {
+ if (horizontalWalls[y + 1] === undefined) {
+ horizontalWalls[y + 1] = []
+ }
+ if (horizontalWalls[y + 1].indexOf(x) === -1) {
+ horizontalWalls[y + 1].push(x)
+ }
+ }
+ }
+ }
+ })
+
+ return { verticalWalls, horizontalWalls }
+}
+
+function mergeWallSegments(vertical, horizontal) {
+ const result = []
+ const walls = [vertical, horizontal]
+
+ for (let i = 0; i < 2; i++) {
+ const wallList = walls[i]
+ for (let a in wallList) {
+ a = parseInt(a, 10)
+
+ wallList[a].sort((a, b) => {
+ return a - b
+ })
+
+ let startPos = wallList[a][0]
+ const isHorizontal = i === 1
+
+ if (wallList[a].length === 1) {
+ const startPosX = isHorizontal ? startPos : a
+ const startPosY = isHorizontal ? a : startPos
+ result.push({
+ startPosX,
+ startPosY,
+ isHorizontal,
+ length: 1,
+ })
+ } else {
+ let consecutiveCount = 1
+ for (let b = 0; b < wallList[a].length - 1; b++) {
+ if (b + 1 === wallList[a].length - 1) {
+ if (wallList[a][b + 1] - wallList[a][b] > 1) {
+ const startPosX = isHorizontal ? startPos : a
+ const startPosY = isHorizontal ? a : startPos
+ result.push({
+ startPosX,
+ startPosY,
+ isHorizontal,
+ length: consecutiveCount,
+ })
+ consecutiveCount = 0
+ startPos = wallList[a][b + 1]
+ }
+ const startPosX = isHorizontal ? startPos : a
+ const startPosY = isHorizontal ? a : startPos
+ result.push({
+ startPosX,
+ startPosY,
+ isHorizontal,
+ length: consecutiveCount + 1,
+ })
+ break
+ } else if (wallList[a][b + 1] - wallList[a][b] > 1) {
+ const startPosX = isHorizontal ? startPos : a
+ const startPosY = isHorizontal ? a : startPos
+ result.push({
+ startPosX,
+ startPosY,
+ isHorizontal,
+ length: consecutiveCount,
+ })
+ startPos = wallList[a][b + 1]
+ consecutiveCount = 0
+ }
+ consecutiveCount++
+ }
+ }
+ }
+ }
+
+ return result
+}
+
+export function deriveValidNextTilePositions(rooms, selectedTiles) {
+ const result = [],
+ newPosition = { x: 0, y: 0 }
+ let isSurroundingTile
+
+ selectedTiles.forEach((tile) => {
+ const x = tile.positionX
+ const y = tile.positionY
+ result.push({ x, y })
+
+ for (let dX = -1; dX <= 1; dX++) {
+ for (let dY = -1; dY <= 1; dY++) {
+ if (Math.abs(dX) === Math.abs(dY)) {
+ continue
+ }
+
+ newPosition.x = x + dX
+ newPosition.y = y + dY
+
+ isSurroundingTile = true
+ for (let index in selectedTiles) {
+ if (
+ selectedTiles[index].positionX === newPosition.x &&
+ selectedTiles[index].positionY === newPosition.y
+ ) {
+ isSurroundingTile = false
+ break
+ }
+ }
+
+ if (isSurroundingTile && findPositionInRooms(rooms, newPosition.x, newPosition.y) === -1) {
+ result.push({ x: newPosition.x, y: newPosition.y })
+ }
+ }
+ }
+ })
+
+ return result
+}
+
+export function findPositionInPositions(positions, positionX, positionY) {
+ for (let i = 0; i < positions.length; i++) {
+ const position = positions[i]
+ if (positionX === position.x && positionY === position.y) {
+ return i
+ }
+ }
+
+ return -1
+}
+
+export function findPositionInRooms(rooms, positionX, positionY) {
+ for (let i = 0; i < rooms.length; i++) {
+ const room = rooms[i]
+ if (findPositionInTiles(room.tiles, positionX, positionY) !== -1) {
+ return i
+ }
+ }
+
+ return -1
+}
+
+function findPositionInTiles(tiles, positionX, positionY) {
+ let index = -1
+
+ for (let i = 0; i < tiles.length; i++) {
+ const tile = tiles[i]
+ if (positionX === tile.positionX && positionY === tile.positionY) {
+ index = i
+ break
+ }
+ }
+
+ return index
+}
+
+export function findTileWithPosition(tiles, positionX, positionY) {
+ for (let i = 0; i < tiles.length; i++) {
+ if (tiles[i].positionX === positionX && tiles[i].positionY === positionY) {
+ return tiles[i]
+ }
+ }
+
+ return null
+}
+
+export function calculateRoomListBounds(rooms) {
+ const min = { x: Number.MAX_VALUE, y: Number.MAX_VALUE }
+ const max = { x: -1, y: -1 }
+
+ rooms.forEach((room) => {
+ room.tiles.forEach((tile) => {
+ if (tile.positionX < min.x) {
+ min.x = tile.positionX
+ }
+ if (tile.positionY < min.y) {
+ min.y = tile.positionY
+ }
+
+ if (tile.positionX > max.x) {
+ max.x = tile.positionX
+ }
+ if (tile.positionY > max.y) {
+ max.y = tile.positionY
+ }
+ })
+ })
+
+ max.x++
+ max.y++
+
+ const center = {
+ x: min.x + (max.x - min.x) / 2.0,
+ y: min.y + (max.y - min.y) / 2.0,
+ }
+
+ return { min, center, max }
+}
diff --git a/opendc-web/opendc-web-ui/src/util/timeline.js b/opendc-web/opendc-web-ui/src/util/timeline.js
new file mode 100644
index 00000000..7c8a3ef0
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/timeline.js
@@ -0,0 +1,9 @@
+export function convertTickToPercentage(tick, maxTick) {
+ if (maxTick === 0) {
+ return '0%'
+ } else if (tick > maxTick) {
+ return (maxTick / (maxTick + 1)) * 100 + '%'
+ }
+
+ return (tick / (maxTick + 1)) * 100 + '%'
+}
diff --git a/opendc-web/opendc-web-ui/src/util/unit-specifications.js b/opendc-web/opendc-web-ui/src/util/unit-specifications.js
new file mode 100644
index 00000000..28479edd
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/util/unit-specifications.js
@@ -0,0 +1,102 @@
+export const CPU_UNITS = {
+ 'cpu-1': {
+ _id: 'cpu-1',
+ name: 'Intel i7 v6 6700k',
+ clockRateMhz: 4100,
+ numberOfCores: 4,
+ energyConsumptionW: 70,
+ },
+ 'cpu-2': {
+ _id: 'cpu-2',
+ name: 'Intel i5 v6 6700k',
+ clockRateMhz: 3500,
+ numberOfCores: 2,
+ energyConsumptionW: 50,
+ },
+ 'cpu-3': {
+ _id: 'cpu-3',
+ name: 'Intel® Xeon® E-2224G',
+ clockRateMhz: 3500,
+ numberOfCores: 4,
+ energyConsumptionW: 71,
+ },
+ 'cpu-4': {
+ _id: 'cpu-4',
+ name: 'Intel® Xeon® E-2244G',
+ clockRateMhz: 3800,
+ numberOfCores: 8,
+ energyConsumptionW: 71,
+ },
+ 'cpu-5': {
+ _id: 'cpu-5',
+ name: 'Intel® Xeon® E-2246G',
+ clockRateMhz: 3600,
+ numberOfCores: 12,
+ energyConsumptionW: 80,
+ },
+}
+
+export const GPU_UNITS = {
+ 'gpu-1': {
+ _id: 'gpu-1',
+ name: 'NVIDIA GTX 4 1080',
+ clockRateMhz: 1200,
+ numberOfCores: 200,
+ energyConsumptionW: 250,
+ },
+ 'gpu-2': {
+ _id: 'gpu-2',
+ name: 'NVIDIA Tesla V100',
+ clockRateMhz: 1200,
+ numberOfCores: 5120,
+ energyConsumptionW: 250,
+ },
+}
+
+export const MEMORY_UNITS = {
+ 'memory-1': {
+ _id: 'memory-1',
+ name: 'Samsung PC DRAM K4A4G045WD',
+ speedMbPerS: 16000,
+ sizeMb: 4000,
+ energyConsumptionW: 10,
+ },
+ 'memory-2': {
+ _id: 'memory-2',
+ name: 'Samsung PC DRAM M393A2K43BB1-CRC',
+ speedMbPerS: 2400,
+ sizeMb: 16000,
+ energyConsumptionW: 10,
+ },
+ 'memory-3': {
+ _id: 'memory-3',
+ name: 'Crucial MTA18ASF4G72PDZ-3G2E1',
+ speedMbPerS: 3200,
+ sizeMb: 32000,
+ energyConsumptionW: 10,
+ },
+ 'memory-4': {
+ _id: 'memory-4',
+ name: 'Crucial MTA9ASF2G72PZ-3G2E1',
+ speedMbPerS: 3200,
+ sizeMb: 16000,
+ energyConsumptionW: 10,
+ },
+}
+
+export const STORAGE_UNITS = {
+ 'storage-1': {
+ _id: 'storage-1',
+ name: 'Samsung EVO 2016 SATA III',
+ speedMbPerS: 6000,
+ sizeMb: 250000,
+ energyConsumptionW: 10,
+ },
+ 'storage-2': {
+ _id: 'storage-2',
+ name: 'Western Digital MTA9ASF2G72PZ-3G2E1',
+ speedMbPerS: 6000,
+ sizeMb: 4000000,
+ energyConsumptionW: 10,
+ },
+}