summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/.editorconfig15
-rw-r--r--frontend/.gitignore28
-rw-r--r--frontend/.travis.yml9
-rw-r--r--frontend/CONTRIBUTING.md28
-rw-r--r--frontend/LICENSE.md21
-rw-r--r--frontend/README.md102
-rw-r--r--frontend/package.json77
-rw-r--r--frontend/public/favicon.icobin0 -> 99678 bytes
-rw-r--r--frontend/public/humans.txt31
-rw-r--r--frontend/public/img/datacenter-drawing.pngbin0 -> 219576 bytes
-rw-r--r--frontend/public/img/logo.pngbin0 -> 2825 bytes
-rw-r--r--frontend/public/img/portraits/aiosup.pngbin0 -> 111629 bytes
-rw-r--r--frontend/public/img/portraits/fmastenbroek.pngbin0 -> 135589 bytes
-rw-r--r--frontend/public/img/portraits/gandreadis.pngbin0 -> 118477 bytes
-rw-r--r--frontend/public/img/portraits/loverweel.pngbin0 -> 107768 bytes
-rw-r--r--frontend/public/img/stakeholders/Developer.pngbin0 -> 11411 bytes
-rw-r--r--frontend/public/img/stakeholders/Manager.pngbin0 -> 9946 bytes
-rw-r--r--frontend/public/img/stakeholders/Researcher.pngbin0 -> 10984 bytes
-rw-r--r--frontend/public/img/stakeholders/Sales.pngbin0 -> 10074 bytes
-rw-r--r--frontend/public/img/stakeholders/Student.pngbin0 -> 12960 bytes
-rw-r--r--frontend/public/img/topology/cpu-icon.pngbin0 -> 4062 bytes
-rw-r--r--frontend/public/img/topology/gpu-icon.pngbin0 -> 2227 bytes
-rw-r--r--frontend/public/img/topology/memory-icon.pngbin0 -> 1980 bytes
-rw-r--r--frontend/public/img/topology/rack-energy-icon.pngbin0 -> 893 bytes
-rw-r--r--frontend/public/img/topology/rack-space-icon.pngbin0 -> 957 bytes
-rw-r--r--frontend/public/img/topology/storage-icon.pngbin0 -> 4038 bytes
-rw-r--r--frontend/public/img/tudelft-icon.pngbin0 -> 4387 bytes
-rw-r--r--frontend/public/index.html70
-rw-r--r--frontend/public/manifest.json15
-rw-r--r--frontend/public/robots.txt3
-rw-r--r--frontend/src/actions/auth.js23
-rw-r--r--frontend/src/actions/experiments.js34
-rw-r--r--frontend/src/actions/interaction-level.js50
-rw-r--r--frontend/src/actions/map.js93
-rw-r--r--frontend/src/actions/modals/experiments.js14
-rw-r--r--frontend/src/actions/modals/profile.js14
-rw-r--r--frontend/src/actions/modals/simulations.js14
-rw-r--r--frontend/src/actions/modals/topology.js70
-rw-r--r--frontend/src/actions/objects.js48
-rw-r--r--frontend/src/actions/profile.js0
-rw-r--r--frontend/src/actions/simulation/load-metric.js8
-rw-r--r--frontend/src/actions/simulation/playback.js15
-rw-r--r--frontend/src/actions/simulation/tick.js49
-rw-r--r--frontend/src/actions/simulations.js52
-rw-r--r--frontend/src/actions/states.js9
-rw-r--r--frontend/src/actions/topology/building.js117
-rw-r--r--frontend/src/actions/topology/machine.js25
-rw-r--r--frontend/src/actions/topology/rack.js23
-rw-r--r--frontend/src/actions/topology/room.js48
-rw-r--r--frontend/src/actions/users.js41
-rw-r--r--frontend/src/api/index.js13
-rw-r--r--frontend/src/api/routes/datacenters.js26
-rw-r--r--frontend/src/api/routes/experiments.js33
-rw-r--r--frontend/src/api/routes/jobs.js5
-rw-r--r--frontend/src/api/routes/paths.js30
-rw-r--r--frontend/src/api/routes/room-types.js9
-rw-r--r--frontend/src/api/routes/rooms.js46
-rw-r--r--frontend/src/api/routes/schedulers.js5
-rw-r--r--frontend/src/api/routes/sections.js5
-rw-r--r--frontend/src/api/routes/simulations.js70
-rw-r--r--frontend/src/api/routes/specifications.js57
-rw-r--r--frontend/src/api/routes/tiles.js146
-rw-r--r--frontend/src/api/routes/token-signin.js7
-rw-r--r--frontend/src/api/routes/traces.js9
-rw-r--r--frontend/src/api/routes/users.js71
-rw-r--r--frontend/src/api/routes/util.js37
-rw-r--r--frontend/src/api/socket.js52
-rw-r--r--frontend/src/auth/index.js57
-rw-r--r--frontend/src/components/app/map/LoadingScreen.js11
-rw-r--r--frontend/src/components/app/map/MapConstants.js29
-rw-r--r--frontend/src/components/app/map/MapStageComponent.js126
-rw-r--r--frontend/src/components/app/map/controls/ExportCanvasComponent.js13
-rw-r--r--frontend/src/components/app/map/controls/ScaleIndicatorComponent.js14
-rw-r--r--frontend/src/components/app/map/controls/ScaleIndicatorComponent.sass9
-rw-r--r--frontend/src/components/app/map/controls/ToolPanelComponent.js13
-rw-r--r--frontend/src/components/app/map/controls/ToolPanelComponent.sass5
-rw-r--r--frontend/src/components/app/map/controls/ZoomControlComponent.js24
-rw-r--r--frontend/src/components/app/map/elements/Backdrop.js16
-rw-r--r--frontend/src/components/app/map/elements/GrayLayer.js17
-rw-r--r--frontend/src/components/app/map/elements/HoverTile.js30
-rw-r--r--frontend/src/components/app/map/elements/ImageComponent.js48
-rw-r--r--frontend/src/components/app/map/elements/RackFillBar.js89
-rw-r--r--frontend/src/components/app/map/elements/RoomTile.js20
-rw-r--r--frontend/src/components/app/map/elements/TileObject.js29
-rw-r--r--frontend/src/components/app/map/elements/TilePlusIcon.js52
-rw-r--r--frontend/src/components/app/map/elements/WallSegment.js39
-rw-r--r--frontend/src/components/app/map/groups/DatacenterGroup.js40
-rw-r--r--frontend/src/components/app/map/groups/GridGroup.js41
-rw-r--r--frontend/src/components/app/map/groups/RackGroup.js43
-rw-r--r--frontend/src/components/app/map/groups/RoomGroup.js56
-rw-r--r--frontend/src/components/app/map/groups/TileGroup.js43
-rw-r--r--frontend/src/components/app/map/groups/WallGroup.js22
-rw-r--r--frontend/src/components/app/map/layers/HoverLayerComponent.js85
-rw-r--r--frontend/src/components/app/map/layers/MapLayerComponent.js22
-rw-r--r--frontend/src/components/app/map/layers/ObjectHoverLayerComponent.js11
-rw-r--r--frontend/src/components/app/map/layers/RoomHoverLayerComponent.js6
-rw-r--r--frontend/src/components/app/sidebars/Sidebar.js50
-rw-r--r--frontend/src/components/app/sidebars/Sidebar.sass50
-rw-r--r--frontend/src/components/app/sidebars/elements/LoadBarComponent.js22
-rw-r--r--frontend/src/components/app/sidebars/elements/LoadChartComponent.js90
-rw-r--r--frontend/src/components/app/sidebars/simulation/ExperimentMetadataComponent.js23
-rw-r--r--frontend/src/components/app/sidebars/simulation/LoadMetricComponent.js40
-rw-r--r--frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.js22
-rw-r--r--frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.sass8
-rw-r--r--frontend/src/components/app/sidebars/simulation/TaskComponent.js58
-rw-r--r--frontend/src/components/app/sidebars/simulation/TraceComponent.js20
-rw-r--r--frontend/src/components/app/sidebars/topology/NameComponent.js13
-rw-r--r--frontend/src/components/app/sidebars/topology/TopologySidebarComponent.js31
-rw-r--r--frontend/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js20
-rw-r--r--frontend/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js31
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/BackToRackComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/MachineNameComponent.js7
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js27
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitAddComponent.js46
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitComponent.js78
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitListComponent.js29
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitTabsComponent.js65
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/BackToRoomComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/DeleteRackComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/EmptySlotComponent.js19
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/MachineComponent.js78
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/MachineListComponent.js26
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/MachineListComponent.sass2
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/RackNameComponent.js8
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.js34
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass11
-rw-r--r--frontend/src/components/app/sidebars/topology/room/BackToBuildingComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/room/DeleteRoomComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/room/EditRoomComponent.js27
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RackConstructionComponent.js32
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RoomNameComponent.js8
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RoomSidebarComponent.js38
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RoomTypeComponent.js8
-rw-r--r--frontend/src/components/app/timeline/PlayButtonComponent.js30
-rw-r--r--frontend/src/components/app/timeline/Timeline.sass116
-rw-r--r--frontend/src/components/app/timeline/TimelineComponent.js37
-rw-r--r--frontend/src/components/app/timeline/TimelineControlsComponent.js49
-rw-r--r--frontend/src/components/app/timeline/TimelineLabelsComponent.js15
-rw-r--r--frontend/src/components/experiments/ExperimentListComponent.js59
-rw-r--r--frontend/src/components/experiments/ExperimentRowComponent.js40
-rw-r--r--frontend/src/components/experiments/NewExperimentButtonComponent.js17
-rw-r--r--frontend/src/components/home/ContactSection.js64
-rw-r--r--frontend/src/components/home/ContactSection.sass15
-rw-r--r--frontend/src/components/home/ContentSection.js19
-rw-r--r--frontend/src/components/home/ContentSection.sass9
-rw-r--r--frontend/src/components/home/IntroSection.js40
-rw-r--r--frontend/src/components/home/JumbotronHeader.js20
-rw-r--r--frontend/src/components/home/JumbotronHeader.sass24
-rw-r--r--frontend/src/components/home/ModelingSection.js24
-rw-r--r--frontend/src/components/home/ScreenshotSection.js32
-rw-r--r--frontend/src/components/home/ScreenshotSection.sass5
-rw-r--r--frontend/src/components/home/SimulationSection.js25
-rw-r--r--frontend/src/components/home/StakeholderSection.js42
-rw-r--r--frontend/src/components/home/TeamSection.js56
-rw-r--r--frontend/src/components/home/TechnologiesSection.js42
-rw-r--r--frontend/src/components/modals/ConfirmationModal.js37
-rw-r--r--frontend/src/components/modals/Modal.js132
-rw-r--r--frontend/src/components/modals/TextInputModal.js58
-rw-r--r--frontend/src/components/modals/custom-components/NewExperimentModalComponent.js104
-rw-r--r--frontend/src/components/navigation/AppNavbar.js56
-rw-r--r--frontend/src/components/navigation/HomeNavbar.js24
-rw-r--r--frontend/src/components/navigation/LogoutButton.js16
-rw-r--r--frontend/src/components/navigation/Navbar.js102
-rw-r--r--frontend/src/components/navigation/Navbar.sass29
-rw-r--r--frontend/src/components/not-found/BlinkingCursor.js6
-rw-r--r--frontend/src/components/not-found/BlinkingCursor.sass35
-rw-r--r--frontend/src/components/not-found/CodeBlock.js34
-rw-r--r--frontend/src/components/not-found/CodeBlock.sass3
-rw-r--r--frontend/src/components/not-found/TerminalWindow.js29
-rw-r--r--frontend/src/components/not-found/TerminalWindow.sass70
-rw-r--r--frontend/src/components/simulations/FilterButton.js24
-rw-r--r--frontend/src/components/simulations/FilterPanel.js13
-rw-r--r--frontend/src/components/simulations/FilterPanel.sass5
-rw-r--r--frontend/src/components/simulations/NewSimulationButtonComponent.js17
-rw-r--r--frontend/src/components/simulations/SimulationActionButtons.js37
-rw-r--r--frontend/src/components/simulations/SimulationAuthList.js43
-rw-r--r--frontend/src/components/simulations/SimulationAuthRow.js32
-rw-r--r--frontend/src/containers/app/map/DatacenterContainer.js17
-rw-r--r--frontend/src/containers/app/map/GrayContainer.js13
-rw-r--r--frontend/src/containers/app/map/MapStage.js31
-rw-r--r--frontend/src/containers/app/map/RackContainer.js30
-rw-r--r--frontend/src/containers/app/map/RackEnergyFillContainer.js40
-rw-r--r--frontend/src/containers/app/map/RackSpaceFillContainer.js16
-rw-r--r--frontend/src/containers/app/map/RoomContainer.js21
-rw-r--r--frontend/src/containers/app/map/TileContainer.js43
-rw-r--r--frontend/src/containers/app/map/WallContainer.js14
-rw-r--r--frontend/src/containers/app/map/controls/ScaleIndicatorContainer.js14
-rw-r--r--frontend/src/containers/app/map/controls/ZoomControlContainer.js21
-rw-r--r--frontend/src/containers/app/map/layers/MapLayer.js13
-rw-r--r--frontend/src/containers/app/map/layers/ObjectHoverLayer.js37
-rw-r--r--frontend/src/containers/app/map/layers/RoomHoverLayer.js55
-rw-r--r--frontend/src/containers/app/sidebars/elements/LoadBarContainer.js32
-rw-r--r--frontend/src/containers/app/sidebars/elements/LoadChartContainer.js31
-rw-r--r--frontend/src/containers/app/sidebars/simulation/ExperimentMetadataContainer.js38
-rw-r--r--frontend/src/containers/app/sidebars/simulation/LoadMetricContainer.js12
-rw-r--r--frontend/src/containers/app/sidebars/simulation/TaskContainer.js26
-rw-r--r--frontend/src/containers/app/sidebars/simulation/TraceContainer.js25
-rw-r--r--frontend/src/containers/app/sidebars/topology/TopologySidebar.js12
-rw-r--r--frontend/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js14
-rw-r--r--frontend/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js27
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/BackToRackContainer.js15
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js15
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/MachineNameContainer.js12
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js18
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/UnitAddContainer.js21
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/UnitContainer.js22
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/UnitListContainer.js18
-rw-r--r--frontend/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js12
-rw-r--r--frontend/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js15
-rw-r--r--frontend/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js15
-rw-r--r--frontend/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js21
-rw-r--r--frontend/src/containers/app/sidebars/topology/rack/MachineContainer.js40
-rw-r--r--frontend/src/containers/app/sidebars/topology/rack/MachineListContainer.js15
-rw-r--r--frontend/src/containers/app/sidebars/topology/rack/RackNameContainer.js24
-rw-r--r--frontend/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js13
-rw-r--r--frontend/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js15
-rw-r--r--frontend/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js15
-rw-r--r--frontend/src/containers/app/sidebars/topology/room/EditRoomContainer.js26
-rw-r--r--frontend/src/containers/app/sidebars/topology/room/RackConstructionContainer.js26
-rw-r--r--frontend/src/containers/app/sidebars/topology/room/RoomNameContainer.js21
-rw-r--r--frontend/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js14
-rw-r--r--frontend/src/containers/app/sidebars/topology/room/RoomTypeContainer.js12
-rw-r--r--frontend/src/containers/app/timeline/PlayButtonContainer.js27
-rw-r--r--frontend/src/containers/app/timeline/TimelineContainer.js41
-rw-r--r--frontend/src/containers/app/timeline/TimelineControlsContainer.js36
-rw-r--r--frontend/src/containers/app/timeline/TimelineLabelsContainer.js15
-rw-r--r--frontend/src/containers/auth/Login.js65
-rw-r--r--frontend/src/containers/auth/Logout.js13
-rw-r--r--frontend/src/containers/auth/ProfileName.js14
-rw-r--r--frontend/src/containers/experiments/ExperimentListContainer.js28
-rw-r--r--frontend/src/containers/experiments/ExperimentRowContainer.js30
-rw-r--r--frontend/src/containers/experiments/NewExperimentButtonContainer.js15
-rw-r--r--frontend/src/containers/modals/DeleteMachineModal.js37
-rw-r--r--frontend/src/containers/modals/DeleteProfileModal.js37
-rw-r--r--frontend/src/containers/modals/DeleteRackModal.js37
-rw-r--r--frontend/src/containers/modals/DeleteRoomModal.js37
-rw-r--r--frontend/src/containers/modals/EditRackNameModal.js44
-rw-r--r--frontend/src/containers/modals/EditRoomNameModal.js42
-rw-r--r--frontend/src/containers/modals/NewExperimentModal.js39
-rw-r--r--frontend/src/containers/modals/NewSimulationModal.js37
-rw-r--r--frontend/src/containers/simulations/FilterLink.js19
-rw-r--r--frontend/src/containers/simulations/NewSimulationButtonContainer.js15
-rw-r--r--frontend/src/containers/simulations/SimulationActions.js22
-rw-r--r--frontend/src/containers/simulations/VisibleSimulationAuthList.js42
-rw-r--r--frontend/src/index.js21
-rw-r--r--frontend/src/index.sass39
-rw-r--r--frontend/src/pages/App.js125
-rw-r--r--frontend/src/pages/Experiments.js75
-rw-r--r--frontend/src/pages/Home.js62
-rw-r--r--frontend/src/pages/Home.sass9
-rw-r--r--frontend/src/pages/NotFound.js14
-rw-r--r--frontend/src/pages/NotFound.sass11
-rw-r--r--frontend/src/pages/Profile.js40
-rw-r--r--frontend/src/pages/Simulations.js46
-rw-r--r--frontend/src/reducers/auth.js12
-rw-r--r--frontend/src/reducers/construction-mode.js50
-rw-r--r--frontend/src/reducers/current-ids.js28
-rw-r--r--frontend/src/reducers/index.js37
-rw-r--r--frontend/src/reducers/interaction-level.js59
-rw-r--r--frontend/src/reducers/map.js39
-rw-r--r--frontend/src/reducers/modals.js75
-rw-r--r--frontend/src/reducers/objects.js80
-rw-r--r--frontend/src/reducers/simulation-list.js34
-rw-r--r--frontend/src/reducers/simulation-mode.js61
-rw-r--r--frontend/src/reducers/states.js33
-rw-r--r--frontend/src/registerServiceWorker.js108
-rw-r--r--frontend/src/routes/index.js64
-rw-r--r--frontend/src/sagas/experiments.js183
-rw-r--r--frontend/src/sagas/index.js106
-rw-r--r--frontend/src/sagas/objects.js140
-rw-r--r--frontend/src/sagas/profile.js12
-rw-r--r--frontend/src/sagas/simulations.js51
-rw-r--r--frontend/src/sagas/topology.js434
-rw-r--r--frontend/src/sagas/users.js50
-rw-r--r--frontend/src/shapes/index.js188
-rw-r--r--frontend/src/shortcuts/keymap.js10
-rw-r--r--frontend/src/store/configure-store.js41
-rw-r--r--frontend/src/store/middlewares/dummy-middleware.js3
-rw-r--r--frontend/src/store/middlewares/viewport-adjustment.js90
-rw-r--r--frontend/src/style-globals/_mixins.sass21
-rw-r--r--frontend/src/style-globals/_variables.sass31
-rw-r--r--frontend/src/util/authorizations.js11
-rw-r--r--frontend/src/util/colors.js29
-rw-r--r--frontend/src/util/date-time.js104
-rw-r--r--frontend/src/util/date-time.test.js35
-rw-r--r--frontend/src/util/jquery.js8
-rw-r--r--frontend/src/util/room-types.js7
-rw-r--r--frontend/src/util/simulation-load.js37
-rw-r--r--frontend/src/util/tile-calculations.js261
-rw-r--r--frontend/src/util/timeline.js19
291 files changed, 10216 insertions, 0 deletions
diff --git a/frontend/.editorconfig b/frontend/.editorconfig
new file mode 100644
index 00000000..823e6853
--- /dev/null
+++ b/frontend/.editorconfig
@@ -0,0 +1,15 @@
+# EditorConfig helps developers define and maintain consistent
+# coding styles between different editors and IDEs
+# editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 00000000..415295d9
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,28 @@
+# Dependencies
+/node_modules
+
+# Testing
+/coverage
+
+# Production
+/build
+
+# Misc.
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# IntelliJ IDEA
+/.idea
+
+# Environment variables
+.env
+
+# Sass output
+*.css
diff --git a/frontend/.travis.yml b/frontend/.travis.yml
new file mode 100644
index 00000000..c3554fe4
--- /dev/null
+++ b/frontend/.travis.yml
@@ -0,0 +1,9 @@
+language: node_js
+node_js:
+ - 10
+cache:
+ directories:
+ - node_modules
+script:
+ - npm run build
+ - npm test
diff --git a/frontend/CONTRIBUTING.md b/frontend/CONTRIBUTING.md
new file mode 100644
index 00000000..152ab5aa
--- /dev/null
+++ b/frontend/CONTRIBUTING.md
@@ -0,0 +1,28 @@
+# Contributing to the OpenDC Frontend
+
+First of all, thanks for wanting to contribute! 🎉
+
+
+## 💬 Have a question or general feedback relating to the OpenDC Frontend?
+
+Contact us at 📧[opendc@atlarge-research.com](mailto:opendc@atlarge-research.com)!
+
+
+## 🐞 Want to report a bug or suggest a feature?
+
+Encountered what you deem to be undesirable behavior? Have an idea for a feature that could be added? Please go to our [GitHub issues page](https://github.com/atlarge-research/opendc-frontend/issues) and have a look if there already is an issue addressing your concern.
+
+If there already is an issue, feel free to comment on the issue to show your support for it, or to add additional information that might be helpful. You can also just react with a thumbs-up 👍 to the issue or feature, to indicate that you'd be interested in its resolution. This can help us prioritize what we spend our development time on.
+
+If you can't find an issue that fits your problem or feature request, [open a new one](https://github.com/atlarge-research/opendc-frontend/issues/new). Describe actual and expected behavior, and be as detailed as you can. We'll get back to you asap.
+
+
+## 💻 Want to contribute code?
+
+Great! [Fork this repo](https://github.com/atlarge-research/opendc-frontend/new/master) and submit a PR here when you're ready! Be sure to describe *what* you changed and *why* you changed it, to help us understand what your contribution is about.
+
+* A couple of notes on the code itself:
+ * Before you make changes to the codebase, have a look at the rest of the codebase (especially parts that are similar to what you want to achieve). Do you understand what they do? If not, feel free to [contact us](mailto:opendc@atlarge-research.com).
+ * Try to write clean, concise, self-documenting code.
+ * Don't worry too much about the formatting of your code: our `prettier` pre-commit hook takes care of formatting your code for you, automatically.
+* A quick note on commit messages: Please follow common Git standards when writing commit messages, see [this post](https://chris.beams.io/posts/git-commit/) for details.
diff --git a/frontend/LICENSE.md b/frontend/LICENSE.md
new file mode 100644
index 00000000..57288ae2
--- /dev/null
+++ b/frontend/LICENSE.md
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 atlarge-research
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 00000000..44858b69
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,102 @@
+<h1 align="center">
+ <img src="public/img/logo.png" width="100" alt="OpenDC">
+ <br>
+ OpenDC Frontend
+</h1>
+<p align="center">
+ Collaborative Datacenter Simulation and Exploration for Everybody
+</p>
+
+<p align="center">
+ <a href="https://travis-ci.org/atlarge-research/opendc-frontend"><img src="https://travis-ci.org/atlarge-research/opendc-frontend.svg?branch=master" alt="Build Status"></a>
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
+ <a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/styled_with-prettier-ff69b4.svg" alt="styled with prettier"></a><br/>
+</p>
+
+The user-facing component of the OpenDC stack, allowing users to build and interact with their own (virtual) datacenters. Built in *React.js* and *Redux*, with the help of `create-react-app`.
+
+
+## Get Up and Running
+
+Looking for the full OpenDC stack? Check out [the main OpenDC repo](https://github.com/atlarge-research/opendc) for instructions on how to set up a Docker container with all of OpenDC, without the hassle of running each of the components manually.
+
+### Installation
+
+To get started, you'll need the [Node.js environment](https://nodejs.org) and the [Yarn package manager](https://yarnpkg.com). Once you have those installed, run the following command from the root directory of this repo:
+
+```bash
+yarn
+```
+
+### Running the development server
+
+First, you need to have a Google OAuth client ID set up. Check the [documentation of the main OpenDC repo](https://github.com/atlarge-research/opendc) if you're not sure how to do this. Once you have such an ID, you need to set it as environment variable `REACT_APP_OAUTH_CLIENT_ID`. One way of doing this is to create an `.env` file with content `REACT_APP_OAUTH_CLIENT_ID=YOUR_ID` (`YOUR_ID` without quotes), in the root directory of this repo.
+
+Once you've set this variable, you're ready to start the development server:
+
+```bash
+yarn start
+```
+
+This will start a development server running on [`localhost:3000`](http://localhost:3000), watching for changes you make to the code and rebuilding automatically when you save changes.
+
+To compile everything for camera-ready deployment, use the following command:
+
+```bash
+yarn build
+```
+
+**Note:** Perhaps this goes without saying, but for any functionality beyond visiting the entry page, a server backend running in the background is necessary. The easiest way to do this is to have an OpenDC docker container running, see [the main repo](https://github.com/atlarge-research/opendc) for more information on how to do this.
+
+
+## Architecture
+
+The codebase follows a standard React.js structure, with static assets being contained in the `public` folder, while dynamic components and their styles are contained in `src`. The app uses client-side routing (with `react-router`), meaning that the only HTML file needed to be served is a `index.html` file.
+
+### Pages
+
+All pages are represented by a component in the `src/pages` directory. There are components for the following pages:
+
+**Home.js** - Entry page (`/`)
+
+**Simulations.js** - Overview of simulations of the user (`/simulations`)
+
+**App.js** - Main application, with datacenter construction and simulation UI (`/simulations/:simulationId` and `/simulations/:simulationId/experiments/:experimentId`)
+
+**Experiments.js** - Overview of experiments of the current simulation (`/simulations/:simulationId/experiments`)
+
+**Profile.js** - Profile of the current user (`/profile`)
+
+**NotFound.js** - 404 page to appear when the route is invalid (`/*`)
+
+### Components & Containers
+
+The building blocks of the UI are divided into so-called *components* and *containers* ([as encouraged](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0) by the author of Redux). *Components* are considered 'pure', rendered as a function of input properties. *Containers*, on the other hand, are wrappers around *components*, injecting state through the properties of the components they wrap.
+
+Even the canvas (the main component of the app) is built using React components, with the help of the `react-konva` module. To illustrate: A rectangular object on the canvas is defined in a way that is not very different from how we define a standard `div` element on the splashpage.
+
+### State Management
+
+Almost all state is kept in a central Redux store. State is kept there in an immutable form, only to be modified through actions being dispatched. These actions are contained in the `src/actions` folder, and the reducers (managing how state is updated according to dispatched actions) are located in `src/reducers`. If you're not familiar with the Redux approach to state management, have a look at their [official documentation](http://redux.js.org/).
+
+### API Interaction
+
+The web-app needs to pull data in from the API of a backend running on a server. The functions that call routes are located in `src/api`. The actual logic responsible for calling these functions is contained in `src/sagas`. These API fetch procedures are written with the help of `redux-saga`. The [official documentation](https://redux-saga.js.org/) of `redux-saga` can be a helpful aid in understanding that part of the codebase.
+
+
+## Tests
+
+Files containing tests can be recognized by the `.test.js` suffix. They are usually located right next to the source code they are testing, to make discovery easier.
+
+### Running all tests
+
+The following command runs all tests in the codebase. On top of this, it also watches the code for changes and reruns the tests whenever any file is saved.
+
+```bash
+yarn test
+```
+
+
+## License
+
+The code is released under the MIT license. See `LICENSE.md`.
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 00000000..692ca8ad
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "opendc-frontend",
+ "version": "0.1.0",
+ "description": "The user-facing component of the OpenDC stack, allowing users to build and interact with their own (virtual) datacenters.",
+ "keywords": [
+ "opendc",
+ "simulation",
+ "datacenter",
+ "frontend"
+ ],
+ "homepage": "http://opendc.org",
+ "bugs": {
+ "url": "https://github.com/atlarge-research/opendc-frontend/issues",
+ "email": "opendc@atlarge-research.com"
+ },
+ "author": "Georgios Andreadis <g.andreadis@atlarge-research.com> (http://gandreadis.com/)",
+ "license": "MIT",
+ "private": true,
+ "proxy": "http://localhost:8081",
+ "dependencies": {
+ "approximate-number": "~2.0.0",
+ "classnames": "~2.2.5",
+ "husky": "~4.2.5",
+ "konva": "~6.0.0",
+ "lint-staged": "~10.2.2",
+ "node-sass-chokidar": "~1.4.0",
+ "npm-run-all": "~4.1.2",
+ "prettier": "~2.0.5",
+ "prop-types": "~15.7.2",
+ "react": "~16.13.1",
+ "react-document-title": "~2.0.3",
+ "react-dom": "~16.13.1",
+ "react-fontawesome": "~1.7.1",
+ "react-google-login": "~5.1.14",
+ "react-konva": "~16.13.0-2",
+ "react-redux": "~7.2.0",
+ "react-router-dom": "~5.1.2",
+ "react-scripts": "~3.4.1",
+ "react-shortcuts": "~2.1.0",
+ "redux": "~4.0.5",
+ "redux-localstorage": "~0.4.1",
+ "redux-logger": "~3.0.6",
+ "redux-saga": "~1.1.3",
+ "redux-thunk": "~2.3.0",
+ "socket.io-client": "~2.3.0",
+ "svgsaver": "~0.9.0",
+ "victory": "~34.2.1"
+ },
+ "lint-staged": {
+ "src/**/*.{js,jsx,json}": [
+ "prettier --write",
+ "git add"
+ ]
+ },
+ "scripts": {
+ "precommit": "lint-staged",
+ "build-css": "node-sass-chokidar src/ -o src/",
+ "watch-css": "npm run build-css && node-sass-chokidar src/ -o src/ --watch --recursive",
+ "start-js": "react-scripts start",
+ "start": "npm-run-all -p watch-css start-js",
+ "build": "npm run build-css && react-scripts build",
+ "test": "react-scripts test --env=jsdom",
+ "eject": "react-scripts eject"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico
new file mode 100644
index 00000000..c2f40a0d
--- /dev/null
+++ b/frontend/public/favicon.ico
Binary files differ
diff --git a/frontend/public/humans.txt b/frontend/public/humans.txt
new file mode 100644
index 00000000..d037fcfd
--- /dev/null
+++ b/frontend/public/humans.txt
@@ -0,0 +1,31 @@
+/* TEAM */
+Benevolent Dictator for Life: Alexandru Iosup.
+Site: http://www.ds.ewi.tudelft.nl/~iosup/
+Twitter: aiosup.
+Location: Delft, Netherlands.
+
+Backend Engineer: Leon Overweel.
+Site: http://leonoverweel.com/
+Twitter: layon_overwhale.
+Location: Delft, Netherlands.
+
+Frontend Engineer: Georgios Andreadis.
+Site: https://github.com/gandreadis
+Location: Delft, Netherlands.
+
+Simulation Engineer: Fabian Mastenbroek.
+Site: https://github.com/fabianishere
+Location: Delft, Netherlands.
+
+Simulation Engineer: Matthijs Bijman.
+Site: https://github.com/MDBijman
+Location: Delft, Netherlands.
+
+/* THANKS */
+Executive Producer: Vincent van Beek.
+Executive Producer: Tim Hegeman.
+
+/* SITE */
+Standards: HTML5, Sass, ES6
+Components: React.js, Redux, create-react-app, react-konva
+Software: WebStorm, Vim, Visual Studio
diff --git a/frontend/public/img/datacenter-drawing.png b/frontend/public/img/datacenter-drawing.png
new file mode 100644
index 00000000..401168e3
--- /dev/null
+++ b/frontend/public/img/datacenter-drawing.png
Binary files differ
diff --git a/frontend/public/img/logo.png b/frontend/public/img/logo.png
new file mode 100644
index 00000000..d743038b
--- /dev/null
+++ b/frontend/public/img/logo.png
Binary files differ
diff --git a/frontend/public/img/portraits/aiosup.png b/frontend/public/img/portraits/aiosup.png
new file mode 100644
index 00000000..30de349c
--- /dev/null
+++ b/frontend/public/img/portraits/aiosup.png
Binary files differ
diff --git a/frontend/public/img/portraits/fmastenbroek.png b/frontend/public/img/portraits/fmastenbroek.png
new file mode 100644
index 00000000..fd0d9de1
--- /dev/null
+++ b/frontend/public/img/portraits/fmastenbroek.png
Binary files differ
diff --git a/frontend/public/img/portraits/gandreadis.png b/frontend/public/img/portraits/gandreadis.png
new file mode 100644
index 00000000..403870fa
--- /dev/null
+++ b/frontend/public/img/portraits/gandreadis.png
Binary files differ
diff --git a/frontend/public/img/portraits/loverweel.png b/frontend/public/img/portraits/loverweel.png
new file mode 100644
index 00000000..d12a9e86
--- /dev/null
+++ b/frontend/public/img/portraits/loverweel.png
Binary files differ
diff --git a/frontend/public/img/stakeholders/Developer.png b/frontend/public/img/stakeholders/Developer.png
new file mode 100644
index 00000000..d2638e6c
--- /dev/null
+++ b/frontend/public/img/stakeholders/Developer.png
Binary files differ
diff --git a/frontend/public/img/stakeholders/Manager.png b/frontend/public/img/stakeholders/Manager.png
new file mode 100644
index 00000000..92db7459
--- /dev/null
+++ b/frontend/public/img/stakeholders/Manager.png
Binary files differ
diff --git a/frontend/public/img/stakeholders/Researcher.png b/frontend/public/img/stakeholders/Researcher.png
new file mode 100644
index 00000000..d87edd39
--- /dev/null
+++ b/frontend/public/img/stakeholders/Researcher.png
Binary files differ
diff --git a/frontend/public/img/stakeholders/Sales.png b/frontend/public/img/stakeholders/Sales.png
new file mode 100644
index 00000000..5b7c3a72
--- /dev/null
+++ b/frontend/public/img/stakeholders/Sales.png
Binary files differ
diff --git a/frontend/public/img/stakeholders/Student.png b/frontend/public/img/stakeholders/Student.png
new file mode 100644
index 00000000..a4900303
--- /dev/null
+++ b/frontend/public/img/stakeholders/Student.png
Binary files differ
diff --git a/frontend/public/img/topology/cpu-icon.png b/frontend/public/img/topology/cpu-icon.png
new file mode 100644
index 00000000..07cfbd31
--- /dev/null
+++ b/frontend/public/img/topology/cpu-icon.png
Binary files differ
diff --git a/frontend/public/img/topology/gpu-icon.png b/frontend/public/img/topology/gpu-icon.png
new file mode 100644
index 00000000..55d4fb05
--- /dev/null
+++ b/frontend/public/img/topology/gpu-icon.png
Binary files differ
diff --git a/frontend/public/img/topology/memory-icon.png b/frontend/public/img/topology/memory-icon.png
new file mode 100644
index 00000000..36e8a44e
--- /dev/null
+++ b/frontend/public/img/topology/memory-icon.png
Binary files differ
diff --git a/frontend/public/img/topology/rack-energy-icon.png b/frontend/public/img/topology/rack-energy-icon.png
new file mode 100644
index 00000000..1088c61b
--- /dev/null
+++ b/frontend/public/img/topology/rack-energy-icon.png
Binary files differ
diff --git a/frontend/public/img/topology/rack-space-icon.png b/frontend/public/img/topology/rack-space-icon.png
new file mode 100644
index 00000000..387d7ea6
--- /dev/null
+++ b/frontend/public/img/topology/rack-space-icon.png
Binary files differ
diff --git a/frontend/public/img/topology/storage-icon.png b/frontend/public/img/topology/storage-icon.png
new file mode 100644
index 00000000..7a39cb6f
--- /dev/null
+++ b/frontend/public/img/topology/storage-icon.png
Binary files differ
diff --git a/frontend/public/img/tudelft-icon.png b/frontend/public/img/tudelft-icon.png
new file mode 100644
index 00000000..a7a2d56a
--- /dev/null
+++ b/frontend/public/img/tudelft-icon.png
Binary files differ
diff --git a/frontend/public/index.html b/frontend/public/index.html
new file mode 100644
index 00000000..e88cca42
--- /dev/null
+++ b/frontend/public/index.html
@@ -0,0 +1,70 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>OpenDC</title>
+
+ <!-- Standard meta tags -->
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+ <meta name="theme-color" content="#00A6D6">
+ <meta name="description" content="Collaborative Datacenter Simulation and Exploration for Everybody">
+ <meta name="author" content="@Large Research">
+ <meta name="keywords" content="OpenDC, Datacenter, Simulation, Simulator, Collaborative, Distributed, Cluster">
+ <link rel="manifest" href="/manifest.json">
+ <link rel="shortcut icon" href="/favicon.ico">
+
+ <!-- Twitter Card data -->
+ <meta name="twitter:card" content="summary">
+ <meta name="twitter:site" content="@LargeResearch">
+ <meta name="twitter:title" content="OpenDC">
+ <meta name="twitter:description" content="Collaborative Datacenter Simulation and Exploration for Everybody">
+ <meta name="twitter:creator" content="@LargeResearch">
+ <meta name="twitter:image" content="http://opendc.org/img/logo.png">
+
+ <!-- OpenGraph meta tags -->
+ <meta property="og:title" content="OpenDC">
+ <meta property="og:site_name" content="OpenDC">
+ <meta property="og:type" content="website">
+ <meta property="og:image" content="http://opendc.org/img/logo.png">
+ <meta property="og:url" content="http://opendc.org/">
+ <meta property="og:description"
+ content="OpenDC provides collaborative online datacenter modeling, diverse and effective datacenter
+ simulation, and exploratory datacenter performance feedback.">
+ <meta property="og:locale" content="en_US">
+
+ <!-- Google meta tags -->
+ <meta name="google-signin-client_id" content="%REACT_APP_OAUTH_CLIENT_ID%">
+ <meta name="google-site-verification" content="YIR4LkQTv6WmOdWv8MkeiUKni-0Yu3WHylLp4VvUMig"/>
+
+ <!-- CDN dependencies -->
+ <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css"
+ integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
+ <link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
+ <script src="https://use.fontawesome.com/ece66a2e7c.js"></script>
+
+ <!-- Google Analytics -->
+ <script async src="https://www.googletagmanager.com/gtag/js?id=UA-84285092-3"></script>
+ <script>
+ window.dataLayer = window.dataLayer || [];
+ function gtag() {dataLayer.push(arguments);}
+ gtag('js', new Date());
+ gtag('config', 'UA-84285092-3');
+ </script>
+</head>
+<body data-spy="scroll" data-target="#navbar">
+<noscript>
+ You need to enable JavaScript to run this app.
+</noscript>
+<div id="root"></div>
+
+<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"
+ integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
+ crossorigin="anonymous"></script>
+<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js"
+ integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4"
+ crossorigin="anonymous"></script>
+<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/js/bootstrap.min.js"
+ integrity="sha384-h0AbiXch4ZDo7tp9hKZ4TsHbi047NrKGLO3SEJAg45jXxnGIfYzk4Si90RDIqNm1"
+ crossorigin="anonymous"></script>
+</body>
+</html>
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 00000000..adb82218
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -0,0 +1,15 @@
+{
+ "short_name": "OpenDC",
+ "name": "OpenDC",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "16x16",
+ "type": "image/png"
+ }
+ ],
+ "start_url": "./index.html",
+ "display": "standalone",
+ "theme_color": "#00A6D6",
+ "background_color": "#eeeeee"
+}
diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt
new file mode 100644
index 00000000..165a1ea9
--- /dev/null
+++ b/frontend/public/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Disallow: /simulations/
+Disallow: /profile/
diff --git a/frontend/src/actions/auth.js b/frontend/src/actions/auth.js
new file mode 100644
index 00000000..45e2eb35
--- /dev/null
+++ b/frontend/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/frontend/src/actions/experiments.js b/frontend/src/actions/experiments.js
new file mode 100644
index 00000000..b5709981
--- /dev/null
+++ b/frontend/src/actions/experiments.js
@@ -0,0 +1,34 @@
+export const FETCH_EXPERIMENTS_OF_SIMULATION =
+ "FETCH_EXPERIMENTS_OF_SIMULATION";
+export const ADD_EXPERIMENT = "ADD_EXPERIMENT";
+export const DELETE_EXPERIMENT = "DELETE_EXPERIMENT";
+export const OPEN_EXPERIMENT_SUCCEEDED = "OPEN_EXPERIMENT_SUCCEEDED";
+
+export function fetchExperimentsOfSimulation(simulationId) {
+ return {
+ type: FETCH_EXPERIMENTS_OF_SIMULATION,
+ simulationId
+ };
+}
+
+export function addExperiment(experiment) {
+ return {
+ type: ADD_EXPERIMENT,
+ experiment
+ };
+}
+
+export function deleteExperiment(id) {
+ return {
+ type: DELETE_EXPERIMENT,
+ id
+ };
+}
+
+export function openExperimentSucceeded(simulationId, experimentId) {
+ return {
+ type: OPEN_EXPERIMENT_SUCCEEDED,
+ simulationId,
+ experimentId
+ };
+}
diff --git a/frontend/src/actions/interaction-level.js b/frontend/src/actions/interaction-level.js
new file mode 100644
index 00000000..31120146
--- /dev/null
+++ b/frontend/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/frontend/src/actions/map.js b/frontend/src/actions/map.js
new file mode 100644
index 00000000..82546c00
--- /dev/null
+++ b/frontend/src/actions/map.js
@@ -0,0 +1,93 @@
+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/frontend/src/actions/modals/experiments.js b/frontend/src/actions/modals/experiments.js
new file mode 100644
index 00000000..df939fa5
--- /dev/null
+++ b/frontend/src/actions/modals/experiments.js
@@ -0,0 +1,14 @@
+export const OPEN_NEW_EXPERIMENT_MODAL = "OPEN_NEW_EXPERIMENT_MODAL";
+export const CLOSE_NEW_EXPERIMENT_MODAL = "CLOSE_EXPERIMENT_MODAL";
+
+export function openNewExperimentModal() {
+ return {
+ type: OPEN_NEW_EXPERIMENT_MODAL
+ };
+}
+
+export function closeNewExperimentModal() {
+ return {
+ type: CLOSE_NEW_EXPERIMENT_MODAL
+ };
+}
diff --git a/frontend/src/actions/modals/profile.js b/frontend/src/actions/modals/profile.js
new file mode 100644
index 00000000..ee52610c
--- /dev/null
+++ b/frontend/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/frontend/src/actions/modals/simulations.js b/frontend/src/actions/modals/simulations.js
new file mode 100644
index 00000000..b11d356c
--- /dev/null
+++ b/frontend/src/actions/modals/simulations.js
@@ -0,0 +1,14 @@
+export const OPEN_NEW_SIMULATION_MODAL = "OPEN_NEW_SIMULATION_MODAL";
+export const CLOSE_NEW_SIMULATION_MODAL = "CLOSE_SIMULATION_MODAL";
+
+export function openNewSimulationModal() {
+ return {
+ type: OPEN_NEW_SIMULATION_MODAL
+ };
+}
+
+export function closeNewSimulationModal() {
+ return {
+ type: CLOSE_NEW_SIMULATION_MODAL
+ };
+}
diff --git a/frontend/src/actions/modals/topology.js b/frontend/src/actions/modals/topology.js
new file mode 100644
index 00000000..7ee16522
--- /dev/null
+++ b/frontend/src/actions/modals/topology.js
@@ -0,0 +1,70 @@
+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 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/frontend/src/actions/objects.js b/frontend/src/actions/objects.js
new file mode 100644
index 00000000..80b56c0c
--- /dev/null
+++ b/frontend/src/actions/objects.js
@@ -0,0 +1,48 @@
+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/frontend/src/actions/profile.js b/frontend/src/actions/profile.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/frontend/src/actions/profile.js
diff --git a/frontend/src/actions/simulation/load-metric.js b/frontend/src/actions/simulation/load-metric.js
new file mode 100644
index 00000000..c59e3596
--- /dev/null
+++ b/frontend/src/actions/simulation/load-metric.js
@@ -0,0 +1,8 @@
+export const CHANGE_LOAD_METRIC = "CHANGE_LOAD_METRIC";
+
+export function changeLoadMetric(metric) {
+ return {
+ type: CHANGE_LOAD_METRIC,
+ metric
+ };
+}
diff --git a/frontend/src/actions/simulation/playback.js b/frontend/src/actions/simulation/playback.js
new file mode 100644
index 00000000..8e913914
--- /dev/null
+++ b/frontend/src/actions/simulation/playback.js
@@ -0,0 +1,15 @@
+export const SET_PLAYING = "SET_PLAYING";
+
+export function playSimulation() {
+ return {
+ type: SET_PLAYING,
+ playing: true
+ };
+}
+
+export function pauseSimulation() {
+ return {
+ type: SET_PLAYING,
+ playing: false
+ };
+}
diff --git a/frontend/src/actions/simulation/tick.js b/frontend/src/actions/simulation/tick.js
new file mode 100644
index 00000000..a629b340
--- /dev/null
+++ b/frontend/src/actions/simulation/tick.js
@@ -0,0 +1,49 @@
+import { getDatacenterIdOfTick } from "../../util/timeline";
+import { setCurrentDatacenter } from "../topology/building";
+
+export const GO_TO_TICK = "GO_TO_TICK";
+export const SET_LAST_SIMULATED_TICK = "SET_LAST_SIMULATED_TICK";
+
+export function incrementTick() {
+ return (dispatch, getState) => {
+ const { currentTick } = getState();
+ dispatch(goToTick(currentTick + 1));
+ };
+}
+
+export function goToTick(tick) {
+ return (dispatch, getState) => {
+ const state = getState();
+
+ let sections = [];
+ if (state.currentExperimentId !== -1) {
+ const sectionIds =
+ state.objects.path[
+ state.objects.experiment[state.currentExperimentId].pathId
+ ].sectionIds;
+
+ if (sectionIds) {
+ sections = sectionIds.map(
+ sectionId => state.objects.section[sectionId]
+ );
+ }
+ }
+
+ const newDatacenterId = getDatacenterIdOfTick(tick, sections);
+ if (state.currentDatacenterId !== newDatacenterId) {
+ dispatch(setCurrentDatacenter(newDatacenterId));
+ }
+
+ dispatch({
+ type: GO_TO_TICK,
+ tick
+ });
+ };
+}
+
+export function setLastSimulatedTick(tick) {
+ return {
+ type: SET_LAST_SIMULATED_TICK,
+ tick
+ };
+}
diff --git a/frontend/src/actions/simulations.js b/frontend/src/actions/simulations.js
new file mode 100644
index 00000000..6da7aa3a
--- /dev/null
+++ b/frontend/src/actions/simulations.js
@@ -0,0 +1,52 @@
+export const SET_AUTH_VISIBILITY_FILTER = "SET_AUTH_VISIBILITY_FILTER";
+export const ADD_SIMULATION = "ADD_SIMULATION";
+export const ADD_SIMULATION_SUCCEEDED = "ADD_SIMULATION_SUCCEEDED";
+export const DELETE_SIMULATION = "DELETE_SIMULATION";
+export const DELETE_SIMULATION_SUCCEEDED = "DELETE_SIMULATION_SUCCEEDED";
+export const OPEN_SIMULATION_SUCCEEDED = "OPEN_SIMULATION_SUCCEEDED";
+
+export function setAuthVisibilityFilter(filter) {
+ return {
+ type: SET_AUTH_VISIBILITY_FILTER,
+ filter
+ };
+}
+
+export function addSimulation(name) {
+ return (dispatch, getState) => {
+ const { auth } = getState();
+ dispatch({
+ type: ADD_SIMULATION,
+ name,
+ userId: auth.userId
+ });
+ };
+}
+
+export function addSimulationSucceeded(authorization) {
+ return {
+ type: ADD_SIMULATION_SUCCEEDED,
+ authorization
+ };
+}
+
+export function deleteSimulation(id) {
+ return {
+ type: DELETE_SIMULATION,
+ id
+ };
+}
+
+export function deleteSimulationSucceeded(id) {
+ return {
+ type: DELETE_SIMULATION_SUCCEEDED,
+ id
+ };
+}
+
+export function openSimulationSucceeded(id) {
+ return {
+ type: OPEN_SIMULATION_SUCCEEDED,
+ id
+ };
+}
diff --git a/frontend/src/actions/states.js b/frontend/src/actions/states.js
new file mode 100644
index 00000000..b3a355a2
--- /dev/null
+++ b/frontend/src/actions/states.js
@@ -0,0 +1,9 @@
+export const ADD_BATCH_TO_STATES = "ADD_BATCH_TO_STATES";
+
+export function addBatchToStates(objectType, objects) {
+ return {
+ type: ADD_BATCH_TO_STATES,
+ objectType,
+ objects
+ };
+}
diff --git a/frontend/src/actions/topology/building.js b/frontend/src/actions/topology/building.js
new file mode 100644
index 00000000..c6381a07
--- /dev/null
+++ b/frontend/src/actions/topology/building.js
@@ -0,0 +1,117 @@
+export const SET_CURRENT_DATACENTER = "SET_CURRENT_DATACENTER";
+export const RESET_CURRENT_DATACENTER = "RESET_CURRENT_DATACENTER";
+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 setCurrentDatacenter(datacenterId) {
+ return {
+ type: SET_CURRENT_DATACENTER,
+ datacenterId
+ };
+}
+
+export function resetCurrentDatacenter() {
+ return {
+ type: RESET_CURRENT_DATACENTER
+ };
+}
+
+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/frontend/src/actions/topology/machine.js b/frontend/src/actions/topology/machine.js
new file mode 100644
index 00000000..56968b7d
--- /dev/null
+++ b/frontend/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/frontend/src/actions/topology/rack.js b/frontend/src/actions/topology/rack.js
new file mode 100644
index 00000000..06988424
--- /dev/null
+++ b/frontend/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/frontend/src/actions/topology/room.js b/frontend/src/actions/topology/room.js
new file mode 100644
index 00000000..4e0fc3a2
--- /dev/null
+++ b/frontend/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/frontend/src/actions/users.js b/frontend/src/actions/users.js
new file mode 100644
index 00000000..dc393df9
--- /dev/null
+++ b/frontend/src/actions/users.js
@@ -0,0 +1,41 @@
+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/frontend/src/api/index.js b/frontend/src/api/index.js
new file mode 100644
index 00000000..37c288a3
--- /dev/null
+++ b/frontend/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/frontend/src/api/routes/datacenters.js b/frontend/src/api/routes/datacenters.js
new file mode 100644
index 00000000..20cf4935
--- /dev/null
+++ b/frontend/src/api/routes/datacenters.js
@@ -0,0 +1,26 @@
+import { sendRequest } from "../index";
+import { getById } from "./util";
+
+export function getDatacenter(datacenterId) {
+ return getById("/datacenters/{datacenterId}", { datacenterId });
+}
+
+export function getRoomsOfDatacenter(datacenterId) {
+ return getById("/datacenters/{datacenterId}/rooms", { datacenterId });
+}
+
+export function addRoomToDatacenter(room) {
+ return sendRequest({
+ path: "/datacenters/{datacenterId}/rooms",
+ method: "POST",
+ parameters: {
+ body: {
+ room
+ },
+ path: {
+ datacenterId: room.datacenterId
+ },
+ query: {}
+ }
+ });
+}
diff --git a/frontend/src/api/routes/experiments.js b/frontend/src/api/routes/experiments.js
new file mode 100644
index 00000000..f61698c5
--- /dev/null
+++ b/frontend/src/api/routes/experiments.js
@@ -0,0 +1,33 @@
+import { deleteById, getById } from "./util";
+
+export function getExperiment(experimentId) {
+ return getById("/experiments/{experimentId}", { experimentId });
+}
+
+export function deleteExperiment(experimentId) {
+ return deleteById("/experiments/{experimentId}", { experimentId });
+}
+
+export function getLastSimulatedTick(experimentId) {
+ return getById("/experiments/{experimentId}/last-simulated-tick", {
+ experimentId
+ });
+}
+
+export function getAllMachineStates(experimentId) {
+ return getById("/experiments/{experimentId}/machine-states", {
+ experimentId
+ });
+}
+
+export function getAllRackStates(experimentId) {
+ return getById("/experiments/{experimentId}/rack-states", { experimentId });
+}
+
+export function getAllRoomStates(experimentId) {
+ return getById("/experiments/{experimentId}/room-states", { experimentId });
+}
+
+export function getAllTaskStates(experimentId) {
+ return getById("/experiments/{experimentId}/task-states", { experimentId });
+}
diff --git a/frontend/src/api/routes/jobs.js b/frontend/src/api/routes/jobs.js
new file mode 100644
index 00000000..355acc32
--- /dev/null
+++ b/frontend/src/api/routes/jobs.js
@@ -0,0 +1,5 @@
+import { getById } from "./util";
+
+export function getTasksOfJob(jobId) {
+ return getById("/jobs/{jobId}/tasks", { jobId });
+}
diff --git a/frontend/src/api/routes/paths.js b/frontend/src/api/routes/paths.js
new file mode 100644
index 00000000..78ef7d6e
--- /dev/null
+++ b/frontend/src/api/routes/paths.js
@@ -0,0 +1,30 @@
+import { sendRequest } from "../index";
+import { getById } from "./util";
+
+export function getPath(pathId) {
+ return getById("/paths/{pathId}", { pathId });
+}
+
+export function getBranchesOfPath(pathId) {
+ return getById("/paths/{pathId}/branches", { pathId });
+}
+
+export function branchFromPath(pathId, section) {
+ return sendRequest({
+ path: "/paths/{pathId}/branches",
+ method: "POST",
+ parameters: {
+ body: {
+ section
+ },
+ path: {
+ pathId
+ },
+ query: {}
+ }
+ });
+}
+
+export function getSectionsOfPath(pathId) {
+ return getById("/paths/{pathId}/sections", { pathId });
+}
diff --git a/frontend/src/api/routes/room-types.js b/frontend/src/api/routes/room-types.js
new file mode 100644
index 00000000..8a3eac58
--- /dev/null
+++ b/frontend/src/api/routes/room-types.js
@@ -0,0 +1,9 @@
+import { getAll, getById } from "./util";
+
+export function getAvailableRoomTypes() {
+ return getAll("/room-types");
+}
+
+export function getAllowedObjectsOfRoomType(name) {
+ return getById("/room-types/{name}/allowed-objects", { name });
+}
diff --git a/frontend/src/api/routes/rooms.js b/frontend/src/api/routes/rooms.js
new file mode 100644
index 00000000..56395d7f
--- /dev/null
+++ b/frontend/src/api/routes/rooms.js
@@ -0,0 +1,46 @@
+import { sendRequest } from "../index";
+import { deleteById, getById } from "./util";
+
+export function getRoom(roomId) {
+ return getById("/rooms/{roomId}", { roomId });
+}
+
+export function updateRoom(room) {
+ return sendRequest({
+ path: "/rooms/{roomId}",
+ method: "PUT",
+ parameters: {
+ body: {
+ room
+ },
+ path: {
+ roomId: room.id
+ },
+ query: {}
+ }
+ });
+}
+
+export function deleteRoom(roomId) {
+ return deleteById("/rooms/{roomId}", { roomId });
+}
+
+export function getTilesOfRoom(roomId) {
+ return getById("/rooms/{roomId}/tiles", { roomId });
+}
+
+export function addTileToRoom(tile) {
+ return sendRequest({
+ path: "/rooms/{roomId}/tiles",
+ method: "POST",
+ parameters: {
+ body: {
+ tile
+ },
+ path: {
+ roomId: tile.roomId
+ },
+ query: {}
+ }
+ });
+}
diff --git a/frontend/src/api/routes/schedulers.js b/frontend/src/api/routes/schedulers.js
new file mode 100644
index 00000000..ea360967
--- /dev/null
+++ b/frontend/src/api/routes/schedulers.js
@@ -0,0 +1,5 @@
+import { getAll } from "./util";
+
+export function getAllSchedulers() {
+ return getAll("/schedulers");
+}
diff --git a/frontend/src/api/routes/sections.js b/frontend/src/api/routes/sections.js
new file mode 100644
index 00000000..5e1a077d
--- /dev/null
+++ b/frontend/src/api/routes/sections.js
@@ -0,0 +1,5 @@
+import { getById } from "./util";
+
+export function getSection(sectionId) {
+ return getById("/sections/{sectionId}", { sectionId });
+}
diff --git a/frontend/src/api/routes/simulations.js b/frontend/src/api/routes/simulations.js
new file mode 100644
index 00000000..dcb9ac5f
--- /dev/null
+++ b/frontend/src/api/routes/simulations.js
@@ -0,0 +1,70 @@
+import { sendRequest } from "../index";
+import { deleteById, getById } from "./util";
+
+export function getSimulation(simulationId) {
+ return getById("/simulations/{simulationId}", { simulationId });
+}
+
+export function addSimulation(simulation) {
+ return sendRequest({
+ path: "/simulations",
+ method: "POST",
+ parameters: {
+ body: {
+ simulation
+ },
+ path: {},
+ query: {}
+ }
+ });
+}
+
+export function updateSimulation(simulation) {
+ return sendRequest({
+ path: "/simulations/{simulationId}",
+ method: "PUT",
+ parameters: {
+ body: {
+ simulation
+ },
+ path: {
+ simulationId: simulation.id
+ },
+ query: {}
+ }
+ });
+}
+
+export function deleteSimulation(simulationId) {
+ return deleteById("/simulations/{simulationId}", { simulationId });
+}
+
+export function getAuthorizationsBySimulation(simulationId) {
+ return getById("/simulations/{simulationId}/authorizations", {
+ simulationId
+ });
+}
+
+export function getPathsOfSimulation(simulationId) {
+ return getById("/simulations/{simulationId}/paths", { simulationId });
+}
+
+export function getExperimentsOfSimulation(simulationId) {
+ return getById("/simulations/{simulationId}/experiments", { simulationId });
+}
+
+export function addExperiment(simulationId, experiment) {
+ return sendRequest({
+ path: "/simulations/{simulationId}/experiments",
+ method: "POST",
+ parameters: {
+ body: {
+ experiment
+ },
+ path: {
+ simulationId
+ },
+ query: {}
+ }
+ });
+}
diff --git a/frontend/src/api/routes/specifications.js b/frontend/src/api/routes/specifications.js
new file mode 100644
index 00000000..0f60b571
--- /dev/null
+++ b/frontend/src/api/routes/specifications.js
@@ -0,0 +1,57 @@
+import { getAll, getById } from "./util";
+
+export function getAllCoolingItems() {
+ return getAll("/specifications/cooling-items");
+}
+
+export function getCoolingItem(id) {
+ return getById("/specifications/cooling-items/{id}", { id });
+}
+
+export function getAllCPUs() {
+ return getAll("/specifications/cpus");
+}
+
+export function getCPU(id) {
+ return getById("/specifications/cpus/{id}", { id });
+}
+
+export function getAllFailureModels() {
+ return getAll("/specifications/failure-models");
+}
+
+export function getFailureModel(id) {
+ return getById("/specifications/failure-models/{id}", { id });
+}
+
+export function getAllGPUs() {
+ return getAll("/specifications/gpus");
+}
+
+export function getGPU(id) {
+ return getById("/specifications/gpus/{id}", { id });
+}
+
+export function getAllMemories() {
+ return getAll("/specifications/memories");
+}
+
+export function getMemory(id) {
+ return getById("/specifications/memories/{id}", { id });
+}
+
+export function getAllPSUs() {
+ return getAll("/specifications/psus");
+}
+
+export function getPSU(id) {
+ return getById("/specifications/psus/{id}", { id });
+}
+
+export function getAllStorages() {
+ return getAll("/specifications/storages");
+}
+
+export function getStorage(id) {
+ return getById("/specifications/storages/{id}", { id });
+}
diff --git a/frontend/src/api/routes/tiles.js b/frontend/src/api/routes/tiles.js
new file mode 100644
index 00000000..08481ef4
--- /dev/null
+++ b/frontend/src/api/routes/tiles.js
@@ -0,0 +1,146 @@
+import { sendRequest } from "../index";
+import { deleteById, getById } from "./util";
+
+export function getTile(tileId) {
+ return getById("/tiles/{tileId}", { tileId });
+}
+
+export function deleteTile(tileId) {
+ return deleteById("/tiles/{tileId}", { tileId });
+}
+
+export function getRackByTile(tileId) {
+ return getTileObject(tileId, "/rack");
+}
+
+export function addRackToTile(tileId, rack) {
+ return addTileObject(tileId, { rack }, "/rack");
+}
+
+export function updateRackOnTile(tileId, rack) {
+ return updateTileObject(tileId, { rack }, "/rack");
+}
+
+export function deleteRackFromTile(tileId) {
+ return deleteTileObject(tileId, "/rack");
+}
+
+export function getMachinesOfRackByTile(tileId) {
+ return getById("/tiles/{tileId}/rack/machines", { tileId });
+}
+
+export function addMachineToRackOnTile(tileId, machine) {
+ return sendRequest({
+ path: "/tiles/{tileId}/rack/machines",
+ method: "POST",
+ parameters: {
+ body: {
+ machine
+ },
+ path: {
+ tileId
+ },
+ query: {}
+ }
+ });
+}
+
+export function updateMachineInRackOnTile(tileId, position, machine) {
+ return sendRequest({
+ path: "/tiles/{tileId}/rack/machines/{position}",
+ method: "PUT",
+ parameters: {
+ body: {
+ machine
+ },
+ path: {
+ tileId,
+ position
+ },
+ query: {}
+ }
+ });
+}
+
+export function deleteMachineInRackOnTile(tileId, position) {
+ return sendRequest({
+ path: "/tiles/{tileId}/rack/machines/{position}",
+ method: "DELETE",
+ parameters: {
+ body: {},
+ path: {
+ tileId,
+ position
+ },
+ query: {}
+ }
+ });
+}
+
+export function getCoolingItemByTile(tileId) {
+ return getTileObject(tileId, "/cooling-item");
+}
+
+export function addCoolingItemToTile(tileId, coolingItemId) {
+ return addTileObject(tileId, { coolingItemId }, "/cooling-item");
+}
+
+export function updateCoolingItemOnTile(tileId, coolingItemId) {
+ return updateTileObject(tileId, { coolingItemId }, "/cooling-item");
+}
+
+export function deleteCoolingItemFromTile(tileId) {
+ return deleteTileObject(tileId, "/cooling-item");
+}
+
+export function getPSUByTile(tileId) {
+ return getTileObject(tileId, "/psu");
+}
+
+export function addPSUToTile(tileId, psuId) {
+ return addTileObject(tileId, { psuId }, "/psu");
+}
+
+export function updatePSUOnTile(tileId, psuId) {
+ return updateTileObject(tileId, { psuId }, "/psu");
+}
+
+export function deletePSUFromTile(tileId) {
+ return deleteTileObject(tileId, "/psu");
+}
+
+function getTileObject(tileId, endpoint) {
+ return getById("/tiles/{tileId}" + endpoint, { tileId });
+}
+
+function addTileObject(tileId, objectBody, endpoint) {
+ return sendRequest({
+ path: "/tiles/{tileId}" + endpoint,
+ method: "POST",
+ parameters: {
+ body: objectBody,
+ path: {
+ tileId
+ },
+ query: {}
+ }
+ });
+}
+
+function updateTileObject(tileId, objectBody, endpoint) {
+ return sendRequest({
+ path: "/tiles/{tileId}" + endpoint,
+ method: "PUT",
+ parameters: {
+ body: objectBody,
+ path: {
+ tileId
+ },
+ query: {}
+ }
+ });
+}
+
+function deleteTileObject(tileId, endpoint) {
+ return deleteById("/tiles/{tileId}" + endpoint, { tileId });
+}
diff --git a/frontend/src/api/routes/token-signin.js b/frontend/src/api/routes/token-signin.js
new file mode 100644
index 00000000..26875606
--- /dev/null
+++ b/frontend/src/api/routes/token-signin.js
@@ -0,0 +1,7 @@
+export function performTokenSignIn(token) {
+ return new Promise(resolve => {
+ window["jQuery"].post("/tokensignin", { idtoken: token }, data =>
+ resolve(data)
+ );
+ });
+}
diff --git a/frontend/src/api/routes/traces.js b/frontend/src/api/routes/traces.js
new file mode 100644
index 00000000..a9ee4fae
--- /dev/null
+++ b/frontend/src/api/routes/traces.js
@@ -0,0 +1,9 @@
+import { getAll, getById } from "./util";
+
+export function getAllTraces() {
+ return getAll("/traces");
+}
+
+export function getJobsOfTrace(traceId) {
+ return getById("/traces/{traceId}/jobs", { traceId });
+}
diff --git a/frontend/src/api/routes/users.js b/frontend/src/api/routes/users.js
new file mode 100644
index 00000000..f8d8039c
--- /dev/null
+++ b/frontend/src/api/routes/users.js
@@ -0,0 +1,71 @@
+import { sendRequest } from "../index";
+import { deleteById, getById } 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: user
+ },
+ path: {},
+ query: {}
+ }
+ });
+}
+
+export function getUser(userId) {
+ return sendRequest({
+ path: "/users/{userId}",
+ method: "GET",
+ parameters: {
+ body: {},
+ path: {
+ userId
+ },
+ query: {}
+ }
+ });
+}
+
+export function updateUser(userId, user) {
+ return sendRequest({
+ path: "/users/{userId}",
+ method: "PUT",
+ parameters: {
+ body: {
+ user: {
+ givenName: user.givenName,
+ familyName: user.familyName
+ }
+ },
+ path: {
+ userId
+ },
+ query: {}
+ }
+ });
+}
+
+export function deleteUser(userId) {
+ return deleteById("/users/{userId}", { userId });
+}
+
+export function getAuthorizationsByUser(userId) {
+ return getById("/users/{userId}/authorizations", { userId });
+}
diff --git a/frontend/src/api/routes/util.js b/frontend/src/api/routes/util.js
new file mode 100644
index 00000000..35a40333
--- /dev/null
+++ b/frontend/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/frontend/src/api/socket.js b/frontend/src/api/socket.js
new file mode 100644
index 00000000..fadb77ad
--- /dev/null
+++ b/frontend/src/api/socket.js
@@ -0,0 +1,52 @@
+import io from "socket.io-client";
+import { getAuthToken } from "../auth/index";
+
+let socket;
+let requestIdCounter = 0;
+const callbacks = {};
+
+export function setupSocketConnection(onConnect) {
+ let port = window.location.port;
+ if (process.env.NODE_ENV !== "production") {
+ port = 8081;
+ }
+ socket = io.connect(
+ window.location.protocol + "//" + window.location.hostname + ":" + port
+ );
+ 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/frontend/src/auth/index.js b/frontend/src/auth/index.js
new file mode 100644
index 00000000..83c27b27
--- /dev/null
+++ b/frontend/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 = "/simulations";
+ break;
+ case LOG_OUT:
+ case DELETE_CURRENT_USER_SUCCEEDED:
+ clearAuthLocalStorage();
+ window.location.href = "/";
+ break;
+ default:
+ next(action);
+ return;
+ }
+
+ next(action);
+};
diff --git a/frontend/src/components/app/map/LoadingScreen.js b/frontend/src/components/app/map/LoadingScreen.js
new file mode 100644
index 00000000..9f379e0b
--- /dev/null
+++ b/frontend/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 datacenter...
+ </div>
+);
+
+export default LoadingScreen;
diff --git a/frontend/src/components/app/map/MapConstants.js b/frontend/src/components/app/map/MapConstants.js
new file mode 100644
index 00000000..32438b5e
--- /dev/null
+++ b/frontend/src/components/app/map/MapConstants.js
@@ -0,0 +1,29 @@
+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 = 4;
+export const DEFAULT_RACK_SLOT_CAPACITY = 42;
+export const DEFAULT_RACK_POWER_CAPACITY = 10000;
diff --git a/frontend/src/components/app/map/MapStageComponent.js b/frontend/src/components/app/map/MapStageComponent.js
new file mode 100644
index 00000000..67b3349c
--- /dev/null
+++ b/frontend/src/components/app/map/MapStageComponent.js
@@ -0,0 +1,126 @@
+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 jQuery from "../../../util/jquery";
+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);
+ }
+
+ componentWillMount() {
+ this.updateDimensions();
+ }
+
+ componentDidMount() {
+ 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(
+ jQuery(window).width(),
+ jQuery(window).height() - 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/frontend/src/components/app/map/controls/ExportCanvasComponent.js b/frontend/src/components/app/map/controls/ExportCanvasComponent.js
new file mode 100644
index 00000000..ee934f21
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/controls/ScaleIndicatorComponent.js b/frontend/src/components/app/map/controls/ScaleIndicatorComponent.js
new file mode 100644
index 00000000..b7b5cc36
--- /dev/null
+++ b/frontend/src/components/app/map/controls/ScaleIndicatorComponent.js
@@ -0,0 +1,14 @@
+import React from "react";
+import { TILE_SIZE_IN_METERS, TILE_SIZE_IN_PIXELS } from "../MapConstants";
+import "./ScaleIndicatorComponent.css";
+
+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/frontend/src/components/app/map/controls/ScaleIndicatorComponent.sass b/frontend/src/components/app/map/controls/ScaleIndicatorComponent.sass
new file mode 100644
index 00000000..f2d2b55b
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/controls/ToolPanelComponent.js b/frontend/src/components/app/map/controls/ToolPanelComponent.js
new file mode 100644
index 00000000..605e9887
--- /dev/null
+++ b/frontend/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.css";
+
+const ToolPanelComponent = () => (
+ <div className="tool-panel">
+ <ZoomControlContainer />
+ <ExportCanvasComponent />
+ </div>
+);
+
+export default ToolPanelComponent;
diff --git a/frontend/src/components/app/map/controls/ToolPanelComponent.sass b/frontend/src/components/app/map/controls/ToolPanelComponent.sass
new file mode 100644
index 00000000..996712b3
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/controls/ZoomControlComponent.js b/frontend/src/components/app/map/controls/ZoomControlComponent.js
new file mode 100644
index 00000000..e1b7491e
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/elements/Backdrop.js b/frontend/src/components/app/map/elements/Backdrop.js
new file mode 100644
index 00000000..57414463
--- /dev/null
+++ b/frontend/src/components/app/map/elements/Backdrop.js
@@ -0,0 +1,16 @@
+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/frontend/src/components/app/map/elements/GrayLayer.js b/frontend/src/components/app/map/elements/GrayLayer.js
new file mode 100644
index 00000000..28fadd8a
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/elements/HoverTile.js b/frontend/src/components/app/map/elements/HoverTile.js
new file mode 100644
index 00000000..42e6547c
--- /dev/null
+++ b/frontend/src/components/app/map/elements/HoverTile.js
@@ -0,0 +1,30 @@
+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/frontend/src/components/app/map/elements/ImageComponent.js b/frontend/src/components/app/map/elements/ImageComponent.js
new file mode 100644
index 00000000..cf41ddfe
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/elements/RackFillBar.js b/frontend/src/components/app/map/elements/RackFillBar.js
new file mode 100644
index 00000000..43701d97
--- /dev/null
+++ b/frontend/src/components/app/map/elements/RackFillBar.js
@@ -0,0 +1,89 @@
+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/frontend/src/components/app/map/elements/RoomTile.js b/frontend/src/components/app/map/elements/RoomTile.js
new file mode 100644
index 00000000..71c3bf15
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/elements/TileObject.js b/frontend/src/components/app/map/elements/TileObject.js
new file mode 100644
index 00000000..c1b631db
--- /dev/null
+++ b/frontend/src/components/app/map/elements/TileObject.js
@@ -0,0 +1,29 @@
+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/frontend/src/components/app/map/elements/TilePlusIcon.js b/frontend/src/components/app/map/elements/TilePlusIcon.js
new file mode 100644
index 00000000..06377152
--- /dev/null
+++ b/frontend/src/components/app/map/elements/TilePlusIcon.js
@@ -0,0 +1,52 @@
+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/frontend/src/components/app/map/elements/WallSegment.js b/frontend/src/components/app/map/elements/WallSegment.js
new file mode 100644
index 00000000..c5011656
--- /dev/null
+++ b/frontend/src/components/app/map/elements/WallSegment.js
@@ -0,0 +1,39 @@
+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/frontend/src/components/app/map/groups/DatacenterGroup.js b/frontend/src/components/app/map/groups/DatacenterGroup.js
new file mode 100644
index 00000000..51e32db6
--- /dev/null
+++ b/frontend/src/components/app/map/groups/DatacenterGroup.js
@@ -0,0 +1,40 @@
+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 DatacenterGroup = ({ datacenter, interactionLevel }) => {
+ if (!datacenter) {
+ return <Group />;
+ }
+
+ if (interactionLevel.mode === "BUILDING") {
+ return (
+ <Group>
+ {datacenter.roomIds.map(roomId => (
+ <RoomContainer key={roomId} roomId={roomId} />
+ ))}
+ </Group>
+ );
+ }
+
+ return (
+ <Group>
+ {datacenter.roomIds
+ .filter(roomId => roomId !== interactionLevel.roomId)
+ .map(roomId => <RoomContainer key={roomId} roomId={roomId} />)}
+ {interactionLevel.mode === "ROOM" ? <GrayContainer /> : null}
+ {datacenter.roomIds
+ .filter(roomId => roomId === interactionLevel.roomId)
+ .map(roomId => <RoomContainer key={roomId} roomId={roomId} />)}
+ </Group>
+ );
+};
+
+DatacenterGroup.propTypes = {
+ datacenter: Shapes.Datacenter,
+ interactionLevel: Shapes.InteractionLevel
+};
+
+export default DatacenterGroup;
diff --git a/frontend/src/components/app/map/groups/GridGroup.js b/frontend/src/components/app/map/groups/GridGroup.js
new file mode 100644
index 00000000..bbb1eb68
--- /dev/null
+++ b/frontend/src/components/app/map/groups/GridGroup.js
@@ -0,0 +1,41 @@
+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/frontend/src/components/app/map/groups/RackGroup.js b/frontend/src/components/app/map/groups/RackGroup.js
new file mode 100644
index 00000000..69d6ac10
--- /dev/null
+++ b/frontend/src/components/app/map/groups/RackGroup.js
@@ -0,0 +1,43 @@
+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 { convertLoadToSimulationColor } from "../../../../util/simulation-load";
+import TileObject from "../elements/TileObject";
+
+const RackGroup = ({ tile, inSimulation, rackLoad }) => {
+ let color = RACK_BACKGROUND_COLOR;
+ if (inSimulation && rackLoad >= 0) {
+ color = convertLoadToSimulationColor(rackLoad);
+ }
+
+ return (
+ <Group>
+ <TileObject
+ positionX={tile.positionX}
+ positionY={tile.positionY}
+ color={color}
+ />
+ <Group opacity={inSimulation ? 0.3 : 1}>
+ <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/frontend/src/components/app/map/groups/RoomGroup.js b/frontend/src/components/app/map/groups/RoomGroup.js
new file mode 100644
index 00000000..c8f0d3db
--- /dev/null
+++ b/frontend/src/components/app/map/groups/RoomGroup.js
@@ -0,0 +1,56 @@
+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/frontend/src/components/app/map/groups/TileGroup.js b/frontend/src/components/app/map/groups/TileGroup.js
new file mode 100644
index 00000000..8f3953d7
--- /dev/null
+++ b/frontend/src/components/app/map/groups/TileGroup.js
@@ -0,0 +1,43 @@
+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 { convertLoadToSimulationColor } from "../../../../util/simulation-load";
+import RoomTile from "../elements/RoomTile";
+
+const TileGroup = ({ tile, newTile, inSimulation, roomLoad, onClick }) => {
+ let tileObject;
+ switch (tile.objectType) {
+ case "RACK":
+ tileObject = <RackContainer tile={tile} />;
+ break;
+ default:
+ tileObject = null;
+ }
+
+ let color = ROOM_DEFAULT_COLOR;
+ if (newTile) {
+ color = ROOM_IN_CONSTRUCTION_COLOR;
+ } else if (inSimulation && roomLoad >= 0) {
+ color = convertLoadToSimulationColor(roomLoad);
+ }
+
+ 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/frontend/src/components/app/map/groups/WallGroup.js b/frontend/src/components/app/map/groups/WallGroup.js
new file mode 100644
index 00000000..43de66e8
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/layers/HoverLayerComponent.js b/frontend/src/components/app/map/layers/HoverLayerComponent.js
new file mode 100644
index 00000000..c39532f1
--- /dev/null
+++ b/frontend/src/components/app/map/layers/HoverLayerComponent.js
@@ -0,0 +1,85 @@
+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/frontend/src/components/app/map/layers/MapLayerComponent.js b/frontend/src/components/app/map/layers/MapLayerComponent.js
new file mode 100644
index 00000000..6ad3cb88
--- /dev/null
+++ b/frontend/src/components/app/map/layers/MapLayerComponent.js
@@ -0,0 +1,22 @@
+import React from "react";
+import { Group, Layer } from "react-konva";
+import DatacenterContainer from "../../../../containers/app/map/DatacenterContainer";
+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 />
+ <DatacenterContainer />
+ <GridGroup />
+ </Group>
+ </Layer>
+);
+
+export default MapLayerComponent;
diff --git a/frontend/src/components/app/map/layers/ObjectHoverLayerComponent.js b/frontend/src/components/app/map/layers/ObjectHoverLayerComponent.js
new file mode 100644
index 00000000..e7342d3c
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/map/layers/RoomHoverLayerComponent.js b/frontend/src/components/app/map/layers/RoomHoverLayerComponent.js
new file mode 100644
index 00000000..feea5ae5
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/Sidebar.js b/frontend/src/components/app/sidebars/Sidebar.js
new file mode 100644
index 00000000..33dbe011
--- /dev/null
+++ b/frontend/src/components/app/sidebars/Sidebar.js
@@ -0,0 +1,50 @@
+import classNames from "classnames";
+import React from "react";
+import "./Sidebar.css";
+
+class Sidebar extends React.Component {
+ 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}
+ {collapseButton}
+ </div>
+ );
+ }
+}
+
+export default Sidebar;
diff --git a/frontend/src/components/app/sidebars/Sidebar.sass b/frontend/src/components/app/sidebars/Sidebar.sass
new file mode 100644
index 00000000..4d0e5f1e
--- /dev/null
+++ b/frontend/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: 350px
+
+ 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/frontend/src/components/app/sidebars/elements/LoadBarComponent.js b/frontend/src/components/app/sidebars/elements/LoadBarComponent.js
new file mode 100644
index 00000000..8c9b164b
--- /dev/null
+++ b/frontend/src/components/app/sidebars/elements/LoadBarComponent.js
@@ -0,0 +1,22 @@
+import classNames from "classnames";
+import React from "react";
+
+const LoadBarComponent = ({ percent, disabled }) => (
+ <div className="mt-1">
+ <strong>Current load</strong>
+ <div className={classNames("progress", { disabled })}>
+ <div
+ className="progress-bar"
+ role="progressbar"
+ aria-valuenow={percent}
+ aria-valuemin="0"
+ aria-valuemax="100"
+ style={{ width: percent + "%" }}
+ >
+ {percent}%
+ </div>
+ </div>
+ </div>
+);
+
+export default LoadBarComponent;
diff --git a/frontend/src/components/app/sidebars/elements/LoadChartComponent.js b/frontend/src/components/app/sidebars/elements/LoadChartComponent.js
new file mode 100644
index 00000000..5f0d40cb
--- /dev/null
+++ b/frontend/src/components/app/sidebars/elements/LoadChartComponent.js
@@ -0,0 +1,90 @@
+import React from "react";
+import ReactDOM from "react-dom/server";
+import SvgSaver from "svgsaver";
+import {
+ VictoryAxis,
+ VictoryChart,
+ VictoryLabel,
+ VictoryLine,
+ VictoryScatter
+} from "victory";
+import { convertSecondsToFormattedTime } from "../../../../util/date-time";
+
+const LoadChartComponent = ({ data, currentTick }) => {
+ const onExport = () => {
+ const div = document.createElement("div");
+ div.innerHTML = ReactDOM.renderToString(
+ <VictoryChartComponent
+ data={data}
+ currentTick={currentTick}
+ showCurrentTick={false}
+ />
+ );
+ div.firstChild.style =
+ "font-family: Roboto, Arial, sans-serif; font-size: 10pt;";
+ const svgSaver = new SvgSaver();
+ svgSaver.asSvg(
+ div.firstChild,
+ "opendc-chart-export-" + Date.now() + ".svg"
+ );
+ };
+
+ return (
+ <div className="mt-1" style={{ position: "relative" }}>
+ <strong>Load over time</strong>
+ <VictoryChartComponent
+ data={data}
+ currentTick={currentTick}
+ showCurrentTick={true}
+ />
+ <ExportChartComponent onExport={onExport} />
+ </div>
+ );
+};
+
+const VictoryChartComponent = ({ data, currentTick, showCurrentTick }) => (
+ <VictoryChart
+ height={250}
+ padding={{ top: 10, bottom: 50, left: 50, right: 50 }}
+ >
+ <VictoryAxis
+ tickFormat={tick => convertSecondsToFormattedTime(tick)}
+ fixLabelOverlap={true}
+ label="Simulated Time"
+ />
+ <VictoryAxis dependentAxis label="Load" />
+ <VictoryLine data={data} />
+ <VictoryScatter data={data} />
+ {showCurrentTick ? (
+ <VictoryLine
+ labelComponent={
+ <VictoryLabel renderInPortal angle={90} dy={-5} dx={60} />
+ }
+ data={[{ x: currentTick + 1, y: 0 }, { x: currentTick + 1, y: 1 }]}
+ labels={point =>
+ point.y === 1
+ ? "Current tick : " + convertSecondsToFormattedTime(currentTick)
+ : ""}
+ style={{
+ data: { stroke: "#00A6D6", strokeWidth: 4 },
+ labels: { fill: "#00A6D6" }
+ }}
+ />
+ ) : (
+ undefined
+ )}
+ </VictoryChart>
+);
+
+const ExportChartComponent = ({ onExport }) => (
+ <button
+ className="btn btn-success btn-circle btn-sm"
+ title="Export Chart to PNG Image"
+ onClick={onExport}
+ style={{ position: "absolute", top: 0, right: 0 }}
+ >
+ <span className="fa fa-camera" />
+ </button>
+);
+
+export default LoadChartComponent;
diff --git a/frontend/src/components/app/sidebars/simulation/ExperimentMetadataComponent.js b/frontend/src/components/app/sidebars/simulation/ExperimentMetadataComponent.js
new file mode 100644
index 00000000..bc563dab
--- /dev/null
+++ b/frontend/src/components/app/sidebars/simulation/ExperimentMetadataComponent.js
@@ -0,0 +1,23 @@
+import React from "react";
+
+const ExperimentMetadataComponent = ({
+ experimentName,
+ pathName,
+ traceName,
+ schedulerName
+}) => (
+ <div>
+ <h2>{experimentName}</h2>
+ <p>
+ Path: <strong>{pathName}</strong>
+ </p>
+ <p>
+ Trace: <strong>{traceName}</strong>
+ </p>
+ <p>
+ Scheduler: <strong>{schedulerName}</strong>
+ </p>
+ </div>
+);
+
+export default ExperimentMetadataComponent;
diff --git a/frontend/src/components/app/sidebars/simulation/LoadMetricComponent.js b/frontend/src/components/app/sidebars/simulation/LoadMetricComponent.js
new file mode 100644
index 00000000..3e4cf810
--- /dev/null
+++ b/frontend/src/components/app/sidebars/simulation/LoadMetricComponent.js
@@ -0,0 +1,40 @@
+import React from "react";
+import {
+ SIM_HIGH_COLOR,
+ SIM_LOW_COLOR,
+ SIM_MID_HIGH_COLOR,
+ SIM_MID_LOW_COLOR
+} from "../../../../util/colors";
+import { LOAD_NAME_MAP } from "../../../../util/simulation-load";
+
+const LoadMetricComponent = ({ loadMetric }) => (
+ <div>
+ <div>
+ Colors represent <strong>{LOAD_NAME_MAP[loadMetric]}</strong>
+ </div>
+ <div className="btn-group mb-2" style={{ display: "flex" }}>
+ <span
+ className="btn btn-secondary"
+ style={{ backgroundColor: SIM_LOW_COLOR, flex: 1 }}
+ title="0-25%"
+ />
+ <span
+ className="btn btn-secondary"
+ style={{ backgroundColor: SIM_MID_LOW_COLOR, flex: 1 }}
+ title="25-50%"
+ />
+ <span
+ className="btn btn-secondary"
+ style={{ backgroundColor: SIM_MID_HIGH_COLOR, flex: 1 }}
+ title="50-75%"
+ />
+ <span
+ className="btn btn-secondary"
+ style={{ backgroundColor: SIM_HIGH_COLOR, flex: 1 }}
+ title="75-100%"
+ />
+ </div>
+ </div>
+);
+
+export default LoadMetricComponent;
diff --git a/frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.js b/frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.js
new file mode 100644
index 00000000..08dbb29a
--- /dev/null
+++ b/frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.js
@@ -0,0 +1,22 @@
+import React from "react";
+import ExperimentMetadataContainer from "../../../../containers/app/sidebars/simulation/ExperimentMetadataContainer";
+import LoadMetricContainer from "../../../../containers/app/sidebars/simulation/LoadMetricContainer";
+import TraceContainer from "../../../../containers/app/sidebars/simulation/TraceContainer";
+import Sidebar from "../Sidebar";
+import "./SimulationSidebarComponent.css";
+
+const SimulationSidebarComponent = () => {
+ return (
+ <Sidebar isRight={false}>
+ <div className="simulation-sidebar-container flex-column">
+ <ExperimentMetadataContainer />
+ <LoadMetricContainer />
+ <div className="trace-container">
+ <TraceContainer />
+ </div>
+ </div>
+ </Sidebar>
+ );
+};
+
+export default SimulationSidebarComponent;
diff --git a/frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.sass b/frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.sass
new file mode 100644
index 00000000..82af97fa
--- /dev/null
+++ b/frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.sass
@@ -0,0 +1,8 @@
+.simulation-sidebar-container
+ display: flex
+ height: 100%
+ max-height: 100%
+
+.trace-container
+ flex: 1
+ overflow-y: scroll
diff --git a/frontend/src/components/app/sidebars/simulation/TaskComponent.js b/frontend/src/components/app/sidebars/simulation/TaskComponent.js
new file mode 100644
index 00000000..bd917cc9
--- /dev/null
+++ b/frontend/src/components/app/sidebars/simulation/TaskComponent.js
@@ -0,0 +1,58 @@
+import approx from "approximate-number";
+import classNames from "classnames";
+import React from "react";
+import { convertSecondsToFormattedTime } from "../../../../util/date-time";
+
+const TaskComponent = ({ task, flopsLeft }) => {
+ let icon;
+ let progressBarContent;
+ let percent;
+ let infoTitle;
+
+ if (flopsLeft === task.totalFlopCount) {
+ icon = "hourglass-half";
+ progressBarContent = "";
+ percent = 0;
+ infoTitle = "Not submitted yet";
+ } else if (flopsLeft > 0) {
+ icon = "refresh";
+ progressBarContent = approx(task.totalFlopCount - flopsLeft) + " FLOP";
+ percent = 100 * (task.totalFlopCount - flopsLeft) / task.totalFlopCount;
+ infoTitle =
+ progressBarContent + " (" + Math.round(percent * 10) / 10 + "%)";
+ } else {
+ icon = "check";
+ progressBarContent = "Completed";
+ percent = 100;
+ infoTitle = "Completed";
+ }
+
+ return (
+ <li className="list-group-item flex-column align-items-start">
+ <div className="d-flex w-100 justify-content-between">
+ <h5 className="mb-1">{approx(task.totalFlopCount)} FLOP</h5>
+ <small>Starts at {convertSecondsToFormattedTime(task.startTick)}</small>
+ </div>
+ <div title={infoTitle} style={{ display: "flex" }}>
+ <span
+ className={classNames("fa", "fa-" + icon)}
+ style={{ width: "20px" }}
+ />
+ <div className="progress" style={{ flexGrow: 1 }}>
+ <div
+ className="progress-bar"
+ role="progressbar"
+ aria-valuenow={percent}
+ aria-valuemin="0"
+ aria-valuemax="100"
+ style={{ width: percent + "%" }}
+ >
+ {progressBarContent}
+ </div>
+ </div>
+ </div>
+ </li>
+ );
+};
+
+export default TaskComponent;
diff --git a/frontend/src/components/app/sidebars/simulation/TraceComponent.js b/frontend/src/components/app/sidebars/simulation/TraceComponent.js
new file mode 100644
index 00000000..2b6559b4
--- /dev/null
+++ b/frontend/src/components/app/sidebars/simulation/TraceComponent.js
@@ -0,0 +1,20 @@
+import React from "react";
+import TaskContainer from "../../../../containers/app/sidebars/simulation/TaskContainer";
+
+const TraceComponent = ({ jobs }) => (
+ <div>
+ <h3>Trace</h3>
+ {jobs.map(job => (
+ <div key={job.id}>
+ <h4>Job: {job.name}</h4>
+ <ul className="list-group">
+ {job.taskIds.map(taskId => (
+ <TaskContainer taskId={taskId} key={taskId} />
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+);
+
+export default TraceComponent;
diff --git a/frontend/src/components/app/sidebars/topology/NameComponent.js b/frontend/src/components/app/sidebars/topology/NameComponent.js
new file mode 100644
index 00000000..805538b3
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/TopologySidebarComponent.js b/frontend/src/components/app/sidebars/topology/TopologySidebarComponent.js
new file mode 100644
index 00000000..81e510a1
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js b/frontend/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js
new file mode 100644
index 00000000..f16c19f0
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js
@@ -0,0 +1,20 @@
+import React from "react";
+import NewRoomConstructionContainer from "../../../../../containers/app/sidebars/topology/building/NewRoomConstructionContainer";
+
+const BuildingSidebarComponent = ({ inSimulation }) => {
+ return (
+ <div>
+ <h2>Building</h2>
+ {inSimulation ? (
+ <div className="alert alert-info">
+ <span className="fa fa-info-circle mr-2" />
+ <strong>Click on individual rooms</strong> to see their stats!
+ </div>
+ ) : (
+ <NewRoomConstructionContainer />
+ )}
+ </div>
+ );
+};
+
+export default BuildingSidebarComponent;
diff --git a/frontend/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js b/frontend/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js
new file mode 100644
index 00000000..7b049642
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js
@@ -0,0 +1,31 @@
+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/frontend/src/components/app/sidebars/topology/machine/BackToRackComponent.js b/frontend/src/components/app/sidebars/topology/machine/BackToRackComponent.js
new file mode 100644
index 00000000..7f56aca0
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js b/frontend/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js
new file mode 100644
index 00000000..d8774bf9
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/machine/MachineNameComponent.js b/frontend/src/components/app/sidebars/topology/machine/MachineNameComponent.js
new file mode 100644
index 00000000..0ad8b79c
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/machine/MachineNameComponent.js
@@ -0,0 +1,7 @@
+import React from "react";
+
+const MachineNameComponent = ({ position }) => (
+ <h2>Machine at slot {position}</h2>
+);
+
+export default MachineNameComponent;
diff --git a/frontend/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js b/frontend/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js
new file mode 100644
index 00000000..5ccaf25c
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js
@@ -0,0 +1,27 @@
+import React from "react";
+import LoadBarContainer from "../../../../../containers/app/sidebars/elements/LoadBarContainer";
+import LoadChartContainer from "../../../../../containers/app/sidebars/elements/LoadChartContainer";
+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 = ({ inSimulation, machineId }) => {
+ return (
+ <div>
+ <MachineNameContainer />
+ <BackToRackContainer />
+ {inSimulation ? (
+ <div>
+ <LoadBarContainer objectType="machine" objectId={machineId} />
+ <LoadChartContainer objectType="machine" objectId={machineId} />
+ </div>
+ ) : (
+ <DeleteMachineContainer />
+ )}
+ <UnitTabsContainer />
+ </div>
+ );
+};
+
+export default MachineSidebarComponent;
diff --git a/frontend/src/components/app/sidebars/topology/machine/UnitAddComponent.js b/frontend/src/components/app/sidebars/topology/machine/UnitAddComponent.js
new file mode 100644
index 00000000..0c903228
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/machine/UnitAddComponent.js
@@ -0,0 +1,46 @@
+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-75 mr-1"
+ ref={unitSelect => (this.unitSelect = unitSelect)}
+ >
+ {this.props.units.map(unit => (
+ <option value={unit.id} key={unit.id}>
+ {unit.manufacturer +
+ " " +
+ unit.family +
+ " " +
+ unit.model +
+ " " +
+ unit.generation}
+ </option>
+ ))}
+ </select>
+ <button
+ type="submit"
+ className="btn btn-outline-primary"
+ onClick={() =>
+ this.props.onAdd(parseInt(this.unitSelect.value, 10))
+ }
+ >
+ <span className="fa fa-plus mr-2" />
+ Add
+ </button>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default UnitAddComponent;
diff --git a/frontend/src/components/app/sidebars/topology/machine/UnitComponent.js b/frontend/src/components/app/sidebars/topology/machine/UnitComponent.js
new file mode 100644
index 00000000..7c27043d
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/machine/UnitComponent.js
@@ -0,0 +1,78 @@
+import React from "react";
+import jQuery from "../../../../../util/jquery";
+
+class UnitComponent extends React.Component {
+ componentDidMount() {
+ jQuery(".unit-info-popover").popover({
+ trigger: "focus"
+ });
+ }
+
+ render() {
+ let unitInfo;
+ if (this.props.unitType === "cpu" || this.props.unitType === "gpu") {
+ unitInfo =
+ "<strong>Clockrate:</strong> <code>" +
+ this.props.unit.clockRateMhz +
+ " MHz</code><br/>" +
+ "<strong>Num. Cores:</strong> <code>" +
+ this.props.unit.numberOfCores +
+ "</code><br/>" +
+ "<strong>Energy Cons.:</strong> <code>" +
+ this.props.unit.energyConsumptionW +
+ " W</code>";
+ } else if (
+ this.props.unitType === "memory" ||
+ this.props.unitType === "storage"
+ ) {
+ unitInfo =
+ "<strong>Speed:</strong> <code>" +
+ this.props.unit.speedMbPerS +
+ " Mb/s</code><br/>" +
+ "<strong>Size:</strong> <code>" +
+ this.props.unit.sizeMb +
+ " MB</code><br/>" +
+ "<strong>Energy Cons.:</strong> <code> " +
+ this.props.unit.energyConsumptionW +
+ " W</code>";
+ }
+
+ return (
+ <li className="d-flex list-group-item justify-content-between align-items-center">
+ <span style={{ maxWidth: "60%" }}>
+ {this.props.unit.manufacturer +
+ " " +
+ this.props.unit.family +
+ " " +
+ this.props.unit.model +
+ " " +
+ this.props.unit.generation}
+ </span>
+ <span>
+ <span
+ tabIndex="0"
+ className="unit-info-popover btn btn-outline-info mr-1 fa fa-info-circle"
+ role="button"
+ data-toggle="popover"
+ data-trigger="focus"
+ title="Unit information"
+ data-content={unitInfo}
+ data-html="true"
+ />
+ {this.props.inSimulation ? (
+ undefined
+ ) : (
+ <span
+ className="btn btn-outline-danger"
+ onClick={this.props.onDelete}
+ >
+ <span className="fa fa-trash" />
+ </span>
+ )}
+ </span>
+ </li>
+ );
+ }
+}
+
+export default UnitComponent;
diff --git a/frontend/src/components/app/sidebars/topology/machine/UnitListComponent.js b/frontend/src/components/app/sidebars/topology/machine/UnitListComponent.js
new file mode 100644
index 00000000..38df806b
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/machine/UnitListComponent.js
@@ -0,0 +1,29 @@
+import React from "react";
+import UnitContainer from "../../../../../containers/app/sidebars/topology/machine/UnitContainer";
+
+const UnitListComponent = ({ unitType, unitIds, inSimulation }) => (
+ <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">
+ {inSimulation ? (
+ <strong>No units of this type in this machine</strong>
+ ) : (
+ <span>
+ <strong>No units...</strong> Add some with the menu above!
+ </span>
+ )}
+ </div>
+ )}
+ </ul>
+);
+
+export default UnitListComponent;
diff --git a/frontend/src/components/app/sidebars/topology/machine/UnitTabsComponent.js b/frontend/src/components/app/sidebars/topology/machine/UnitTabsComponent.js
new file mode 100644
index 00000000..0683c796
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/machine/UnitTabsComponent.js
@@ -0,0 +1,65 @@
+import React from "react";
+import UnitAddContainer from "../../../../../containers/app/sidebars/topology/machine/UnitAddContainer";
+import UnitListContainer from "../../../../../containers/app/sidebars/topology/machine/UnitListContainer";
+
+const UnitTabsComponent = ({ inSimulation }) => (
+ <div>
+ <ul className="nav nav-tabs mt-2 mb-1" role="tablist">
+ <li className="nav-item">
+ <a
+ className="nav-link active"
+ data-toggle="tab"
+ href="#cpu-units"
+ role="tab"
+ >
+ CPU
+ </a>
+ </li>
+ <li className="nav-item">
+ <a className="nav-link" data-toggle="tab" href="#gpu-units" role="tab">
+ GPU
+ </a>
+ </li>
+ <li className="nav-item">
+ <a
+ className="nav-link"
+ data-toggle="tab"
+ href="#memory-units"
+ role="tab"
+ >
+ Memory
+ </a>
+ </li>
+ <li className="nav-item">
+ <a
+ className="nav-link"
+ data-toggle="tab"
+ href="#storage-units"
+ role="tab"
+ >
+ Storage
+ </a>
+ </li>
+ </ul>
+ <div className="tab-content">
+ <div className="tab-pane active" id="cpu-units" role="tabpanel">
+ {inSimulation ? undefined : <UnitAddContainer unitType="cpu" />}
+ <UnitListContainer unitType="cpu" />
+ </div>
+ <div className="tab-pane" id="gpu-units" role="tabpanel">
+ {inSimulation ? undefined : <UnitAddContainer unitType="gpu" />}
+ <UnitListContainer unitType="gpu" />
+ </div>
+ <div className="tab-pane" id="memory-units" role="tabpanel">
+ {inSimulation ? undefined : <UnitAddContainer unitType="memory" />}
+ <UnitListContainer unitType="memory" />
+ </div>
+ <div className="tab-pane" id="storage-units" role="tabpanel">
+ {inSimulation ? undefined : <UnitAddContainer unitType="storage" />}
+ <UnitListContainer unitType="storage" />
+ </div>
+ </div>
+ </div>
+);
+
+export default UnitTabsComponent;
diff --git a/frontend/src/components/app/sidebars/topology/rack/BackToRoomComponent.js b/frontend/src/components/app/sidebars/topology/rack/BackToRoomComponent.js
new file mode 100644
index 00000000..6bcf4088
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/rack/DeleteRackComponent.js b/frontend/src/components/app/sidebars/topology/rack/DeleteRackComponent.js
new file mode 100644
index 00000000..d8aa7634
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/rack/EmptySlotComponent.js b/frontend/src/components/app/sidebars/topology/rack/EmptySlotComponent.js
new file mode 100644
index 00000000..d86f9fee
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/rack/EmptySlotComponent.js
@@ -0,0 +1,19 @@
+import React from "react";
+
+const EmptySlotComponent = ({ position, onAdd, inSimulation }) => (
+ <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>
+ {inSimulation ? (
+ <span className="badge badge-default badge-success">Empty Slot</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/frontend/src/components/app/sidebars/topology/rack/MachineComponent.js b/frontend/src/components/app/sidebars/topology/rack/MachineComponent.js
new file mode 100644
index 00000000..2521f4a2
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/rack/MachineComponent.js
@@ -0,0 +1,78 @@
+import React from "react";
+import Shapes from "../../../../../shapes";
+import { convertLoadToSimulationColor } from "../../../../../util/simulation-load";
+
+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,
+ inSimulation,
+ machineLoad,
+ onClick
+}) => {
+ let color = "white";
+ if (inSimulation && machineLoad >= 0) {
+ color = convertLoadToSimulationColor(machineLoad);
+ }
+ 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: color }}
+ >
+ <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/frontend/src/components/app/sidebars/topology/rack/MachineListComponent.js b/frontend/src/components/app/sidebars/topology/rack/MachineListComponent.js
new file mode 100644
index 00000000..d5521557
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/rack/MachineListComponent.js
@@ -0,0 +1,26 @@
+import React from "react";
+import EmptySlotContainer from "../../../../../containers/app/sidebars/topology/rack/EmptySlotContainer";
+import MachineContainer from "../../../../../containers/app/sidebars/topology/rack/MachineContainer";
+import "./MachineListComponent.css";
+
+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/frontend/src/components/app/sidebars/topology/rack/MachineListComponent.sass b/frontend/src/components/app/sidebars/topology/rack/MachineListComponent.sass
new file mode 100644
index 00000000..bbcfe696
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/rack/MachineListComponent.sass
@@ -0,0 +1,2 @@
+.machine-list li
+ min-height: 64px
diff --git a/frontend/src/components/app/sidebars/topology/rack/RackNameComponent.js b/frontend/src/components/app/sidebars/topology/rack/RackNameComponent.js
new file mode 100644
index 00000000..5e095823
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/rack/RackNameComponent.js
@@ -0,0 +1,8 @@
+import React from "react";
+import NameComponent from "../NameComponent";
+
+const RackNameComponent = ({ rackName, onEdit }) => (
+ <NameComponent name={rackName} onEdit={onEdit} />
+);
+
+export default RackNameComponent;
diff --git a/frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.js b/frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.js
new file mode 100644
index 00000000..f832b9b9
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.js
@@ -0,0 +1,34 @@
+import React from "react";
+import LoadBarContainer from "../../../../../containers/app/sidebars/elements/LoadBarContainer";
+import LoadChartContainer from "../../../../../containers/app/sidebars/elements/LoadChartContainer";
+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.css";
+
+const RackSidebarComponent = ({ inSimulation, rackId }) => {
+ return (
+ <div className="rack-sidebar-container flex-column">
+ <div className="rack-sidebar-header-container">
+ <RackNameContainer />
+ <BackToRoomContainer />
+ {inSimulation ? (
+ <div>
+ <LoadBarContainer objectType="rack" objectId={rackId} />
+ <LoadChartContainer objectType="rack" objectId={rackId} />
+ </div>
+ ) : (
+ <div>
+ <DeleteRackContainer />
+ </div>
+ )}
+ </div>
+ <div className="machine-list-container mt-2">
+ <MachineListContainer />
+ </div>
+ </div>
+ );
+};
+
+export default RackSidebarComponent;
diff --git a/frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass b/frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass
new file mode 100644
index 00000000..822804bc
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/room/BackToBuildingComponent.js b/frontend/src/components/app/sidebars/topology/room/BackToBuildingComponent.js
new file mode 100644
index 00000000..0409dbdd
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/room/DeleteRoomComponent.js b/frontend/src/components/app/sidebars/topology/room/DeleteRoomComponent.js
new file mode 100644
index 00000000..3e3b3b36
--- /dev/null
+++ b/frontend/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/frontend/src/components/app/sidebars/topology/room/EditRoomComponent.js b/frontend/src/components/app/sidebars/topology/room/EditRoomComponent.js
new file mode 100644
index 00000000..c3b9f0ad
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/room/EditRoomComponent.js
@@ -0,0 +1,27 @@
+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/frontend/src/components/app/sidebars/topology/room/RackConstructionComponent.js b/frontend/src/components/app/sidebars/topology/room/RackConstructionComponent.js
new file mode 100644
index 00000000..06b8a2aa
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/room/RackConstructionComponent.js
@@ -0,0 +1,32 @@
+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/frontend/src/components/app/sidebars/topology/room/RoomNameComponent.js b/frontend/src/components/app/sidebars/topology/room/RoomNameComponent.js
new file mode 100644
index 00000000..11b88edd
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/room/RoomNameComponent.js
@@ -0,0 +1,8 @@
+import React from "react";
+import NameComponent from "../NameComponent";
+
+const RoomNameComponent = ({ roomName, onEdit }) => (
+ <NameComponent name={roomName} onEdit={onEdit} />
+);
+
+export default RoomNameComponent;
diff --git a/frontend/src/components/app/sidebars/topology/room/RoomSidebarComponent.js b/frontend/src/components/app/sidebars/topology/room/RoomSidebarComponent.js
new file mode 100644
index 00000000..275f9624
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/room/RoomSidebarComponent.js
@@ -0,0 +1,38 @@
+import React from "react";
+import LoadBarContainer from "../../../../../containers/app/sidebars/elements/LoadBarContainer";
+import LoadChartContainer from "../../../../../containers/app/sidebars/elements/LoadChartContainer";
+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";
+import RoomTypeContainer from "../../../../../containers/app/sidebars/topology/room/RoomTypeContainer";
+
+const RoomSidebarComponent = ({ roomId, roomType, inSimulation }) => {
+ let allowedObjects;
+ if (!inSimulation && roomType === "SERVER") {
+ allowedObjects = <RackConstructionContainer />;
+ }
+
+ return (
+ <div>
+ <RoomNameContainer />
+ <RoomTypeContainer />
+ <BackToBuildingContainer />
+ {inSimulation ? (
+ <div>
+ <LoadBarContainer objectType="room" objectId={roomId} />
+ <LoadChartContainer objectType="room" objectId={roomId} />
+ </div>
+ ) : (
+ <div>
+ {allowedObjects}
+ <EditRoomContainer />
+ <DeleteRoomContainer />
+ </div>
+ )}
+ </div>
+ );
+};
+
+export default RoomSidebarComponent;
diff --git a/frontend/src/components/app/sidebars/topology/room/RoomTypeComponent.js b/frontend/src/components/app/sidebars/topology/room/RoomTypeComponent.js
new file mode 100644
index 00000000..46d91c2c
--- /dev/null
+++ b/frontend/src/components/app/sidebars/topology/room/RoomTypeComponent.js
@@ -0,0 +1,8 @@
+import React from "react";
+import { ROOM_TYPE_TO_NAME_MAP } from "../../../../../util/room-types";
+
+const RoomTypeComponent = ({ roomType }) => (
+ <p className="lead">{ROOM_TYPE_TO_NAME_MAP[roomType]}</p>
+);
+
+export default RoomTypeComponent;
diff --git a/frontend/src/components/app/timeline/PlayButtonComponent.js b/frontend/src/components/app/timeline/PlayButtonComponent.js
new file mode 100644
index 00000000..1a9b0ced
--- /dev/null
+++ b/frontend/src/components/app/timeline/PlayButtonComponent.js
@@ -0,0 +1,30 @@
+import React from "react";
+
+const PlayButtonComponent = ({
+ isPlaying,
+ currentTick,
+ lastSimulatedTick,
+ onPlay,
+ onPause
+}) => (
+ <div
+ className="play-btn"
+ onClick={() => {
+ if (isPlaying) {
+ onPause();
+ } else {
+ if (currentTick !== lastSimulatedTick) {
+ onPlay();
+ }
+ }
+ }}
+ >
+ {isPlaying ? (
+ <span className="fa fa-pause" />
+ ) : (
+ <span className="fa fa-play" />
+ )}
+ </div>
+);
+
+export default PlayButtonComponent;
diff --git a/frontend/src/components/app/timeline/Timeline.sass b/frontend/src/components/app/timeline/Timeline.sass
new file mode 100644
index 00000000..4c99a218
--- /dev/null
+++ b/frontend/src/components/app/timeline/Timeline.sass
@@ -0,0 +1,116 @@
+@import ../../../style-globals/_variables.sass
+@import ../../../style-globals/_mixins.sass
+
+$container-size: 500px
+$play-btn-size: 40px
+$border-width: 1px
+$timeline-border: $border-width solid $gray-semi-dark
+
+.timeline-bar
+ display: block
+ position: absolute
+ left: 0
+ bottom: 20px
+ width: 100%
+ text-align: center
+ z-index: 2000
+
+ pointer-events: none
+
+.timeline-container
+ display: inline-block
+ margin: 0 auto
+ text-align: left
+
+ width: $container-size
+
+.timeline-labels
+ display: block
+ height: 25px
+ line-height: 25px
+
+ div
+ display: inline-block
+
+ .start-time-label
+ margin-left: $play-btn-size - $border-width
+ padding-left: 4px
+
+ .end-time-label
+ padding-right: 4px
+ float: right
+
+.timeline-controls
+ display: flex
+ border: $timeline-border
+ overflow: hidden
+
+ pointer-events: all
+
+ +border-radius($standard-border-radius)
+
+ .play-btn
+ width: $play-btn-size
+ height: $play-btn-size + $border-width
+ line-height: $play-btn-size + $border-width
+ text-align: center
+ float: left
+ margin-top: -$border-width
+
+ font-size: 16pt
+ background: #333
+ color: #eee
+
+ +transition(background, $transition-length)
+ +user-select
+ +clickable
+
+ .play-btn:hover
+ background: #656565
+
+ .play-btn:active
+ background: #000
+
+ .timeline
+ position: relative
+ flex: 1
+ height: $play-btn-size
+ line-height: $play-btn-size
+ float: right
+
+ background: $blue-light
+
+ z-index: 500
+
+ div
+ +transition(all, $transition-length)
+
+ .time-marker
+ position: absolute
+ top: 0
+ left: 0
+
+ width: 6px
+ height: 100%
+
+ background: $blue-very-dark
+
+ +border-radius(2px)
+
+ z-index: 503
+
+ pointer-events: none
+
+ .section-marker
+ position: absolute
+ top: 0
+ left: 0
+
+ width: 3px
+ height: 100%
+
+ background: #222222
+
+ z-index: 504
+
+ pointer-events: none
diff --git a/frontend/src/components/app/timeline/TimelineComponent.js b/frontend/src/components/app/timeline/TimelineComponent.js
new file mode 100644
index 00000000..0f88b8f4
--- /dev/null
+++ b/frontend/src/components/app/timeline/TimelineComponent.js
@@ -0,0 +1,37 @@
+import React from "react";
+import TimelineControlsContainer from "../../../containers/app/timeline/TimelineControlsContainer";
+import TimelineLabelsContainer from "../../../containers/app/timeline/TimelineLabelsContainer";
+import "./Timeline.css";
+
+class TimelineComponent extends React.Component {
+ componentDidMount() {
+ this.interval = setInterval(() => {
+ if (!this.props.isPlaying) {
+ return;
+ }
+
+ if (this.props.currentTick < this.props.lastSimulatedTick) {
+ this.props.incrementTick();
+ } else {
+ this.props.pauseSimulation();
+ }
+ }, 1000);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.interval);
+ }
+
+ render() {
+ return (
+ <div className="timeline-bar">
+ <div className="timeline-container">
+ <TimelineLabelsContainer />
+ <TimelineControlsContainer />
+ </div>
+ </div>
+ );
+ }
+}
+
+export default TimelineComponent;
diff --git a/frontend/src/components/app/timeline/TimelineControlsComponent.js b/frontend/src/components/app/timeline/TimelineControlsComponent.js
new file mode 100644
index 00000000..f3d55154
--- /dev/null
+++ b/frontend/src/components/app/timeline/TimelineControlsComponent.js
@@ -0,0 +1,49 @@
+import React from "react";
+import PlayButtonContainer from "../../../containers/app/timeline/PlayButtonContainer";
+import { convertTickToPercentage } from "../../../util/timeline";
+
+class TimelineControlsComponent extends React.Component {
+ onTimelineClick(e) {
+ const percentage = e.nativeEvent.offsetX / this.timeline.clientWidth;
+ const tick = Math.floor(percentage * (this.props.lastSimulatedTick + 1));
+ this.props.goToTick(tick);
+ }
+
+ render() {
+ return (
+ <div className="timeline-controls">
+ <PlayButtonContainer />
+ <div
+ className="timeline"
+ ref={timeline => (this.timeline = timeline)}
+ onClick={this.onTimelineClick.bind(this)}
+ >
+ <div
+ className="time-marker"
+ style={{
+ left: convertTickToPercentage(
+ this.props.currentTick,
+ this.props.lastSimulatedTick
+ )
+ }}
+ />
+ {this.props.sectionTicks.map(sectionTick => (
+ <div
+ key={sectionTick}
+ className="section-marker"
+ style={{
+ left: convertTickToPercentage(
+ sectionTick,
+ this.props.lastSimulatedTick
+ )
+ }}
+ title="Topology changes at this tick"
+ />
+ ))}
+ </div>
+ </div>
+ );
+ }
+}
+
+export default TimelineControlsComponent;
diff --git a/frontend/src/components/app/timeline/TimelineLabelsComponent.js b/frontend/src/components/app/timeline/TimelineLabelsComponent.js
new file mode 100644
index 00000000..6943a86f
--- /dev/null
+++ b/frontend/src/components/app/timeline/TimelineLabelsComponent.js
@@ -0,0 +1,15 @@
+import React from "react";
+import { convertSecondsToFormattedTime } from "../../../util/date-time";
+
+const TimelineLabelsComponent = ({ currentTick, lastSimulatedTick }) => (
+ <div className="timeline-labels">
+ <div className="start-time-label">
+ {convertSecondsToFormattedTime(currentTick)}
+ </div>
+ <div className="end-time-label">
+ {convertSecondsToFormattedTime(lastSimulatedTick)}
+ </div>
+ </div>
+);
+
+export default TimelineLabelsComponent;
diff --git a/frontend/src/components/experiments/ExperimentListComponent.js b/frontend/src/components/experiments/ExperimentListComponent.js
new file mode 100644
index 00000000..2f7106e5
--- /dev/null
+++ b/frontend/src/components/experiments/ExperimentListComponent.js
@@ -0,0 +1,59 @@
+import PropTypes from "prop-types";
+import React from "react";
+import ExperimentRowContainer from "../../containers/experiments/ExperimentRowContainer";
+
+const ExperimentListComponent = ({ experimentIds, loading }) => {
+ let alert;
+
+ if (loading) {
+ alert = (
+ <div className="alert alert-success">
+ <span className="fa fa-refresh fa-spin mr-2" />
+ <strong>Loading Experiments...</strong>
+ </div>
+ );
+ } else if (experimentIds.length === 0 && !loading) {
+ alert = (
+ <div className="alert alert-info">
+ <span className="fa fa-question-circle mr-2" />
+ <strong>No experiments here yet...</strong> Add some with the button
+ below!
+ </div>
+ );
+ }
+
+ return (
+ <div className="vertically-expanding-container">
+ {alert ? (
+ alert
+ ) : (
+ <table className="table table-striped">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Path</th>
+ <th>Trace</th>
+ <th>Scheduler</th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {experimentIds.map(experimentId => (
+ <ExperimentRowContainer
+ experimentId={experimentId}
+ key={experimentId}
+ />
+ ))}
+ </tbody>
+ </table>
+ )}
+ </div>
+ );
+};
+
+ExperimentListComponent.propTypes = {
+ experimentIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ loading: PropTypes.bool
+};
+
+export default ExperimentListComponent;
diff --git a/frontend/src/components/experiments/ExperimentRowComponent.js b/frontend/src/components/experiments/ExperimentRowComponent.js
new file mode 100644
index 00000000..e71c6a00
--- /dev/null
+++ b/frontend/src/components/experiments/ExperimentRowComponent.js
@@ -0,0 +1,40 @@
+import PropTypes from "prop-types";
+import React from "react";
+import { Link } from "react-router-dom";
+import Shapes from "../../shapes/index";
+
+const ExperimentRowComponent = ({ experiment, simulationId, onDelete }) => (
+ <tr>
+ <td className="pt-3">{experiment.name}</td>
+ <td className="pt-3">
+ {experiment.path.name
+ ? experiment.path.name
+ : "Path " + experiment.path.id}
+ </td>
+ <td className="pt-3">{experiment.trace.name}</td>
+ <td className="pt-3">{experiment.scheduler.name}</td>
+ <td className="text-right">
+ <Link
+ to={"/simulations/" + simulationId + "/experiments/" + experiment.id}
+ className="btn btn-outline-primary btn-sm mr-2"
+ title="Open this experiment"
+ >
+ <span className="fa fa-play" />
+ </Link>
+ <div
+ className="btn btn-outline-danger btn-sm"
+ title="Delete this experiment"
+ onClick={() => onDelete(experiment.id)}
+ >
+ <span className="fa fa-trash" />
+ </div>
+ </td>
+ </tr>
+);
+
+ExperimentRowComponent.propTypes = {
+ experiment: Shapes.Experiment.isRequired,
+ simulationId: PropTypes.number.isRequired
+};
+
+export default ExperimentRowComponent;
diff --git a/frontend/src/components/experiments/NewExperimentButtonComponent.js b/frontend/src/components/experiments/NewExperimentButtonComponent.js
new file mode 100644
index 00000000..651172e3
--- /dev/null
+++ b/frontend/src/components/experiments/NewExperimentButtonComponent.js
@@ -0,0 +1,17 @@
+import PropTypes from "prop-types";
+import React from "react";
+
+const NewExperimentButtonComponent = ({ onClick }) => (
+ <div className="bottom-btn-container">
+ <div className="btn btn-primary float-right" onClick={onClick}>
+ <span className="fa fa-plus mr-2" />
+ New Experiment
+ </div>
+ </div>
+);
+
+NewExperimentButtonComponent.propTypes = {
+ onClick: PropTypes.func.isRequired
+};
+
+export default NewExperimentButtonComponent;
diff --git a/frontend/src/components/home/ContactSection.js b/frontend/src/components/home/ContactSection.js
new file mode 100644
index 00000000..f6c9c5d8
--- /dev/null
+++ b/frontend/src/components/home/ContactSection.js
@@ -0,0 +1,64 @@
+import React from "react";
+import FontAwesome from "react-fontawesome";
+import "./ContactSection.css";
+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.txt">
+ license
+ </a>
+ </strong>). Sorry for the inconvenience.
+ </div>
+ </div>
+ </ContentSection>
+);
+
+export default ContactSection;
diff --git a/frontend/src/components/home/ContactSection.sass b/frontend/src/components/home/ContactSection.sass
new file mode 100644
index 00000000..2cde7391
--- /dev/null
+++ b/frontend/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/frontend/src/components/home/ContentSection.js b/frontend/src/components/home/ContentSection.js
new file mode 100644
index 00000000..2e24ee10
--- /dev/null
+++ b/frontend/src/components/home/ContentSection.js
@@ -0,0 +1,19 @@
+import classNames from "classnames";
+import PropTypes from "prop-types";
+import React from "react";
+import "./ContentSection.css";
+
+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/frontend/src/components/home/ContentSection.sass b/frontend/src/components/home/ContentSection.sass
new file mode 100644
index 00000000..67541179
--- /dev/null
+++ b/frontend/src/components/home/ContentSection.sass
@@ -0,0 +1,9 @@
+@import ../../style-globals/_variables.sass
+
+.content-section
+ padding-top: 50px
+ padding-bottom: 100px
+ text-align: center
+
+ h1
+ margin-bottom: 30px
diff --git a/frontend/src/components/home/IntroSection.js b/frontend/src/components/home/IntroSection.js
new file mode 100644
index 00000000..59f5face
--- /dev/null
+++ b/frontend/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/frontend/src/components/home/JumbotronHeader.js b/frontend/src/components/home/JumbotronHeader.js
new file mode 100644
index 00000000..8a5312b3
--- /dev/null
+++ b/frontend/src/components/home/JumbotronHeader.js
@@ -0,0 +1,20 @@
+import React from "react";
+import "./JumbotronHeader.css";
+
+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/frontend/src/components/home/JumbotronHeader.sass b/frontend/src/components/home/JumbotronHeader.sass
new file mode 100644
index 00000000..b88b79f7
--- /dev/null
+++ b/frontend/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/frontend/src/components/home/ModelingSection.js b/frontend/src/components/home/ModelingSection.js
new file mode 100644
index 00000000..17834b0b
--- /dev/null
+++ b/frontend/src/components/home/ModelingSection.js
@@ -0,0 +1,24 @@
+import React from "react";
+import ScreenshotSection from "./ScreenshotSection";
+
+const ModelingSection = () => (
+ <ScreenshotSection
+ name="modeling"
+ title="Datacenter Modeling"
+ imageUrl="https://github.com/atlarge-research/opendc/raw/master/images/opendc-frontend-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/frontend/src/components/home/ScreenshotSection.js b/frontend/src/components/home/ScreenshotSection.js
new file mode 100644
index 00000000..42b8ac77
--- /dev/null
+++ b/frontend/src/components/home/ScreenshotSection.js
@@ -0,0 +1,32 @@
+import classNames from "classnames";
+import React from "react";
+import ContentSection from "./ContentSection";
+import "./ScreenshotSection.css";
+
+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/frontend/src/components/home/ScreenshotSection.sass b/frontend/src/components/home/ScreenshotSection.sass
new file mode 100644
index 00000000..a349ad48
--- /dev/null
+++ b/frontend/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/frontend/src/components/home/SimulationSection.js b/frontend/src/components/home/SimulationSection.js
new file mode 100644
index 00000000..3961e549
--- /dev/null
+++ b/frontend/src/components/home/SimulationSection.js
@@ -0,0 +1,25 @@
+import React from "react";
+import ScreenshotSection from "./ScreenshotSection";
+
+const ModelingSection = () => (
+ <ScreenshotSection
+ name="simulation"
+ title="Datacenter Simulation"
+ imageUrl="https://github.com/atlarge-research/opendc/raw/master/images/opendc-frontend-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/frontend/src/components/home/StakeholderSection.js b/frontend/src/components/home/StakeholderSection.js
new file mode 100644
index 00000000..6d25fd86
--- /dev/null
+++ b/frontend/src/components/home/StakeholderSection.js
@@ -0,0 +1,42 @@
+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/frontend/src/components/home/TeamSection.js b/frontend/src/components/home/TeamSection.js
new file mode 100644
index 00000000..b86655b4
--- /dev/null
+++ b/frontend/src/components/home/TeamSection.js
@@ -0,0 +1,56 @@
+import React from "react";
+import ContentSection from "./ContentSection";
+
+const TeamMember = ({ photoId, name, description }) => (
+ <div className="col-xl-3 col-lg-3 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="Technology Lead and Software Engineer responsible for the frontend web application"
+ />
+ <TeamMember
+ photoId="fmastenbroek"
+ name="Fabian Mastenbroek"
+ description="Software Engineer responsible for the datacenter simulator"
+ />
+ <TeamMember
+ photoId="loverweel"
+ name="Leon Overweel"
+ description="Software Engineer responsible for the web server, database, and API specification"
+ />
+ </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/frontend/src/components/home/TechnologiesSection.js b/frontend/src/components/home/TechnologiesSection.js
new file mode 100644
index 00000000..fdcfc522
--- /dev/null
+++ b/frontend/src/components/home/TechnologiesSection.js
@@ -0,0 +1,42 @@
+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">MariaDB</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/frontend/src/components/modals/ConfirmationModal.js b/frontend/src/components/modals/ConfirmationModal.js
new file mode 100644
index 00000000..abdce5ac
--- /dev/null
+++ b/frontend/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/frontend/src/components/modals/Modal.js b/frontend/src/components/modals/Modal.js
new file mode 100644
index 00000000..19337db8
--- /dev/null
+++ b/frontend/src/components/modals/Modal.js
@@ -0,0 +1,132 @@
+import classNames from "classnames";
+import PropTypes from "prop-types";
+import React from "react";
+import jQuery from "../../util/jquery";
+
+class Modal extends React.Component {
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ show: PropTypes.bool.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired,
+ submitButtonType: PropTypes.string,
+ submitButtonText: PropTypes.string
+ };
+ static defaultProps = {
+ submitButtonType: "primary",
+ submitButtonText: "Save"
+ };
+ static idCounter = 0;
+
+ // Local, up-to-date copy of modal visibility for time between close event and a props update (to prevent duplicate
+ // 'close' triggers)
+ visible = false;
+
+ constructor(props) {
+ super(props);
+ this.id = "modal-" + Modal.idCounter++;
+ }
+
+ componentDidMount() {
+ this.visible = this.props.show;
+ this.openOrCloseModal();
+
+ // Trigger auto-focus
+ jQuery("#" + this.id)
+ .on("shown.bs.modal", function() {
+ jQuery(this)
+ .find("input")
+ .first()
+ .focus();
+ })
+ .on("hide.bs.modal", () => {
+ if (this.visible) {
+ this.props.onCancel();
+ }
+ })
+ .on("keydown", e => {
+ e.stopPropagation();
+ });
+ }
+
+ componentDidUpdate() {
+ this.visible = this.props.show;
+ this.openOrCloseModal();
+ }
+
+ onSubmit() {
+ if (this.visible) {
+ this.props.onSubmit();
+ this.visible = false;
+ this.closeModal();
+ }
+ }
+
+ onCancel() {
+ if (this.visible) {
+ this.props.onCancel();
+ this.visible = false;
+ this.closeModal();
+ }
+ }
+
+ openModal() {
+ jQuery("#" + this.id).modal("show");
+ }
+
+ closeModal() {
+ jQuery("#" + this.id).modal("hide");
+ }
+
+ openOrCloseModal() {
+ if (this.visible) {
+ this.openModal();
+ } else {
+ this.closeModal();
+ }
+ }
+
+ render() {
+ return (
+ <div className="modal fade" id={this.id} role="dialog">
+ <div className="modal-dialog" role="document">
+ <div className="modal-content">
+ <div className="modal-header">
+ <h5 className="modal-title">{this.props.title}</h5>
+ <button
+ type="button"
+ className="close"
+ onClick={this.onCancel.bind(this)}
+ aria-label="Close"
+ >
+ <span>&times;</span>
+ </button>
+ </div>
+ <div className="modal-body">{this.props.children}</div>
+ <div className="modal-footer">
+ <button
+ type="button"
+ className="btn btn-secondary"
+ onClick={this.onCancel.bind(this)}
+ >
+ Close
+ </button>
+ <button
+ type="button"
+ className={classNames(
+ "btn",
+ "btn-" + this.props.submitButtonType
+ )}
+ onClick={this.onSubmit.bind(this)}
+ >
+ {this.props.submitButtonText}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default Modal;
diff --git a/frontend/src/components/modals/TextInputModal.js b/frontend/src/components/modals/TextInputModal.js
new file mode 100644
index 00000000..cc16f8e1
--- /dev/null
+++ b/frontend/src/components/modals/TextInputModal.js
@@ -0,0 +1,58 @@
+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.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/frontend/src/components/modals/custom-components/NewExperimentModalComponent.js b/frontend/src/components/modals/custom-components/NewExperimentModalComponent.js
new file mode 100644
index 00000000..e356fe96
--- /dev/null
+++ b/frontend/src/components/modals/custom-components/NewExperimentModalComponent.js
@@ -0,0 +1,104 @@
+import PropTypes from "prop-types";
+import React from "react";
+import Shapes from "../../../shapes";
+import Modal from "../Modal";
+
+class NewExperimentModalComponent extends React.Component {
+ static propTypes = {
+ show: PropTypes.bool.isRequired,
+ paths: PropTypes.arrayOf(Shapes.Path),
+ schedulers: PropTypes.arrayOf(Shapes.Scheduler),
+ traces: PropTypes.arrayOf(Shapes.Trace),
+ callback: PropTypes.func.isRequired
+ };
+
+ reset() {
+ this.textInput.value = "";
+ this.pathSelect.selectedIndex = 0;
+ this.traceSelect.selectedIndex = 0;
+ this.schedulerSelect.selectedIndex = 0;
+ }
+
+ onSubmit() {
+ this.props.callback(
+ this.textInput.value,
+ parseInt(this.pathSelect.value, 10),
+ parseInt(this.traceSelect.value, 10),
+ this.schedulerSelect.value
+ );
+ this.reset();
+ }
+
+ onCancel() {
+ this.props.callback(undefined);
+ this.reset();
+ }
+
+ render() {
+ return (
+ <Modal
+ title="New Experiment"
+ 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">Name</label>
+ <input
+ type="text"
+ className="form-control"
+ ref={textInput => (this.textInput = textInput)}
+ />
+ </div>
+ <div className="form-group">
+ <label className="form-control-label">Path</label>
+ <select
+ className="form-control"
+ ref={pathSelect => (this.pathSelect = pathSelect)}
+ >
+ {this.props.paths.map(path => (
+ <option value={path.id} key={path.id}>
+ {path.name ? path.name : "Path " + path.id}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div className="form-group">
+ <label className="form-control-label">Trace</label>
+ <select
+ className="form-control"
+ ref={traceSelect => (this.traceSelect = traceSelect)}
+ >
+ {this.props.traces.map(trace => (
+ <option value={trace.id} key={trace.id}>
+ {trace.name}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div className="form-group">
+ <label className="form-control-label">Scheduler</label>
+ <select
+ className="form-control"
+ ref={schedulerSelect => (this.schedulerSelect = schedulerSelect)}
+ >
+ {this.props.schedulers.map(scheduler => (
+ <option value={scheduler.name} key={scheduler.name}>
+ {scheduler.name}
+ </option>
+ ))}
+ </select>
+ </div>
+ </form>
+ </Modal>
+ );
+ }
+}
+
+export default NewExperimentModalComponent;
diff --git a/frontend/src/components/navigation/AppNavbar.js b/frontend/src/components/navigation/AppNavbar.js
new file mode 100644
index 00000000..1a35f85d
--- /dev/null
+++ b/frontend/src/components/navigation/AppNavbar.js
@@ -0,0 +1,56 @@
+import React from "react";
+import FontAwesome from "react-fontawesome";
+import { Link } from "react-router-dom";
+import Navbar, { NavItem } from "./Navbar";
+import "./Navbar.css";
+
+const AppNavbar = ({ simulationId, inSimulation, fullWidth }) => (
+ <Navbar fullWidth={fullWidth}>
+ {inSimulation ? (
+ <NavItem route={"/simulations/" + simulationId}>
+ <Link
+ className="nav-link"
+ title="Construction"
+ to={"/simulations/" + simulationId}
+ >
+ <FontAwesome name="industry" className="mr-2" />
+ Construction
+ </Link>
+ </NavItem>
+ ) : (
+ undefined
+ )}
+ {inSimulation ? (
+ <NavItem route={"/simulations/" + simulationId + "/experiments"}>
+ <Link
+ className="nav-link"
+ title="Experiments"
+ to={"/simulations/" + simulationId + "/experiments"}
+ >
+ <FontAwesome name="play" className="mr-2" />
+ Experiments
+ </Link>
+ </NavItem>
+ ) : (
+ undefined
+ )}
+ <NavItem route="/simulations">
+ <Link className="nav-link" title="My Simulations" to="/simulations">
+ <FontAwesome name="list" className="mr-2" />
+ My Simulations
+ </Link>
+ </NavItem>
+ <NavItem route="email">
+ <a
+ className="nav-link"
+ title="Support"
+ href="mailto:opendc@atlarge-research.com"
+ >
+ <FontAwesome name="envelope" className="mr-2" />
+ Support
+ </a>
+ </NavItem>
+ </Navbar>
+);
+
+export default AppNavbar;
diff --git a/frontend/src/components/navigation/HomeNavbar.js b/frontend/src/components/navigation/HomeNavbar.js
new file mode 100644
index 00000000..5d08bf3c
--- /dev/null
+++ b/frontend/src/components/navigation/HomeNavbar.js
@@ -0,0 +1,24 @@
+import React from "react";
+import Navbar from "./Navbar";
+import "./Navbar.css";
+
+const ScrollNavItem = ({ id, name }) => (
+ <li className="nav-item">
+ <a className="nav-link" href={id}>
+ {name}
+ </a>
+ </li>
+);
+
+const HomeNavbar = () => (
+ <Navbar fullWidth={false}>
+ <ScrollNavItem id="#stakeholders" name="Stakeholders" />
+ <ScrollNavItem id="#modeling" name="Modeling" />
+ <ScrollNavItem id="#simulation" name="Simulation" />
+ <ScrollNavItem id="#technologies" name="Technologies" />
+ <ScrollNavItem id="#team" name="Team" />
+ <ScrollNavItem id="#contact" name="Contact" />
+ </Navbar>
+);
+
+export default HomeNavbar;
diff --git a/frontend/src/components/navigation/LogoutButton.js b/frontend/src/components/navigation/LogoutButton.js
new file mode 100644
index 00000000..800a3da8
--- /dev/null
+++ b/frontend/src/components/navigation/LogoutButton.js
@@ -0,0 +1,16 @@
+import PropTypes from "prop-types";
+import React from "react";
+import FontAwesome from "react-fontawesome";
+import { Link } from "react-router-dom";
+
+const LogoutButton = ({ onLogout }) => (
+ <Link className="logout nav-link" title="Sign out" to="#" onClick={onLogout}>
+ <FontAwesome name="power-off" size="lg" />
+ </Link>
+);
+
+LogoutButton.propTypes = {
+ onLogout: PropTypes.func.isRequired
+};
+
+export default LogoutButton;
diff --git a/frontend/src/components/navigation/Navbar.js b/frontend/src/components/navigation/Navbar.js
new file mode 100644
index 00000000..44458949
--- /dev/null
+++ b/frontend/src/components/navigation/Navbar.js
@@ -0,0 +1,102 @@
+import classNames from "classnames";
+import React from "react";
+import { Link, withRouter } from "react-router-dom";
+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.css";
+
+export const NAVBAR_HEIGHT = 60;
+
+export const NavItem = withRouter(props => <NavItemWithoutRoute {...props} />);
+export const LoggedInSection = withRouter(props => (
+ <LoggedInSectionWithoutRoute {...props} />
+));
+
+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>
+);
+
+const NavItemWithoutRoute = ({ route, location, children }) => (
+ <li
+ className={classNames(
+ "nav-item",
+ location.pathname === route ? "active" : undefined
+ )}
+ >
+ {children}
+ </li>
+);
+
+const LoggedInSectionWithoutRoute = ({ location }) => (
+ <ul className="navbar-nav auth-links">
+ {userIsLoggedIn() ? (
+ [
+ location.pathname === "/" ? (
+ <NavItem route="/simulations" key="simulations">
+ <Link className="nav-link" title="My Simulations" to="/simulations">
+ My Simulations
+ </Link>
+ </NavItem>
+ ) : (
+ <NavItem route="/profile" key="profile">
+ <Link className="nav-link" title="My Profile" to="/profile">
+ <ProfileName />
+ </Link>
+ </NavItem>
+ ),
+ <NavItem route="logout" key="logout">
+ <Logout />
+ </NavItem>
+ ]
+ ) : (
+ <NavItem route="login">
+ <GitHubLink />
+ <Login visible={true} />
+ </NavItem>
+ )}
+ </ul>
+);
+
+const Navbar = ({ fullWidth, children }) => (
+ <nav
+ className="navbar fixed-top navbar-expand-lg navbar-light bg-faded"
+ id="navbar"
+ >
+ <div className={fullWidth ? "container-fluid" : "container"}>
+ <button
+ className="navbar-toggler navbar-toggler-right"
+ type="button"
+ data-toggle="collapse"
+ data-target="#navbarSupportedContent"
+ aria-controls="navbarSupportedContent"
+ aria-expanded="false"
+ aria-label="Toggle navigation"
+ >
+ <span className="navbar-toggler-icon" />
+ </button>
+ <Link
+ className="navbar-brand opendc-brand"
+ to="/"
+ title="OpenDC"
+ onClick={() => window.scrollTo(0, 0)}
+ >
+ <img src="/img/logo.png" alt="OpenDC" />
+ </Link>
+
+ <div className="collapse navbar-collapse" id="navbarSupportedContent">
+ <ul className="navbar-nav mr-auto">{children}</ul>
+ <LoggedInSection />
+ </div>
+ </div>
+ </nav>
+);
+
+export default Navbar;
diff --git a/frontend/src/components/navigation/Navbar.sass b/frontend/src/components/navigation/Navbar.sass
new file mode 100644
index 00000000..94c52936
--- /dev/null
+++ b/frontend/src/components/navigation/Navbar.sass
@@ -0,0 +1,29 @@
+@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
+ +clickable
+
+ &:hover
+ background: $blue-dark
diff --git a/frontend/src/components/not-found/BlinkingCursor.js b/frontend/src/components/not-found/BlinkingCursor.js
new file mode 100644
index 00000000..eea89e7b
--- /dev/null
+++ b/frontend/src/components/not-found/BlinkingCursor.js
@@ -0,0 +1,6 @@
+import React from "react";
+import "./BlinkingCursor.css";
+
+const BlinkingCursor = () => <span className="blinking-cursor">_</span>;
+
+export default BlinkingCursor;
diff --git a/frontend/src/components/not-found/BlinkingCursor.sass b/frontend/src/components/not-found/BlinkingCursor.sass
new file mode 100644
index 00000000..6be1476d
--- /dev/null
+++ b/frontend/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/frontend/src/components/not-found/CodeBlock.js b/frontend/src/components/not-found/CodeBlock.js
new file mode 100644
index 00000000..46dc4402
--- /dev/null
+++ b/frontend/src/components/not-found/CodeBlock.js
@@ -0,0 +1,34 @@
+import React from "react";
+import "./CodeBlock.css";
+
+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/frontend/src/components/not-found/CodeBlock.sass b/frontend/src/components/not-found/CodeBlock.sass
new file mode 100644
index 00000000..51a3d3d0
--- /dev/null
+++ b/frontend/src/components/not-found/CodeBlock.sass
@@ -0,0 +1,3 @@
+.code-block
+ white-space: pre-wrap
+ margin-top: 60px
diff --git a/frontend/src/components/not-found/TerminalWindow.js b/frontend/src/components/not-found/TerminalWindow.js
new file mode 100644
index 00000000..c6b8b78b
--- /dev/null
+++ b/frontend/src/components/not-found/TerminalWindow.js
@@ -0,0 +1,29 @@
+import React from "react";
+import { Link } from "react-router-dom";
+import BlinkingCursor from "./BlinkingCursor";
+import CodeBlock from "./CodeBlock";
+import "./TerminalWindow.css";
+
+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/frontend/src/components/not-found/TerminalWindow.sass b/frontend/src/components/not-found/TerminalWindow.sass
new file mode 100644
index 00000000..4f51a77f
--- /dev/null
+++ b/frontend/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/frontend/src/components/simulations/FilterButton.js b/frontend/src/components/simulations/FilterButton.js
new file mode 100644
index 00000000..aa41f180
--- /dev/null
+++ b/frontend/src/components/simulations/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/frontend/src/components/simulations/FilterPanel.js b/frontend/src/components/simulations/FilterPanel.js
new file mode 100644
index 00000000..836c0842
--- /dev/null
+++ b/frontend/src/components/simulations/FilterPanel.js
@@ -0,0 +1,13 @@
+import React from "react";
+import FilterLink from "../../containers/simulations/FilterLink";
+import "./FilterPanel.css";
+
+const FilterPanel = () => (
+ <div className="btn-group filter-panel mb-2">
+ <FilterLink filter="SHOW_ALL">All Simulations</FilterLink>
+ <FilterLink filter="SHOW_OWN">My Simulations</FilterLink>
+ <FilterLink filter="SHOW_SHARED">Shared with me</FilterLink>
+ </div>
+);
+
+export default FilterPanel;
diff --git a/frontend/src/components/simulations/FilterPanel.sass b/frontend/src/components/simulations/FilterPanel.sass
new file mode 100644
index 00000000..e10e4746
--- /dev/null
+++ b/frontend/src/components/simulations/FilterPanel.sass
@@ -0,0 +1,5 @@
+.filter-panel
+ display: flex
+
+ button
+ flex: 1 !important
diff --git a/frontend/src/components/simulations/NewSimulationButtonComponent.js b/frontend/src/components/simulations/NewSimulationButtonComponent.js
new file mode 100644
index 00000000..7e12d30f
--- /dev/null
+++ b/frontend/src/components/simulations/NewSimulationButtonComponent.js
@@ -0,0 +1,17 @@
+import PropTypes from "prop-types";
+import React from "react";
+
+const NewSimulationButtonComponent = ({ onClick }) => (
+ <div className="bottom-btn-container">
+ <div className="btn btn-primary float-right" onClick={onClick}>
+ <span className="fa fa-plus mr-2" />
+ New Simulation
+ </div>
+ </div>
+);
+
+NewSimulationButtonComponent.propTypes = {
+ onClick: PropTypes.func.isRequired
+};
+
+export default NewSimulationButtonComponent;
diff --git a/frontend/src/components/simulations/SimulationActionButtons.js b/frontend/src/components/simulations/SimulationActionButtons.js
new file mode 100644
index 00000000..46f4f159
--- /dev/null
+++ b/frontend/src/components/simulations/SimulationActionButtons.js
@@ -0,0 +1,37 @@
+import PropTypes from "prop-types";
+import React from "react";
+import { Link } from "react-router-dom";
+
+const SimulationActionButtons = ({ simulationId, onViewUsers, onDelete }) => (
+ <td className="text-right">
+ <Link
+ to={"/simulations/" + simulationId}
+ className="btn btn-outline-primary btn-sm mr-2"
+ title="Open this simulation"
+ >
+ <span className="fa fa-play" />
+ </Link>
+ <div
+ className="btn btn-outline-success btn-sm disabled mr-2"
+ title="View and edit collaborators (not supported yet)"
+ onClick={() => onViewUsers(simulationId)}
+ >
+ <span className="fa fa-users" />
+ </div>
+ <div
+ className="btn btn-outline-danger btn-sm"
+ title="Delete this simulation"
+ onClick={() => onDelete(simulationId)}
+ >
+ <span className="fa fa-trash" />
+ </div>
+ </td>
+);
+
+SimulationActionButtons.propTypes = {
+ simulationId: PropTypes.number.isRequired,
+ onViewUsers: PropTypes.func,
+ onDelete: PropTypes.func
+};
+
+export default SimulationActionButtons;
diff --git a/frontend/src/components/simulations/SimulationAuthList.js b/frontend/src/components/simulations/SimulationAuthList.js
new file mode 100644
index 00000000..f29dc96d
--- /dev/null
+++ b/frontend/src/components/simulations/SimulationAuthList.js
@@ -0,0 +1,43 @@
+import PropTypes from "prop-types";
+import React from "react";
+import Shapes from "../../shapes/index";
+import SimulationAuthRow from "./SimulationAuthRow";
+
+const SimulationAuthList = ({ 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 simulations here yet...</strong> Add some with the 'New
+ Simulation' button!
+ </div>
+ ) : (
+ <table className="table table-striped">
+ <thead>
+ <tr>
+ <th>Simulation name</th>
+ <th>Last edited</th>
+ <th>Access rights</th>
+ <th />
+ </tr>
+ </thead>
+ <tbody>
+ {authorizations.map(authorization => (
+ <SimulationAuthRow
+ simulationAuth={authorization}
+ key={authorization.simulation.id}
+ />
+ ))}
+ </tbody>
+ </table>
+ )}
+ </div>
+ );
+};
+
+SimulationAuthList.propTypes = {
+ authorizations: PropTypes.arrayOf(Shapes.Authorization).isRequired
+};
+
+export default SimulationAuthList;
diff --git a/frontend/src/components/simulations/SimulationAuthRow.js b/frontend/src/components/simulations/SimulationAuthRow.js
new file mode 100644
index 00000000..b638fbce
--- /dev/null
+++ b/frontend/src/components/simulations/SimulationAuthRow.js
@@ -0,0 +1,32 @@
+import classNames from "classnames";
+import React from "react";
+import SimulationActions from "../../containers/simulations/SimulationActions";
+import Shapes from "../../shapes/index";
+import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP } from "../../util/authorizations";
+import { parseAndFormatDateTime } from "../../util/date-time";
+
+const SimulationAuthRow = ({ simulationAuth }) => (
+ <tr>
+ <td className="pt-3">{simulationAuth.simulation.name}</td>
+ <td className="pt-3">
+ {parseAndFormatDateTime(simulationAuth.simulation.datetimeLastEdited)}
+ </td>
+ <td className="pt-3">
+ <span
+ className={classNames(
+ "fa",
+ "fa-" + AUTH_ICON_MAP[simulationAuth.authorizationLevel],
+ "mr-2"
+ )}
+ />
+ {AUTH_DESCRIPTION_MAP[simulationAuth.authorizationLevel]}
+ </td>
+ <SimulationActions simulationId={simulationAuth.simulation.id} />
+ </tr>
+);
+
+SimulationAuthRow.propTypes = {
+ simulationAuth: Shapes.Authorization.isRequired
+};
+
+export default SimulationAuthRow;
diff --git a/frontend/src/containers/app/map/DatacenterContainer.js b/frontend/src/containers/app/map/DatacenterContainer.js
new file mode 100644
index 00000000..125739f3
--- /dev/null
+++ b/frontend/src/containers/app/map/DatacenterContainer.js
@@ -0,0 +1,17 @@
+import { connect } from "react-redux";
+import DatacenterGroup from "../../../components/app/map/groups/DatacenterGroup";
+
+const mapStateToProps = state => {
+ if (state.currentDatacenterId === -1) {
+ return {};
+ }
+
+ return {
+ datacenter: state.objects.datacenter[state.currentDatacenterId],
+ interactionLevel: state.interactionLevel
+ };
+};
+
+const DatacenterContainer = connect(mapStateToProps)(DatacenterGroup);
+
+export default DatacenterContainer;
diff --git a/frontend/src/containers/app/map/GrayContainer.js b/frontend/src/containers/app/map/GrayContainer.js
new file mode 100644
index 00000000..d215bf6c
--- /dev/null
+++ b/frontend/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/frontend/src/containers/app/map/MapStage.js b/frontend/src/containers/app/map/MapStage.js
new file mode 100644
index 00000000..a8467171
--- /dev/null
+++ b/frontend/src/containers/app/map/MapStage.js
@@ -0,0 +1,31 @@
+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/frontend/src/containers/app/map/RackContainer.js b/frontend/src/containers/app/map/RackContainer.js
new file mode 100644
index 00000000..365bb062
--- /dev/null
+++ b/frontend/src/containers/app/map/RackContainer.js
@@ -0,0 +1,30 @@
+import { connect } from "react-redux";
+import RackGroup from "../../../components/app/map/groups/RackGroup";
+import { getStateLoad } from "../../../util/simulation-load";
+
+const mapStateToProps = (state, ownProps) => {
+ const inSimulation = state.currentExperimentId !== -1;
+
+ let rackLoad = undefined;
+ if (inSimulation) {
+ if (
+ state.states.rack[state.currentTick] &&
+ state.states.rack[state.currentTick][ownProps.tile.objectId]
+ ) {
+ rackLoad = getStateLoad(
+ state.loadMetric,
+ state.states.rack[state.currentTick][ownProps.tile.objectId]
+ );
+ }
+ }
+
+ return {
+ interactionLevel: state.interactionLevel,
+ inSimulation,
+ rackLoad
+ };
+};
+
+const RackContainer = connect(mapStateToProps)(RackGroup);
+
+export default RackContainer;
diff --git a/frontend/src/containers/app/map/RackEnergyFillContainer.js b/frontend/src/containers/app/map/RackEnergyFillContainer.js
new file mode 100644
index 00000000..0b7921d9
--- /dev/null
+++ b/frontend/src/containers/app/map/RackEnergyFillContainer.js
@@ -0,0 +1,40 @@
+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].objectId];
+ 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/frontend/src/containers/app/map/RackSpaceFillContainer.js b/frontend/src/containers/app/map/RackSpaceFillContainer.js
new file mode 100644
index 00000000..cc4d1251
--- /dev/null
+++ b/frontend/src/containers/app/map/RackSpaceFillContainer.js
@@ -0,0 +1,16 @@
+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].objectId].machineIds;
+ return {
+ type: "space",
+ fillFraction:
+ machineIds.filter(id => id !== null).length / machineIds.length
+ };
+};
+
+const RackSpaceFillContainer = connect(mapStateToProps)(RackFillBar);
+
+export default RackSpaceFillContainer;
diff --git a/frontend/src/containers/app/map/RoomContainer.js b/frontend/src/containers/app/map/RoomContainer.js
new file mode 100644
index 00000000..b83c7fa0
--- /dev/null
+++ b/frontend/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/frontend/src/containers/app/map/TileContainer.js b/frontend/src/containers/app/map/TileContainer.js
new file mode 100644
index 00000000..9e179924
--- /dev/null
+++ b/frontend/src/containers/app/map/TileContainer.js
@@ -0,0 +1,43 @@
+import { connect } from "react-redux";
+import { goFromRoomToRack } from "../../../actions/interaction-level";
+import TileGroup from "../../../components/app/map/groups/TileGroup";
+import { getStateLoad } from "../../../util/simulation-load";
+
+const mapStateToProps = (state, ownProps) => {
+ const tile = state.objects.tile[ownProps.tileId];
+ const inSimulation = state.currentExperimentId !== -1;
+
+ let roomLoad = undefined;
+ if (inSimulation) {
+ if (
+ state.states.room[state.currentTick] &&
+ state.states.room[state.currentTick][tile.roomId]
+ ) {
+ roomLoad = getStateLoad(
+ state.loadMetric,
+ state.states.room[state.currentTick][tile.roomId]
+ );
+ }
+ }
+
+ return {
+ interactionLevel: state.interactionLevel,
+ tile,
+ inSimulation,
+ roomLoad
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onClick: tile => {
+ if (tile.objectType) {
+ dispatch(goFromRoomToRack(tile.id));
+ }
+ }
+ };
+};
+
+const TileContainer = connect(mapStateToProps, mapDispatchToProps)(TileGroup);
+
+export default TileContainer;
diff --git a/frontend/src/containers/app/map/WallContainer.js b/frontend/src/containers/app/map/WallContainer.js
new file mode 100644
index 00000000..38192b05
--- /dev/null
+++ b/frontend/src/containers/app/map/WallContainer.js
@@ -0,0 +1,14 @@
+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/frontend/src/containers/app/map/controls/ScaleIndicatorContainer.js b/frontend/src/containers/app/map/controls/ScaleIndicatorContainer.js
new file mode 100644
index 00000000..f075cde5
--- /dev/null
+++ b/frontend/src/containers/app/map/controls/ScaleIndicatorContainer.js
@@ -0,0 +1,14 @@
+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/frontend/src/containers/app/map/controls/ZoomControlContainer.js b/frontend/src/containers/app/map/controls/ZoomControlContainer.js
new file mode 100644
index 00000000..50910bd6
--- /dev/null
+++ b/frontend/src/containers/app/map/controls/ZoomControlContainer.js
@@ -0,0 +1,21 @@
+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/frontend/src/containers/app/map/layers/MapLayer.js b/frontend/src/containers/app/map/layers/MapLayer.js
new file mode 100644
index 00000000..cf971350
--- /dev/null
+++ b/frontend/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/frontend/src/containers/app/map/layers/ObjectHoverLayer.js b/frontend/src/containers/app/map/layers/ObjectHoverLayer.js
new file mode 100644
index 00000000..9b28575e
--- /dev/null
+++ b/frontend/src/containers/app/map/layers/ObjectHoverLayer.js
@@ -0,0 +1,37 @@
+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.objectType);
+ }
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onClick: (x, y) => dispatch(addRackToTile(x, y))
+ };
+};
+
+const ObjectHoverLayer = connect(mapStateToProps, mapDispatchToProps)(
+ ObjectHoverLayerComponent
+);
+
+export default ObjectHoverLayer;
diff --git a/frontend/src/containers/app/map/layers/RoomHoverLayer.js b/frontend/src/containers/app/map/layers/RoomHoverLayer.js
new file mode 100644
index 00000000..020102bf
--- /dev/null
+++ b/frontend/src/containers/app/map/layers/RoomHoverLayer.js
@@ -0,0 +1,55 @@
+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.datacenter[state.currentDatacenterId].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/frontend/src/containers/app/sidebars/elements/LoadBarContainer.js b/frontend/src/containers/app/sidebars/elements/LoadBarContainer.js
new file mode 100644
index 00000000..2e637f9a
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/elements/LoadBarContainer.js
@@ -0,0 +1,32 @@
+import { connect } from "react-redux";
+import LoadBarComponent from "../../../../components/app/sidebars/elements/LoadBarComponent";
+import { getStateLoad } from "../../../../util/simulation-load";
+
+const mapStateToProps = (state, ownProps) => {
+ let percent = 0;
+ let enabled = false;
+
+ const objectStates = state.states[ownProps.objectType];
+ if (
+ objectStates[state.currentTick] &&
+ objectStates[state.currentTick][ownProps.objectId]
+ ) {
+ percent = Math.floor(
+ 100 *
+ getStateLoad(
+ state.loadMetric,
+ objectStates[state.currentTick][ownProps.objectId]
+ )
+ );
+ enabled = true;
+ }
+
+ return {
+ percent,
+ enabled
+ };
+};
+
+const LoadBarContainer = connect(mapStateToProps)(LoadBarComponent);
+
+export default LoadBarContainer;
diff --git a/frontend/src/containers/app/sidebars/elements/LoadChartContainer.js b/frontend/src/containers/app/sidebars/elements/LoadChartContainer.js
new file mode 100644
index 00000000..57bfec38
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/elements/LoadChartContainer.js
@@ -0,0 +1,31 @@
+import { connect } from "react-redux";
+import LoadChartComponent from "../../../../components/app/sidebars/elements/LoadChartComponent";
+import { getStateLoad } from "../../../../util/simulation-load";
+
+const mapStateToProps = (state, ownProps) => {
+ const data = [];
+
+ if (state.lastSimulatedTick !== -1) {
+ const objectStates = state.states[ownProps.objectType];
+ Object.keys(objectStates).forEach(tick => {
+ if (objectStates[tick][ownProps.objectId]) {
+ data.push({
+ x: tick,
+ y: getStateLoad(
+ state.loadMetric,
+ objectStates[tick][ownProps.objectId]
+ )
+ });
+ }
+ });
+ }
+
+ return {
+ data,
+ currentTick: state.currentTick
+ };
+};
+
+const LoadChartContainer = connect(mapStateToProps)(LoadChartComponent);
+
+export default LoadChartContainer;
diff --git a/frontend/src/containers/app/sidebars/simulation/ExperimentMetadataContainer.js b/frontend/src/containers/app/sidebars/simulation/ExperimentMetadataContainer.js
new file mode 100644
index 00000000..25a0d9e9
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/simulation/ExperimentMetadataContainer.js
@@ -0,0 +1,38 @@
+import { connect } from "react-redux";
+import ExperimentMetadataComponent from "../../../../components/app/sidebars/simulation/ExperimentMetadataComponent";
+
+const mapStateToProps = state => {
+ if (!state.objects.experiment[state.currentExperimentId]) {
+ return {
+ experimentName: "Loading experiment",
+ pathName: "",
+ traceName: "",
+ schedulerName: ""
+ };
+ }
+
+ const path =
+ state.objects.path[
+ state.objects.experiment[state.currentExperimentId].pathId
+ ];
+ const pathName = path.name ? path.name : "Path " + path.id;
+
+ return {
+ experimentName: state.objects.experiment[state.currentExperimentId].name,
+ pathName,
+ traceName:
+ state.objects.trace[
+ state.objects.experiment[state.currentExperimentId].traceId
+ ].name,
+ schedulerName:
+ state.objects.scheduler[
+ state.objects.experiment[state.currentExperimentId].schedulerName
+ ].name
+ };
+};
+
+const ExperimentMetadataContainer = connect(mapStateToProps)(
+ ExperimentMetadataComponent
+);
+
+export default ExperimentMetadataContainer;
diff --git a/frontend/src/containers/app/sidebars/simulation/LoadMetricContainer.js b/frontend/src/containers/app/sidebars/simulation/LoadMetricContainer.js
new file mode 100644
index 00000000..0c66b582
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/simulation/LoadMetricContainer.js
@@ -0,0 +1,12 @@
+import { connect } from "react-redux";
+import LoadMetricComponent from "../../../../components/app/sidebars/simulation/LoadMetricComponent";
+
+const mapStateToProps = state => {
+ return {
+ loadMetric: state.loadMetric
+ };
+};
+
+const LoadMetricContainer = connect(mapStateToProps)(LoadMetricComponent);
+
+export default LoadMetricContainer;
diff --git a/frontend/src/containers/app/sidebars/simulation/TaskContainer.js b/frontend/src/containers/app/sidebars/simulation/TaskContainer.js
new file mode 100644
index 00000000..093d4266
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/simulation/TaskContainer.js
@@ -0,0 +1,26 @@
+import { connect } from "react-redux";
+import TaskComponent from "../../../../components/app/sidebars/simulation/TaskComponent";
+
+const mapStateToProps = (state, ownProps) => {
+ let flopsLeft = state.objects.task[ownProps.taskId].totalFlopCount;
+
+ if (
+ state.states.task[state.currentTick] &&
+ state.states.task[state.currentTick][ownProps.taskId]
+ ) {
+ flopsLeft = state.states.task[state.currentTick][ownProps.taskId].flopsLeft;
+ } else if (
+ state.objects.task[ownProps.taskId].startTick < state.currentTick
+ ) {
+ flopsLeft = 0;
+ }
+
+ return {
+ task: state.objects.task[ownProps.taskId],
+ flopsLeft
+ };
+};
+
+const TaskContainer = connect(mapStateToProps)(TaskComponent);
+
+export default TaskContainer;
diff --git a/frontend/src/containers/app/sidebars/simulation/TraceContainer.js b/frontend/src/containers/app/sidebars/simulation/TraceContainer.js
new file mode 100644
index 00000000..682b6cc9
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/simulation/TraceContainer.js
@@ -0,0 +1,25 @@
+import { connect } from "react-redux";
+import TraceComponent from "../../../../components/app/sidebars/simulation/TraceComponent";
+
+const mapStateToProps = state => {
+ if (
+ !state.objects.experiment[state.currentExperimentId] ||
+ !state.objects.trace[
+ state.objects.experiment[state.currentExperimentId].traceId
+ ].jobIds
+ ) {
+ return {
+ jobs: []
+ };
+ }
+
+ return {
+ jobs: state.objects.trace[
+ state.objects.experiment[state.currentExperimentId].traceId
+ ].jobIds.map(id => state.objects.job[id])
+ };
+};
+
+const TraceContainer = connect(mapStateToProps)(TraceComponent);
+
+export default TraceContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/TopologySidebar.js b/frontend/src/containers/app/sidebars/topology/TopologySidebar.js
new file mode 100644
index 00000000..31c902fc
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/TopologySidebar.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 TopologySidebar = connect(mapStateToProps)(TopologySidebarComponent);
+
+export default TopologySidebar;
diff --git a/frontend/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js b/frontend/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js
new file mode 100644
index 00000000..da24b8f0
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/building/BuildingSidebarContainer.js
@@ -0,0 +1,14 @@
+import { connect } from "react-redux";
+import BuildingSidebarComponent from "../../../../../components/app/sidebars/topology/building/BuildingSidebarComponent";
+
+const mapStateToProps = state => {
+ return {
+ inSimulation: state.currentExperimentId !== -1
+ };
+};
+
+const BuildingSidebarContainer = connect(mapStateToProps)(
+ BuildingSidebarComponent
+);
+
+export default BuildingSidebarContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js b/frontend/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js
new file mode 100644
index 00000000..bb64cbb4
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/building/NewRoomConstructionContainer.js
@@ -0,0 +1,27 @@
+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/frontend/src/containers/app/sidebars/topology/machine/BackToRackContainer.js b/frontend/src/containers/app/sidebars/topology/machine/BackToRackContainer.js
new file mode 100644
index 00000000..885c533d
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/machine/BackToRackContainer.js
@@ -0,0 +1,15 @@
+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/frontend/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js b/frontend/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js
new file mode 100644
index 00000000..f42c8ba7
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/machine/DeleteMachineContainer.js
@@ -0,0 +1,15 @@
+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/frontend/src/containers/app/sidebars/topology/machine/MachineNameContainer.js b/frontend/src/containers/app/sidebars/topology/machine/MachineNameContainer.js
new file mode 100644
index 00000000..05d2bf80
--- /dev/null
+++ b/frontend/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/frontend/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js b/frontend/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js
new file mode 100644
index 00000000..7729385e
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/machine/MachineSidebarContainer.js
@@ -0,0 +1,18 @@
+import { connect } from "react-redux";
+import MachineSidebarComponent from "../../../../../components/app/sidebars/topology/machine/MachineSidebarComponent";
+
+const mapStateToProps = state => {
+ return {
+ inSimulation: state.currentExperimentId !== -1,
+ machineId:
+ state.objects.rack[
+ state.objects.tile[state.interactionLevel.tileId].objectId
+ ].machineIds[state.interactionLevel.position - 1]
+ };
+};
+
+const MachineSidebarContainer = connect(mapStateToProps)(
+ MachineSidebarComponent
+);
+
+export default MachineSidebarContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/machine/UnitAddContainer.js b/frontend/src/containers/app/sidebars/topology/machine/UnitAddContainer.js
new file mode 100644
index 00000000..0e5a6073
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/machine/UnitAddContainer.js
@@ -0,0 +1,21 @@
+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/frontend/src/containers/app/sidebars/topology/machine/UnitContainer.js b/frontend/src/containers/app/sidebars/topology/machine/UnitContainer.js
new file mode 100644
index 00000000..a919e8d3
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/machine/UnitContainer.js
@@ -0,0 +1,22 @@
+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],
+ inSimulation: state.currentExperimentId !== -1
+ };
+};
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onDelete: () => dispatch(deleteUnit(ownProps.unitType, ownProps.index))
+ };
+};
+
+const UnitContainer = connect(mapStateToProps, mapDispatchToProps)(
+ UnitComponent
+);
+
+export default UnitContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/machine/UnitListContainer.js b/frontend/src/containers/app/sidebars/topology/machine/UnitListContainer.js
new file mode 100644
index 00000000..6554b8f8
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/machine/UnitListContainer.js
@@ -0,0 +1,18 @@
+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].objectId
+ ].machineIds[state.interactionLevel.position - 1]
+ ][ownProps.unitType + "Ids"],
+ inSimulation: state.currentExperimentId !== -1
+ };
+};
+
+const UnitListContainer = connect(mapStateToProps)(UnitListComponent);
+
+export default UnitListContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js b/frontend/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js
new file mode 100644
index 00000000..85d83877
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/machine/UnitTabsContainer.js
@@ -0,0 +1,12 @@
+import { connect } from "react-redux";
+import UnitTabsComponent from "../../../../../components/app/sidebars/topology/machine/UnitTabsComponent";
+
+const mapStateToProps = state => {
+ return {
+ inSimulation: state.currentExperimentId !== -1
+ };
+};
+
+const UnitTabsContainer = connect(mapStateToProps)(UnitTabsComponent);
+
+export default UnitTabsContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js b/frontend/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js
new file mode 100644
index 00000000..1b1bb2b0
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/rack/BackToRoomContainer.js
@@ -0,0 +1,15 @@
+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/frontend/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js b/frontend/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js
new file mode 100644
index 00000000..a54ceb23
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/rack/DeleteRackContainer.js
@@ -0,0 +1,15 @@
+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/frontend/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js b/frontend/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js
new file mode 100644
index 00000000..527805a2
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/rack/EmptySlotContainer.js
@@ -0,0 +1,21 @@
+import { connect } from "react-redux";
+import { addMachine } from "../../../../../actions/topology/rack";
+import EmptySlotComponent from "../../../../../components/app/sidebars/topology/rack/EmptySlotComponent";
+
+const mapStateToProps = state => {
+ return {
+ inSimulation: state.currentExperimentId !== -1
+ };
+};
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onAdd: () => dispatch(addMachine(ownProps.position))
+ };
+};
+
+const EmptySlotContainer = connect(mapStateToProps, mapDispatchToProps)(
+ EmptySlotComponent
+);
+
+export default EmptySlotContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/rack/MachineContainer.js b/frontend/src/containers/app/sidebars/topology/rack/MachineContainer.js
new file mode 100644
index 00000000..8cd177e7
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/rack/MachineContainer.js
@@ -0,0 +1,40 @@
+import { connect } from "react-redux";
+import { goFromRackToMachine } from "../../../../../actions/interaction-level";
+import MachineComponent from "../../../../../components/app/sidebars/topology/rack/MachineComponent";
+import { getStateLoad } from "../../../../../util/simulation-load";
+
+const mapStateToProps = (state, ownProps) => {
+ const machine = state.objects.machine[ownProps.machineId];
+ const inSimulation = state.currentExperimentId !== -1;
+
+ let machineLoad = undefined;
+ if (inSimulation) {
+ if (
+ state.states.machine[state.currentTick] &&
+ state.states.machine[state.currentTick][machine.id]
+ ) {
+ machineLoad = getStateLoad(
+ state.loadMetric,
+ state.states.machine[state.currentTick][machine.id]
+ );
+ }
+ }
+
+ return {
+ machine,
+ inSimulation,
+ machineLoad
+ };
+};
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ return {
+ onClick: () => dispatch(goFromRackToMachine(ownProps.position))
+ };
+};
+
+const MachineContainer = connect(mapStateToProps, mapDispatchToProps)(
+ MachineComponent
+);
+
+export default MachineContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/rack/MachineListContainer.js b/frontend/src/containers/app/sidebars/topology/rack/MachineListContainer.js
new file mode 100644
index 00000000..b19a50ae
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/rack/MachineListContainer.js
@@ -0,0 +1,15 @@
+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].objectId
+ ].machineIds
+ };
+};
+
+const MachineListContainer = connect(mapStateToProps)(MachineListComponent);
+
+export default MachineListContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/rack/RackNameContainer.js b/frontend/src/containers/app/sidebars/topology/rack/RackNameContainer.js
new file mode 100644
index 00000000..8f364ca0
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/rack/RackNameContainer.js
@@ -0,0 +1,24 @@
+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].objectId
+ ].name
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onEdit: () => dispatch(openEditRackNameModal())
+ };
+};
+
+const RackNameContainer = connect(mapStateToProps, mapDispatchToProps)(
+ RackNameComponent
+);
+
+export default RackNameContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js b/frontend/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js
new file mode 100644
index 00000000..0a2bfdcc
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/rack/RackSidebarContainer.js
@@ -0,0 +1,13 @@
+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].objectId,
+ inSimulation: state.currentExperimentId !== -1
+ };
+};
+
+const RackSidebarContainer = connect(mapStateToProps)(RackSidebarComponent);
+
+export default RackSidebarContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js b/frontend/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js
new file mode 100644
index 00000000..02288b7b
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/room/BackToBuildingContainer.js
@@ -0,0 +1,15 @@
+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/frontend/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js b/frontend/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js
new file mode 100644
index 00000000..5223061d
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/room/DeleteRoomContainer.js
@@ -0,0 +1,15 @@
+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/frontend/src/containers/app/sidebars/topology/room/EditRoomContainer.js b/frontend/src/containers/app/sidebars/topology/room/EditRoomContainer.js
new file mode 100644
index 00000000..81052f54
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/room/EditRoomContainer.js
@@ -0,0 +1,26 @@
+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/frontend/src/containers/app/sidebars/topology/room/RackConstructionContainer.js b/frontend/src/containers/app/sidebars/topology/room/RackConstructionContainer.js
new file mode 100644
index 00000000..c784d3ae
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/room/RackConstructionContainer.js
@@ -0,0 +1,26 @@
+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/frontend/src/containers/app/sidebars/topology/room/RoomNameContainer.js b/frontend/src/containers/app/sidebars/topology/room/RoomNameContainer.js
new file mode 100644
index 00000000..36125521
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/room/RoomNameContainer.js
@@ -0,0 +1,21 @@
+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/frontend/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js b/frontend/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js
new file mode 100644
index 00000000..38d5fb80
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/room/RoomSidebarContainer.js
@@ -0,0 +1,14 @@
+import { connect } from "react-redux";
+import RoomSidebarComponent from "../../../../../components/app/sidebars/topology/room/RoomSidebarComponent";
+
+const mapStateToProps = state => {
+ return {
+ roomId: state.interactionLevel.roomId,
+ roomType: state.objects.room[state.interactionLevel.roomId].roomType,
+ inSimulation: state.currentExperimentId !== -1
+ };
+};
+
+const RoomSidebarContainer = connect(mapStateToProps)(RoomSidebarComponent);
+
+export default RoomSidebarContainer;
diff --git a/frontend/src/containers/app/sidebars/topology/room/RoomTypeContainer.js b/frontend/src/containers/app/sidebars/topology/room/RoomTypeContainer.js
new file mode 100644
index 00000000..414852f1
--- /dev/null
+++ b/frontend/src/containers/app/sidebars/topology/room/RoomTypeContainer.js
@@ -0,0 +1,12 @@
+import { connect } from "react-redux";
+import RoomTypeComponent from "../../../../../components/app/sidebars/topology/room/RoomTypeComponent";
+
+const mapStateToProps = state => {
+ return {
+ roomType: state.objects.room[state.interactionLevel.roomId].roomType
+ };
+};
+
+const RoomNameContainer = connect(mapStateToProps)(RoomTypeComponent);
+
+export default RoomNameContainer;
diff --git a/frontend/src/containers/app/timeline/PlayButtonContainer.js b/frontend/src/containers/app/timeline/PlayButtonContainer.js
new file mode 100644
index 00000000..4e3c3d81
--- /dev/null
+++ b/frontend/src/containers/app/timeline/PlayButtonContainer.js
@@ -0,0 +1,27 @@
+import { connect } from "react-redux";
+import {
+ pauseSimulation,
+ playSimulation
+} from "../../../actions/simulation/playback";
+import PlayButtonComponent from "../../../components/app/timeline/PlayButtonComponent";
+
+const mapStateToProps = state => {
+ return {
+ isPlaying: state.isPlaying,
+ currentTick: state.currentTick,
+ lastSimulatedTick: state.lastSimulatedTick
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onPlay: () => dispatch(playSimulation()),
+ onPause: () => dispatch(pauseSimulation())
+ };
+};
+
+const PlayButtonContainer = connect(mapStateToProps, mapDispatchToProps)(
+ PlayButtonComponent
+);
+
+export default PlayButtonContainer;
diff --git a/frontend/src/containers/app/timeline/TimelineContainer.js b/frontend/src/containers/app/timeline/TimelineContainer.js
new file mode 100644
index 00000000..74d37d58
--- /dev/null
+++ b/frontend/src/containers/app/timeline/TimelineContainer.js
@@ -0,0 +1,41 @@
+import { connect } from "react-redux";
+import { pauseSimulation } from "../../../actions/simulation/playback";
+import { incrementTick } from "../../../actions/simulation/tick";
+import { setCurrentDatacenter } from "../../../actions/topology/building";
+import TimelineComponent from "../../../components/app/timeline/TimelineComponent";
+
+const mapStateToProps = state => {
+ let sections = [];
+ if (state.currentExperimentId !== -1) {
+ const sectionIds =
+ state.objects.path[
+ state.objects.experiment[state.currentExperimentId].pathId
+ ].sectionIds;
+
+ if (sectionIds) {
+ sections = sectionIds.map(sectionId => state.objects.section[sectionId]);
+ }
+ }
+
+ return {
+ isPlaying: state.isPlaying,
+ currentTick: state.currentTick,
+ lastSimulatedTick: state.lastSimulatedTick,
+ currentDatacenterId: state.currentDatacenterId,
+ sections
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ incrementTick: () => dispatch(incrementTick()),
+ pauseSimulation: () => dispatch(pauseSimulation()),
+ setCurrentDatacenter: id => dispatch(setCurrentDatacenter(id))
+ };
+};
+
+const TimelineContainer = connect(mapStateToProps, mapDispatchToProps)(
+ TimelineComponent
+);
+
+export default TimelineContainer;
diff --git a/frontend/src/containers/app/timeline/TimelineControlsContainer.js b/frontend/src/containers/app/timeline/TimelineControlsContainer.js
new file mode 100644
index 00000000..ac851b2e
--- /dev/null
+++ b/frontend/src/containers/app/timeline/TimelineControlsContainer.js
@@ -0,0 +1,36 @@
+import { connect } from "react-redux";
+import { goToTick } from "../../../actions/simulation/tick";
+import TimelineControlsComponent from "../../../components/app/timeline/TimelineControlsComponent";
+
+const mapStateToProps = state => {
+ let sectionTicks = [];
+ if (state.currentExperimentId !== -1) {
+ const sectionIds =
+ state.objects.path[
+ state.objects.experiment[state.currentExperimentId].pathId
+ ].sectionIds;
+ if (sectionIds) {
+ sectionTicks = sectionIds
+ .filter(sectionId => state.objects.section[sectionId].startTick !== 0)
+ .map(sectionId => state.objects.section[sectionId].startTick);
+ }
+ }
+
+ return {
+ currentTick: state.currentTick,
+ lastSimulatedTick: state.lastSimulatedTick,
+ sectionTicks
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ goToTick: tick => dispatch(goToTick(tick))
+ };
+};
+
+const TimelineControlsContainer = connect(mapStateToProps, mapDispatchToProps)(
+ TimelineControlsComponent
+);
+
+export default TimelineControlsContainer;
diff --git a/frontend/src/containers/app/timeline/TimelineLabelsContainer.js b/frontend/src/containers/app/timeline/TimelineLabelsContainer.js
new file mode 100644
index 00000000..9d7f268d
--- /dev/null
+++ b/frontend/src/containers/app/timeline/TimelineLabelsContainer.js
@@ -0,0 +1,15 @@
+import { connect } from "react-redux";
+import TimelineLabelsComponent from "../../../components/app/timeline/TimelineLabelsComponent";
+
+const mapStateToProps = state => {
+ return {
+ currentTick: state.currentTick,
+ lastSimulatedTick: state.lastSimulatedTick
+ };
+};
+
+const TimelineLabelsContainer = connect(mapStateToProps)(
+ TimelineLabelsComponent
+);
+
+export default TimelineLabelsContainer;
diff --git a/frontend/src/containers/auth/Login.js b/frontend/src/containers/auth/Login.js
new file mode 100644
index 00000000..15af8e62
--- /dev/null
+++ b/frontend/src/containers/auth/Login.js
@@ -0,0 +1,65 @@
+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>
+ )}
+ />
+ );
+ }
+}
+
+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/frontend/src/containers/auth/Logout.js b/frontend/src/containers/auth/Logout.js
new file mode 100644
index 00000000..918932f6
--- /dev/null
+++ b/frontend/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/frontend/src/containers/auth/ProfileName.js b/frontend/src/containers/auth/ProfileName.js
new file mode 100644
index 00000000..21941bd2
--- /dev/null
+++ b/frontend/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/frontend/src/containers/experiments/ExperimentListContainer.js b/frontend/src/containers/experiments/ExperimentListContainer.js
new file mode 100644
index 00000000..53bb1dad
--- /dev/null
+++ b/frontend/src/containers/experiments/ExperimentListContainer.js
@@ -0,0 +1,28 @@
+import { connect } from "react-redux";
+import ExperimentListComponent from "../../components/experiments/ExperimentListComponent";
+
+const mapStateToProps = state => {
+ if (
+ state.currentSimulationId === -1 ||
+ !("experimentIds" in state.objects.simulation[state.currentSimulationId])
+ ) {
+ return {
+ loading: true,
+ experimentIds: []
+ };
+ }
+
+ const experimentIds =
+ state.objects.simulation[state.currentSimulationId].experimentIds;
+ if (experimentIds) {
+ return {
+ experimentIds
+ };
+ }
+};
+
+const ExperimentListContainer = connect(mapStateToProps)(
+ ExperimentListComponent
+);
+
+export default ExperimentListContainer;
diff --git a/frontend/src/containers/experiments/ExperimentRowContainer.js b/frontend/src/containers/experiments/ExperimentRowContainer.js
new file mode 100644
index 00000000..96ebc3db
--- /dev/null
+++ b/frontend/src/containers/experiments/ExperimentRowContainer.js
@@ -0,0 +1,30 @@
+import { connect } from "react-redux";
+import { deleteExperiment } from "../../actions/experiments";
+import ExperimentRowComponent from "../../components/experiments/ExperimentRowComponent";
+
+const mapStateToProps = (state, ownProps) => {
+ const experiment = Object.assign(
+ {},
+ state.objects.experiment[ownProps.experimentId]
+ );
+ experiment.trace = state.objects.trace[experiment.traceId];
+ experiment.scheduler = state.objects.scheduler[experiment.schedulerName];
+ experiment.path = state.objects.path[experiment.pathId];
+
+ return {
+ experiment,
+ simulationId: state.currentSimulationId
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onDelete: id => dispatch(deleteExperiment(id))
+ };
+};
+
+const ExperimentRowContainer = connect(mapStateToProps, mapDispatchToProps)(
+ ExperimentRowComponent
+);
+
+export default ExperimentRowContainer;
diff --git a/frontend/src/containers/experiments/NewExperimentButtonContainer.js b/frontend/src/containers/experiments/NewExperimentButtonContainer.js
new file mode 100644
index 00000000..60eb92a6
--- /dev/null
+++ b/frontend/src/containers/experiments/NewExperimentButtonContainer.js
@@ -0,0 +1,15 @@
+import { connect } from "react-redux";
+import { openNewExperimentModal } from "../../actions/modals/experiments";
+import NewExperimentButtonComponent from "../../components/experiments/NewExperimentButtonComponent";
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onClick: () => dispatch(openNewExperimentModal())
+ };
+};
+
+const NewExperimentButtonContainer = connect(undefined, mapDispatchToProps)(
+ NewExperimentButtonComponent
+);
+
+export default NewExperimentButtonContainer;
diff --git a/frontend/src/containers/modals/DeleteMachineModal.js b/frontend/src/containers/modals/DeleteMachineModal.js
new file mode 100644
index 00000000..eba37833
--- /dev/null
+++ b/frontend/src/containers/modals/DeleteMachineModal.js
@@ -0,0 +1,37 @@
+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/frontend/src/containers/modals/DeleteProfileModal.js b/frontend/src/containers/modals/DeleteProfileModal.js
new file mode 100644
index 00000000..674e9408
--- /dev/null
+++ b/frontend/src/containers/modals/DeleteProfileModal.js
@@ -0,0 +1,37 @@
+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/frontend/src/containers/modals/DeleteRackModal.js b/frontend/src/containers/modals/DeleteRackModal.js
new file mode 100644
index 00000000..41bacb37
--- /dev/null
+++ b/frontend/src/containers/modals/DeleteRackModal.js
@@ -0,0 +1,37 @@
+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/frontend/src/containers/modals/DeleteRoomModal.js b/frontend/src/containers/modals/DeleteRoomModal.js
new file mode 100644
index 00000000..339ff22c
--- /dev/null
+++ b/frontend/src/containers/modals/DeleteRoomModal.js
@@ -0,0 +1,37 @@
+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/frontend/src/containers/modals/EditRackNameModal.js b/frontend/src/containers/modals/EditRackNameModal.js
new file mode 100644
index 00000000..748e847b
--- /dev/null
+++ b/frontend/src/containers/modals/EditRackNameModal.js
@@ -0,0 +1,44 @@
+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].objectId
+ ].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/frontend/src/containers/modals/EditRoomNameModal.js b/frontend/src/containers/modals/EditRoomNameModal.js
new file mode 100644
index 00000000..be6c547c
--- /dev/null
+++ b/frontend/src/containers/modals/EditRoomNameModal.js
@@ -0,0 +1,42 @@
+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/frontend/src/containers/modals/NewExperimentModal.js b/frontend/src/containers/modals/NewExperimentModal.js
new file mode 100644
index 00000000..c703c39a
--- /dev/null
+++ b/frontend/src/containers/modals/NewExperimentModal.js
@@ -0,0 +1,39 @@
+import { connect } from "react-redux";
+import { addExperiment } from "../../actions/experiments";
+import { closeNewExperimentModal } from "../../actions/modals/experiments";
+import NewExperimentModalComponent from "../../components/modals/custom-components/NewExperimentModalComponent";
+
+const mapStateToProps = state => {
+ return {
+ show: state.modals.newExperimentModalVisible,
+ paths: Object.values(state.objects.path).filter(
+ path => path.simulationId === state.currentSimulationId
+ ),
+ traces: Object.values(state.objects.trace),
+ schedulers: Object.values(state.objects.scheduler)
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ callback: (name, pathId, traceId, schedulerName) => {
+ if (name) {
+ dispatch(
+ addExperiment({
+ name,
+ pathId,
+ traceId,
+ schedulerName
+ })
+ );
+ }
+ dispatch(closeNewExperimentModal());
+ }
+ };
+};
+
+const NewExperimentModal = connect(mapStateToProps, mapDispatchToProps)(
+ NewExperimentModalComponent
+);
+
+export default NewExperimentModal;
diff --git a/frontend/src/containers/modals/NewSimulationModal.js b/frontend/src/containers/modals/NewSimulationModal.js
new file mode 100644
index 00000000..80789cd2
--- /dev/null
+++ b/frontend/src/containers/modals/NewSimulationModal.js
@@ -0,0 +1,37 @@
+import React from "react";
+import { connect } from "react-redux";
+import { closeNewSimulationModal } from "../../actions/modals/simulations";
+import { addSimulation } from "../../actions/simulations";
+import TextInputModal from "../../components/modals/TextInputModal";
+
+const NewSimulationModalComponent = ({ visible, callback }) => (
+ <TextInputModal
+ title="New Simulation"
+ label="Simulation title"
+ show={visible}
+ callback={callback}
+ />
+);
+
+const mapStateToProps = state => {
+ return {
+ visible: state.modals.newSimulationModalVisible
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ callback: text => {
+ if (text) {
+ dispatch(addSimulation(text));
+ }
+ dispatch(closeNewSimulationModal());
+ }
+ };
+};
+
+const NewSimulationModal = connect(mapStateToProps, mapDispatchToProps)(
+ NewSimulationModalComponent
+);
+
+export default NewSimulationModal;
diff --git a/frontend/src/containers/simulations/FilterLink.js b/frontend/src/containers/simulations/FilterLink.js
new file mode 100644
index 00000000..2c5f4ed5
--- /dev/null
+++ b/frontend/src/containers/simulations/FilterLink.js
@@ -0,0 +1,19 @@
+import { connect } from "react-redux";
+import { setAuthVisibilityFilter } from "../../actions/simulations";
+import FilterButton from "../../components/simulations/FilterButton";
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ active: state.simulationList.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/frontend/src/containers/simulations/NewSimulationButtonContainer.js b/frontend/src/containers/simulations/NewSimulationButtonContainer.js
new file mode 100644
index 00000000..3ea04d24
--- /dev/null
+++ b/frontend/src/containers/simulations/NewSimulationButtonContainer.js
@@ -0,0 +1,15 @@
+import { connect } from "react-redux";
+import { openNewSimulationModal } from "../../actions/modals/simulations";
+import NewSimulationButtonComponent from "../../components/simulations/NewSimulationButtonComponent";
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onClick: () => dispatch(openNewSimulationModal())
+ };
+};
+
+const NewSimulationButtonContainer = connect(undefined, mapDispatchToProps)(
+ NewSimulationButtonComponent
+);
+
+export default NewSimulationButtonContainer;
diff --git a/frontend/src/containers/simulations/SimulationActions.js b/frontend/src/containers/simulations/SimulationActions.js
new file mode 100644
index 00000000..32243eff
--- /dev/null
+++ b/frontend/src/containers/simulations/SimulationActions.js
@@ -0,0 +1,22 @@
+import { connect } from "react-redux";
+import { deleteSimulation } from "../../actions/simulations";
+import SimulationActionButtons from "../../components/simulations/SimulationActionButtons";
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ simulationId: ownProps.simulationId
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onViewUsers: id => {}, // TODO implement user viewing
+ onDelete: id => dispatch(deleteSimulation(id))
+ };
+};
+
+const SimulationActions = connect(mapStateToProps, mapDispatchToProps)(
+ SimulationActionButtons
+);
+
+export default SimulationActions;
diff --git a/frontend/src/containers/simulations/VisibleSimulationAuthList.js b/frontend/src/containers/simulations/VisibleSimulationAuthList.js
new file mode 100644
index 00000000..ffc74d9e
--- /dev/null
+++ b/frontend/src/containers/simulations/VisibleSimulationAuthList.js
@@ -0,0 +1,42 @@
+import { connect } from "react-redux";
+import SimulationList from "../../components/simulations/SimulationAuthList";
+
+const getVisibleSimulationAuths = (simulationAuths, filter) => {
+ switch (filter) {
+ case "SHOW_ALL":
+ return simulationAuths;
+ case "SHOW_OWN":
+ return simulationAuths.filter(
+ simulationAuth => simulationAuth.authorizationLevel === "OWN"
+ );
+ case "SHOW_SHARED":
+ return simulationAuths.filter(
+ simulationAuth => simulationAuth.authorizationLevel !== "OWN"
+ );
+ default:
+ return simulationAuths;
+ }
+};
+
+const mapStateToProps = state => {
+ const denormalizedAuthorizations = state.simulationList.authorizationsOfCurrentUser.map(
+ authorizationIds => {
+ const authorization = state.objects.authorization[authorizationIds];
+ authorization.user = state.objects.user[authorization.userId];
+ authorization.simulation =
+ state.objects.simulation[authorization.simulationId];
+ return authorization;
+ }
+ );
+
+ return {
+ authorizations: getVisibleSimulationAuths(
+ denormalizedAuthorizations,
+ state.simulationList.authVisibilityFilter
+ )
+ };
+};
+
+const VisibleSimulationAuthList = connect(mapStateToProps)(SimulationList);
+
+export default VisibleSimulationAuthList;
diff --git a/frontend/src/index.js b/frontend/src/index.js
new file mode 100644
index 00000000..dad662c4
--- /dev/null
+++ b/frontend/src/index.js
@@ -0,0 +1,21 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { Provider } from "react-redux";
+import { setupSocketConnection } from "./api/socket";
+import "./index.css";
+import registerServiceWorker from "./registerServiceWorker";
+import Routes from "./routes";
+import configureStore from "./store/configure-store";
+
+setupSocketConnection(() => {
+ const store = configureStore();
+
+ ReactDOM.render(
+ <Provider store={store}>
+ <Routes />
+ </Provider>,
+ document.getElementById("root")
+ );
+
+ registerServiceWorker();
+});
diff --git a/frontend/src/index.sass b/frontend/src/index.sass
new file mode 100644
index 00000000..248987ab
--- /dev/null
+++ b/frontend/src/index.sass
@@ -0,0 +1,39 @@
+@import ./style-globals/_mixins.sass
+
+html, body, #root
+ margin: 0
+ padding: 0
+ width: 100%
+ height: 100%
+
+ font-family: Roboto, Helvetica, Verdana, sans-serif
+ background: #eee
+
+.full-height
+ position: relative
+ height: 100%
+
+.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
+
+.btn-circle
+ +border-radius(50%)
+
+a, a:hover
+ text-decoration: none
diff --git a/frontend/src/pages/App.js b/frontend/src/pages/App.js
new file mode 100644
index 00000000..ad201e7d
--- /dev/null
+++ b/frontend/src/pages/App.js
@@ -0,0 +1,125 @@
+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 { openExperimentSucceeded } from "../actions/experiments";
+import { openSimulationSucceeded } from "../actions/simulations";
+import { resetCurrentDatacenter } from "../actions/topology/building";
+import ToolPanelComponent from "../components/app/map/controls/ToolPanelComponent";
+import LoadingScreen from "../components/app/map/LoadingScreen";
+import SimulationSidebarComponent from "../components/app/sidebars/simulation/SimulationSidebarComponent";
+import AppNavbar from "../components/navigation/AppNavbar";
+import ScaleIndicatorContainer from "../containers/app/map/controls/ScaleIndicatorContainer";
+import MapStage from "../containers/app/map/MapStage";
+import TopologySidebar from "../containers/app/sidebars/topology/TopologySidebar";
+import TimelineContainer from "../containers/app/timeline/TimelineContainer";
+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";
+
+const shortcutManager = new ShortcutManager(KeymapConfiguration);
+
+class AppComponent extends React.Component {
+ static propTypes = {
+ simulationId: PropTypes.number.isRequired,
+ inSimulation: PropTypes.bool,
+ experimentId: PropTypes.number,
+ simulationName: PropTypes.string
+ };
+ static childContextTypes = {
+ shortcuts: PropTypes.object.isRequired
+ };
+
+ componentDidMount() {
+ this.props.resetCurrentDatacenter();
+ if (this.props.inSimulation) {
+ this.props.openExperimentSucceeded(
+ this.props.simulationId,
+ this.props.experimentId
+ );
+ return;
+ }
+ this.props.openSimulationSucceeded(this.props.simulationId);
+ }
+
+ getChildContext() {
+ return {
+ shortcuts: shortcutManager
+ };
+ }
+
+ render() {
+ return (
+ <DocumentTitle
+ title={
+ this.props.simulationName
+ ? this.props.simulationName + " - OpenDC"
+ : "Simulation - OpenDC"
+ }
+ >
+ <div className="page-container full-height">
+ <AppNavbar
+ simulationId={this.props.simulationId}
+ inSimulation={true}
+ fullWidth={true}
+ />
+ {this.props.datacenterIsLoading ? (
+ <div className="full-height d-flex align-items-center justify-content-center">
+ <LoadingScreen />
+ </div>
+ ) : (
+ <div className="full-height">
+ <MapStage />
+ <ScaleIndicatorContainer />
+ <ToolPanelComponent />
+ <TopologySidebar />
+ {this.props.inSimulation ? <TimelineContainer /> : undefined}
+ {this.props.inSimulation ? (
+ <SimulationSidebarComponent />
+ ) : (
+ undefined
+ )}
+ </div>
+ )}
+ <EditRoomNameModal />
+ <DeleteRoomModal />
+ <EditRackNameModal />
+ <DeleteRackModal />
+ <DeleteMachineModal />
+ </div>
+ </DocumentTitle>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ let simulationName = undefined;
+ if (
+ state.currentSimulationId !== -1 &&
+ state.objects.simulation[state.currentSimulationId]
+ ) {
+ simulationName = state.objects.simulation[state.currentSimulationId].name;
+ }
+
+ return {
+ datacenterIsLoading: state.currentDatacenterId === -1,
+ simulationName
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ resetCurrentDatacenter: () => dispatch(resetCurrentDatacenter()),
+ openSimulationSucceeded: id => dispatch(openSimulationSucceeded(id)),
+ openExperimentSucceeded: (simulationId, experimentId) =>
+ dispatch(openExperimentSucceeded(simulationId, experimentId))
+ };
+};
+
+const App = connect(mapStateToProps, mapDispatchToProps)(AppComponent);
+
+export default App;
diff --git a/frontend/src/pages/Experiments.js b/frontend/src/pages/Experiments.js
new file mode 100644
index 00000000..2f73cd7e
--- /dev/null
+++ b/frontend/src/pages/Experiments.js
@@ -0,0 +1,75 @@
+import PropTypes from "prop-types";
+import React from "react";
+import DocumentTitle from "react-document-title";
+import { connect } from "react-redux";
+import { fetchExperimentsOfSimulation } from "../actions/experiments";
+import { openSimulationSucceeded } from "../actions/simulations";
+import AppNavbar from "../components/navigation/AppNavbar";
+import ExperimentListContainer from "../containers/experiments/ExperimentListContainer";
+import NewExperimentButtonContainer from "../containers/experiments/NewExperimentButtonContainer";
+import NewExperimentModal from "../containers/modals/NewExperimentModal";
+
+class ExperimentsComponent extends React.Component {
+ static propTypes = {
+ simulationId: PropTypes.number.isRequired,
+ simulationName: PropTypes.string
+ };
+
+ componentDidMount() {
+ this.props.storeSimulationId(this.props.simulationId);
+ this.props.fetchExperimentsOfSimulation(this.props.simulationId);
+ }
+
+ render() {
+ return (
+ <DocumentTitle
+ title={
+ this.props.simulationName
+ ? "Experiments - " + this.props.simulationName + " - OpenDC"
+ : "Experiments - OpenDC"
+ }
+ >
+ <div className="full-height">
+ <AppNavbar
+ simulationId={this.props.simulationId}
+ inSimulation={true}
+ fullWidth={true}
+ />
+ <div className="container text-page-container full-height">
+ <ExperimentListContainer />
+ <NewExperimentButtonContainer />
+ </div>
+ <NewExperimentModal />
+ </div>
+ </DocumentTitle>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ let simulationName = undefined;
+ if (
+ state.currentSimulationId !== -1 &&
+ state.objects.simulation[state.currentSimulationId]
+ ) {
+ simulationName = state.objects.simulation[state.currentSimulationId].name;
+ }
+
+ return {
+ simulationName
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ storeSimulationId: id => dispatch(openSimulationSucceeded(id)),
+ fetchExperimentsOfSimulation: id =>
+ dispatch(fetchExperimentsOfSimulation(id))
+ };
+};
+
+const Experiments = connect(mapStateToProps, mapDispatchToProps)(
+ ExperimentsComponent
+);
+
+export default Experiments;
diff --git a/frontend/src/pages/Home.js b/frontend/src/pages/Home.js
new file mode 100644
index 00000000..f6479722
--- /dev/null
+++ b/frontend/src/pages/Home.js
@@ -0,0 +1,62 @@
+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 jQuery from "../util/jquery";
+import "./Home.css";
+
+class Home extends React.Component {
+ state = {
+ scrollSpySetup: false
+ };
+
+ componentDidMount() {
+ const scrollOffset = 60;
+ jQuery("#navbar")
+ .find("li a")
+ .click(function(e) {
+ if (jQuery(e.target).parents(".auth-links").length > 0) {
+ return;
+ }
+ e.preventDefault();
+ jQuery(jQuery(this).attr("href"))[0].scrollIntoView();
+ window.scrollBy(0, -scrollOffset);
+ });
+
+ if (!this.state.scrollSpySetup) {
+ jQuery("body").scrollspy({
+ target: "#navbar",
+ offset: scrollOffset
+ });
+ this.setState({ scrollSpySetup: true });
+ }
+ }
+
+ render() {
+ 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/frontend/src/pages/Home.sass b/frontend/src/pages/Home.sass
new file mode 100644
index 00000000..9c812db2
--- /dev/null
+++ b/frontend/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/frontend/src/pages/NotFound.js b/frontend/src/pages/NotFound.js
new file mode 100644
index 00000000..b344e923
--- /dev/null
+++ b/frontend/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.css";
+
+const NotFound = () => (
+ <DocumentTitle title="Page Not Found - OpenDC">
+ <div className="not-found-backdrop">
+ <TerminalWindow />
+ </div>
+ </DocumentTitle>
+);
+
+export default NotFound;
diff --git a/frontend/src/pages/NotFound.sass b/frontend/src/pages/NotFound.sass
new file mode 100644
index 00000000..9457da01
--- /dev/null
+++ b/frontend/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/frontend/src/pages/Profile.js b/frontend/src/pages/Profile.js
new file mode 100644
index 00000000..106ec97e
--- /dev/null
+++ b/frontend/src/pages/Profile.js
@@ -0,0 +1,40 @@
+import React from "react";
+import DocumentTitle from "react-document-title";
+import { connect } from "react-redux";
+import { openDeleteProfileModal } from "../actions/modals/profile";
+import AppNavbar from "../components/navigation/AppNavbar";
+import DeleteProfileModal from "../containers/modals/DeleteProfileModal";
+
+const ProfileContainer = ({ onDelete }) => (
+ <DocumentTitle title="My Profile - OpenDC">
+ <div className="full-height">
+ <AppNavbar inSimulation={false} 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 simulation info that is
+ associated with you (simulations 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/frontend/src/pages/Simulations.js b/frontend/src/pages/Simulations.js
new file mode 100644
index 00000000..ecff8fe6
--- /dev/null
+++ b/frontend/src/pages/Simulations.js
@@ -0,0 +1,46 @@
+import React from "react";
+import DocumentTitle from "react-document-title";
+import { connect } from "react-redux";
+import { openNewSimulationModal } from "../actions/modals/simulations";
+import { fetchAuthorizationsOfCurrentUser } from "../actions/users";
+import AppNavbar from "../components/navigation/AppNavbar";
+import SimulationFilterPanel from "../components/simulations/FilterPanel";
+import NewSimulationModal from "../containers/modals/NewSimulationModal";
+import NewSimulationButtonContainer from "../containers/simulations/NewSimulationButtonContainer";
+import VisibleSimulationList from "../containers/simulations/VisibleSimulationAuthList";
+
+class SimulationsContainer extends React.Component {
+ componentDidMount() {
+ this.props.fetchAuthorizationsOfCurrentUser();
+ }
+
+ render() {
+ return (
+ <DocumentTitle title="My Simulations - OpenDC">
+ <div className="full-height">
+ <AppNavbar inSimulation={false} fullWidth={false} />
+ <div className="container text-page-container full-height">
+ <SimulationFilterPanel />
+ <VisibleSimulationList />
+ <NewSimulationButtonContainer />
+ </div>
+ <NewSimulationModal />
+ </div>
+ </DocumentTitle>
+ );
+ }
+}
+
+const mapDispatchToProps = dispatch => {
+ return {
+ fetchAuthorizationsOfCurrentUser: () =>
+ dispatch(fetchAuthorizationsOfCurrentUser()),
+ openNewSimulationModal: () => dispatch(openNewSimulationModal())
+ };
+};
+
+const Simulations = connect(undefined, mapDispatchToProps)(
+ SimulationsContainer
+);
+
+export default Simulations;
diff --git a/frontend/src/reducers/auth.js b/frontend/src/reducers/auth.js
new file mode 100644
index 00000000..635929d4
--- /dev/null
+++ b/frontend/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/frontend/src/reducers/construction-mode.js b/frontend/src/reducers/construction-mode.js
new file mode 100644
index 00000000..b5e6e781
--- /dev/null
+++ b/frontend/src/reducers/construction-mode.js
@@ -0,0 +1,50 @@
+import { combineReducers } from "redux";
+import { OPEN_EXPERIMENT_SUCCEEDED } from "../actions/experiments";
+import { GO_DOWN_ONE_INTERACTION_LEVEL } from "../actions/interaction-level";
+import {
+ CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ FINISH_NEW_ROOM_CONSTRUCTION,
+ FINISH_ROOM_EDIT,
+ 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";
+
+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_EXPERIMENT_SUCCEEDED:
+ case FINISH_ROOM_EDIT:
+ 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_EXPERIMENT_SUCCEEDED:
+ case GO_DOWN_ONE_INTERACTION_LEVEL:
+ return false;
+ default:
+ return state;
+ }
+}
+
+export const construction = combineReducers({
+ currentRoomInConstruction,
+ inRackConstructionMode
+});
diff --git a/frontend/src/reducers/current-ids.js b/frontend/src/reducers/current-ids.js
new file mode 100644
index 00000000..4e16630d
--- /dev/null
+++ b/frontend/src/reducers/current-ids.js
@@ -0,0 +1,28 @@
+import { OPEN_EXPERIMENT_SUCCEEDED } from "../actions/experiments";
+import { OPEN_SIMULATION_SUCCEEDED } from "../actions/simulations";
+import {
+ RESET_CURRENT_DATACENTER,
+ SET_CURRENT_DATACENTER
+} from "../actions/topology/building";
+
+export function currentDatacenterId(state = -1, action) {
+ switch (action.type) {
+ case SET_CURRENT_DATACENTER:
+ return action.datacenterId;
+ case RESET_CURRENT_DATACENTER:
+ return -1;
+ default:
+ return state;
+ }
+}
+
+export function currentSimulationId(state = -1, action) {
+ switch (action.type) {
+ case OPEN_SIMULATION_SUCCEEDED:
+ return action.id;
+ case OPEN_EXPERIMENT_SUCCEEDED:
+ return action.simulationId;
+ default:
+ return state;
+ }
+}
diff --git a/frontend/src/reducers/index.js b/frontend/src/reducers/index.js
new file mode 100644
index 00000000..6f4d0c94
--- /dev/null
+++ b/frontend/src/reducers/index.js
@@ -0,0 +1,37 @@
+import { combineReducers } from "redux";
+import { auth } from "./auth";
+import { construction } from "./construction-mode";
+import { currentDatacenterId, currentSimulationId } from "./current-ids";
+import { interactionLevel } from "./interaction-level";
+import { map } from "./map";
+import { modals } from "./modals";
+import { objects } from "./objects";
+import { simulationList } from "./simulation-list";
+import {
+ currentExperimentId,
+ currentTick,
+ isPlaying,
+ lastSimulatedTick,
+ loadMetric
+} from "./simulation-mode";
+import { states } from "./states";
+
+const rootReducer = combineReducers({
+ objects,
+ states,
+ modals,
+ simulationList,
+ construction,
+ map,
+ currentSimulationId,
+ currentDatacenterId,
+ currentExperimentId,
+ currentTick,
+ lastSimulatedTick,
+ loadMetric,
+ isPlaying,
+ interactionLevel,
+ auth
+});
+
+export default rootReducer;
diff --git a/frontend/src/reducers/interaction-level.js b/frontend/src/reducers/interaction-level.js
new file mode 100644
index 00000000..581906c5
--- /dev/null
+++ b/frontend/src/reducers/interaction-level.js
@@ -0,0 +1,59 @@
+import { OPEN_EXPERIMENT_SUCCEEDED } from "../actions/experiments";
+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_SIMULATION_SUCCEEDED } from "../actions/simulations";
+import { SET_CURRENT_DATACENTER } from "../actions/topology/building";
+
+export function interactionLevel(state = { mode: "BUILDING" }, action) {
+ switch (action.type) {
+ case OPEN_EXPERIMENT_SUCCEEDED:
+ case OPEN_SIMULATION_SUCCEEDED:
+ case SET_CURRENT_DATACENTER:
+ 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/frontend/src/reducers/map.js b/frontend/src/reducers/map.js
new file mode 100644
index 00000000..b75dc051
--- /dev/null
+++ b/frontend/src/reducers/map.js
@@ -0,0 +1,39 @@
+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/frontend/src/reducers/modals.js b/frontend/src/reducers/modals.js
new file mode 100644
index 00000000..78527feb
--- /dev/null
+++ b/frontend/src/reducers/modals.js
@@ -0,0 +1,75 @@
+import { combineReducers } from "redux";
+import { OPEN_EXPERIMENT_SUCCEEDED } from "../actions/experiments";
+import {
+ CLOSE_NEW_EXPERIMENT_MODAL,
+ OPEN_NEW_EXPERIMENT_MODAL
+} from "../actions/modals/experiments";
+import {
+ CLOSE_DELETE_PROFILE_MODAL,
+ OPEN_DELETE_PROFILE_MODAL
+} from "../actions/modals/profile";
+import {
+ CLOSE_NEW_SIMULATION_MODAL,
+ OPEN_NEW_SIMULATION_MODAL
+} from "../actions/modals/simulations";
+import {
+ CLOSE_DELETE_MACHINE_MODAL,
+ CLOSE_DELETE_RACK_MODAL,
+ CLOSE_DELETE_ROOM_MODAL,
+ CLOSE_EDIT_RACK_NAME_MODAL,
+ CLOSE_EDIT_ROOM_NAME_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";
+
+function modal(openAction, closeAction) {
+ return function(state = false, action) {
+ switch (action.type) {
+ case openAction:
+ return true;
+ case closeAction:
+ case OPEN_EXPERIMENT_SUCCEEDED:
+ return false;
+ default:
+ return state;
+ }
+ };
+}
+
+export const modals = combineReducers({
+ newSimulationModalVisible: modal(
+ OPEN_NEW_SIMULATION_MODAL,
+ CLOSE_NEW_SIMULATION_MODAL
+ ),
+ deleteProfileModalVisible: modal(
+ OPEN_DELETE_PROFILE_MODAL,
+ CLOSE_DELETE_PROFILE_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
+ ),
+ newExperimentModalVisible: modal(
+ OPEN_NEW_EXPERIMENT_MODAL,
+ CLOSE_NEW_EXPERIMENT_MODAL
+ )
+});
diff --git a/frontend/src/reducers/objects.js b/frontend/src/reducers/objects.js
new file mode 100644
index 00000000..99d91092
--- /dev/null
+++ b/frontend/src/reducers/objects.js
@@ -0,0 +1,80 @@
+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";
+
+export const objects = combineReducers({
+ simulation: object("simulation"),
+ user: object("user"),
+ authorization: objectWithId("authorization", object => [
+ object.userId,
+ object.simulationId
+ ]),
+ failureModel: object("failureModel"),
+ cpu: object("cpu"),
+ gpu: object("gpu"),
+ memory: object("memory"),
+ storage: object("storage"),
+ machine: object("machine"),
+ rack: object("rack"),
+ coolingItem: object("coolingItem"),
+ psu: object("psu"),
+ tile: object("tile"),
+ room: object("room"),
+ datacenter: object("datacenter"),
+ section: object("section"),
+ path: object("path"),
+ task: object("task"),
+ job: object("job"),
+ trace: object("trace"),
+ scheduler: object("scheduler"),
+ experiment: object("experiment")
+});
+
+function object(type) {
+ return objectWithId(type, object => object.id);
+}
+
+function objectWithId(type, getId) {
+ return (state = {}, 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/frontend/src/reducers/simulation-list.js b/frontend/src/reducers/simulation-list.js
new file mode 100644
index 00000000..9afa3586
--- /dev/null
+++ b/frontend/src/reducers/simulation-list.js
@@ -0,0 +1,34 @@
+import { combineReducers } from "redux";
+import {
+ ADD_SIMULATION_SUCCEEDED,
+ DELETE_SIMULATION_SUCCEEDED,
+ SET_AUTH_VISIBILITY_FILTER
+} from "../actions/simulations";
+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_SIMULATION_SUCCEEDED:
+ return [...state, action.authorization];
+ case DELETE_SIMULATION_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 simulationList = combineReducers({
+ authorizationsOfCurrentUser,
+ authVisibilityFilter
+});
diff --git a/frontend/src/reducers/simulation-mode.js b/frontend/src/reducers/simulation-mode.js
new file mode 100644
index 00000000..02041468
--- /dev/null
+++ b/frontend/src/reducers/simulation-mode.js
@@ -0,0 +1,61 @@
+import { OPEN_EXPERIMENT_SUCCEEDED } from "../actions/experiments";
+import { CHANGE_LOAD_METRIC } from "../actions/simulation/load-metric";
+import { SET_PLAYING } from "../actions/simulation/playback";
+import {
+ GO_TO_TICK,
+ SET_LAST_SIMULATED_TICK
+} from "../actions/simulation/tick";
+import { OPEN_SIMULATION_SUCCEEDED } from "../actions/simulations";
+
+export function currentExperimentId(state = -1, action) {
+ switch (action.type) {
+ case OPEN_EXPERIMENT_SUCCEEDED:
+ return action.experimentId;
+ case OPEN_SIMULATION_SUCCEEDED:
+ return -1;
+ default:
+ return state;
+ }
+}
+
+export function currentTick(state = 0, action) {
+ switch (action.type) {
+ case GO_TO_TICK:
+ return action.tick;
+ case OPEN_EXPERIMENT_SUCCEEDED:
+ return 0;
+ default:
+ return state;
+ }
+}
+
+export function loadMetric(state = "LOAD", action) {
+ switch (action.type) {
+ case CHANGE_LOAD_METRIC:
+ return action.metric;
+ default:
+ return state;
+ }
+}
+
+export function isPlaying(state = false, action) {
+ switch (action.type) {
+ case SET_PLAYING:
+ return action.playing;
+ case OPEN_EXPERIMENT_SUCCEEDED:
+ return false;
+ default:
+ return state;
+ }
+}
+
+export function lastSimulatedTick(state = -1, action) {
+ switch (action.type) {
+ case SET_LAST_SIMULATED_TICK:
+ return action.tick;
+ case OPEN_EXPERIMENT_SUCCEEDED:
+ return -1;
+ default:
+ return state;
+ }
+}
diff --git a/frontend/src/reducers/states.js b/frontend/src/reducers/states.js
new file mode 100644
index 00000000..793f7b7d
--- /dev/null
+++ b/frontend/src/reducers/states.js
@@ -0,0 +1,33 @@
+import { combineReducers } from "redux";
+import { ADD_BATCH_TO_STATES } from "../actions/states";
+
+export const states = combineReducers({
+ task: objectStates("task"),
+ room: objectStates("room"),
+ rack: objectStates("rack"),
+ machine: objectStates("machine")
+});
+
+function objectStates(type) {
+ return (state = {}, action) => {
+ if (action.objectType !== type) {
+ return state;
+ }
+
+ if (action.type === ADD_BATCH_TO_STATES) {
+ const batch = {};
+ for (let i in action.objects) {
+ batch[action.objects[i].tick] = Object.assign(
+ {},
+ state[action.objects[i].tick],
+ batch[action.objects[i].tick],
+ { [action.objects[i][action.objectType + "Id"]]: action.objects[i] }
+ );
+ }
+
+ return Object.assign({}, state, batch);
+ }
+
+ return state;
+ };
+}
diff --git a/frontend/src/registerServiceWorker.js b/frontend/src/registerServiceWorker.js
new file mode 100644
index 00000000..0fe89a23
--- /dev/null
+++ b/frontend/src/registerServiceWorker.js
@@ -0,0 +1,108 @@
+// In production, we register a service worker to serve assets from local cache.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on the "N+1" visit to a page, since previously
+// cached resources are updated in the background.
+
+// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
+// This link also includes instructions on opting out of this behavior.
+
+const isLocalhost = Boolean(
+ window.location.hostname === "localhost" ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === "[::1]" ||
+ // 127.0.0.1/8 is considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+ )
+);
+
+export default function register() {
+ if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener("load", () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (!isLocalhost) {
+ // Is not local host. Just register service worker
+ registerValidSW(swUrl);
+ } else {
+ // This is running on localhost. Lets check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl);
+ }
+ });
+ }
+}
+
+function registerValidSW(swUrl) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === "installed") {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the old content will have been purged and
+ // the fresh content will have been added to the cache.
+ // It's the perfect time to display a "New content is
+ // available; please refresh." message in your web app.
+ console.log("New content is available; please refresh.");
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ console.log("Content is cached for offline use.");
+ }
+ }
+ };
+ };
+ })
+ .catch(error => {
+ console.error("Error during service worker registration:", error);
+ });
+}
+
+function checkValidServiceWorker(swUrl) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl)
+ .then(response => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ if (
+ response.status === 404 ||
+ response.headers.get("content-type").indexOf("javascript") === -1
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister().then(() => {
+ window.location.reload();
+ });
+ });
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl);
+ }
+ })
+ .catch(() => {
+ console.log(
+ "No internet connection found. App is running in offline mode."
+ );
+ });
+}
+
+export function unregister() {
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister();
+ });
+ }
+}
diff --git a/frontend/src/routes/index.js b/frontend/src/routes/index.js
new file mode 100644
index 00000000..f7523458
--- /dev/null
+++ b/frontend/src/routes/index.js
@@ -0,0 +1,64 @@
+import React from "react";
+import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom";
+import { userIsLoggedIn } from "../auth/index";
+import App from "../pages/App";
+import Experiments from "../pages/Experiments";
+import Home from "../pages/Home";
+import NotFound from "../pages/NotFound";
+import Profile from "../pages/Profile";
+import Simulations from "../pages/Simulations";
+
+const ProtectedComponent = component => () =>
+ userIsLoggedIn() ? component : <Redirect to="/" />;
+const AppComponent = ({ match }) =>
+ userIsLoggedIn() ? (
+ <App simulationId={parseInt(match.params.simulationId, 10)} />
+ ) : (
+ <Redirect to="/" />
+ );
+
+const ExperimentsComponent = ({ match }) =>
+ userIsLoggedIn() ? (
+ <Experiments simulationId={parseInt(match.params.simulationId, 10)} />
+ ) : (
+ <Redirect to="/" />
+ );
+
+const SimulationComponent = ({ match }) =>
+ userIsLoggedIn() ? (
+ <App
+ simulationId={parseInt(match.params.simulationId, 10)}
+ inSimulation={true}
+ experimentId={parseInt(match.params.experimentId, 10)}
+ />
+ ) : (
+ <Redirect to="/" />
+ );
+
+const Routes = () => (
+ <BrowserRouter>
+ <Switch>
+ <Route exact path="/" component={Home} />
+ <Route
+ exact
+ path="/simulations"
+ render={ProtectedComponent(<Simulations />)}
+ />
+ <Route exact path="/simulations/:simulationId" component={AppComponent} />
+ <Route
+ exact
+ path="/simulations/:simulationId/experiments"
+ component={ExperimentsComponent}
+ />
+ <Route
+ exact
+ path="/simulations/:simulationId/experiments/:experimentId"
+ component={SimulationComponent}
+ />
+ <Route exact path="/profile" render={ProtectedComponent(<Profile />)} />
+ <Route path="/*" component={NotFound} />
+ </Switch>
+ </BrowserRouter>
+);
+
+export default Routes;
diff --git a/frontend/src/sagas/experiments.js b/frontend/src/sagas/experiments.js
new file mode 100644
index 00000000..d9c410f7
--- /dev/null
+++ b/frontend/src/sagas/experiments.js
@@ -0,0 +1,183 @@
+import { call, put, select, delay } from "redux-saga/effects";
+import { addPropToStoreObject, addToStore } from "../actions/objects";
+import { setLastSimulatedTick } from "../actions/simulation/tick";
+import { addBatchToStates } from "../actions/states";
+import {
+ deleteExperiment,
+ getAllMachineStates,
+ getAllRackStates,
+ getAllRoomStates,
+ getAllTaskStates,
+ getExperiment,
+ getLastSimulatedTick
+} from "../api/routes/experiments";
+import { getTasksOfJob } from "../api/routes/jobs";
+import {
+ addExperiment,
+ getExperimentsOfSimulation,
+ getSimulation
+} from "../api/routes/simulations";
+import { getJobsOfTrace } from "../api/routes/traces";
+import {
+ fetchAndStoreAllSchedulers,
+ fetchAndStoreAllTraces,
+ fetchAndStorePathsOfSimulation
+} from "./objects";
+import { fetchAllDatacentersOfExperiment } from "./topology";
+
+export function* onOpenExperimentSucceeded(action) {
+ try {
+ const simulation = yield call(getSimulation, action.simulationId);
+ yield put(addToStore("simulation", simulation));
+
+ const experiment = yield call(getExperiment, action.experimentId);
+ yield put(addToStore("experiment", experiment));
+
+ yield fetchExperimentSpecifications();
+ yield fetchWorkloadOfTrace(experiment.traceId);
+
+ yield fetchAllDatacentersOfExperiment(experiment);
+ yield startStateFetchLoop(action.experimentId);
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+function* startStateFetchLoop(experimentId) {
+ try {
+ while ((yield select(state => state.currentExperimentId)) !== -1) {
+ const lastSimulatedTick = (yield call(getLastSimulatedTick, experimentId))
+ .lastSimulatedTick;
+ if (
+ lastSimulatedTick !== (yield select(state => state.lastSimulatedTick))
+ ) {
+ yield put(setLastSimulatedTick(lastSimulatedTick));
+
+ const taskStates = yield call(getAllTaskStates, experimentId);
+ const machineStates = yield call(getAllMachineStates, experimentId);
+ const rackStates = yield call(getAllRackStates, experimentId);
+ const roomStates = yield call(getAllRoomStates, experimentId);
+
+ yield put(addBatchToStates("task", taskStates));
+ yield put(addBatchToStates("machine", machineStates));
+ yield put(addBatchToStates("rack", rackStates));
+ yield put(addBatchToStates("room", roomStates));
+
+ yield delay(5000);
+ } else {
+ yield delay(10000);
+ }
+ }
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onFetchExperimentsOfSimulation() {
+ try {
+ const currentSimulationId = yield select(
+ state => state.currentSimulationId
+ );
+
+ yield fetchExperimentSpecifications();
+ const experiments = yield call(
+ getExperimentsOfSimulation,
+ currentSimulationId
+ );
+ for (let i in experiments) {
+ yield put(addToStore("experiment", experiments[i]));
+ }
+ yield put(
+ addPropToStoreObject("simulation", currentSimulationId, {
+ experimentIds: experiments.map(experiment => experiment.id)
+ })
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+function* fetchExperimentSpecifications() {
+ try {
+ const currentSimulationId = yield select(
+ state => state.currentSimulationId
+ );
+ yield fetchAndStorePathsOfSimulation(currentSimulationId);
+ yield fetchAndStoreAllTraces();
+ yield fetchAndStoreAllSchedulers();
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+function* fetchWorkloadOfTrace(traceId) {
+ try {
+ const jobs = yield call(getJobsOfTrace, traceId);
+ for (let i in jobs) {
+ const job = jobs[i];
+ const tasks = yield call(getTasksOfJob, job.id);
+ job.taskIds = tasks.map(task => task.id);
+ for (let j in tasks) {
+ yield put(addToStore("task", tasks[j]));
+ }
+ yield put(addToStore("job", job));
+ }
+ yield put(
+ addPropToStoreObject("trace", traceId, {
+ jobIds: jobs.map(job => job.id)
+ })
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onAddExperiment(action) {
+ try {
+ const currentSimulationId = yield select(
+ state => state.currentSimulationId
+ );
+
+ const experiment = yield call(
+ addExperiment,
+ currentSimulationId,
+ Object.assign({}, action.experiment, {
+ id: -1,
+ simulationId: currentSimulationId
+ })
+ );
+ yield put(addToStore("experiment", experiment));
+
+ const experimentIds = yield select(
+ state => state.objects.simulation[currentSimulationId].experimentIds
+ );
+ yield put(
+ addPropToStoreObject("simulation", currentSimulationId, {
+ experimentIds: experimentIds.concat([experiment.id])
+ })
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onDeleteExperiment(action) {
+ try {
+ yield call(deleteExperiment, action.id);
+
+ const currentSimulationId = yield select(
+ state => state.currentSimulationId
+ );
+ const experimentIds = yield select(
+ state => state.objects.simulation[currentSimulationId].experimentIds
+ );
+
+ yield put(
+ addPropToStoreObject("simulation", currentSimulationId, {
+ experimentIds: experimentIds.filter(id => id !== action.id)
+ })
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
diff --git a/frontend/src/sagas/index.js b/frontend/src/sagas/index.js
new file mode 100644
index 00000000..56c8f09b
--- /dev/null
+++ b/frontend/src/sagas/index.js
@@ -0,0 +1,106 @@
+import { takeEvery } from "redux-saga/effects";
+import { LOG_IN } from "../actions/auth";
+import {
+ ADD_EXPERIMENT,
+ DELETE_EXPERIMENT,
+ FETCH_EXPERIMENTS_OF_SIMULATION,
+ OPEN_EXPERIMENT_SUCCEEDED
+} from "../actions/experiments";
+import {
+ ADD_SIMULATION,
+ DELETE_SIMULATION,
+ OPEN_SIMULATION_SUCCEEDED
+} from "../actions/simulations";
+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 {
+ onAddExperiment,
+ onDeleteExperiment,
+ onFetchExperimentsOfSimulation,
+ onOpenExperimentSucceeded
+} from "./experiments";
+import { onDeleteCurrentUser } from "./profile";
+import {
+ onOpenSimulationSucceeded,
+ onSimulationAdd,
+ onSimulationDelete
+} from "./simulations";
+import {
+ onAddMachine,
+ onAddRackToTile,
+ onAddTile,
+ onAddUnit,
+ onCancelNewRoomConstruction,
+ onDeleteMachine,
+ onDeleteRack,
+ onDeleteRoom,
+ onDeleteTile,
+ onDeleteUnit,
+ onEditRackName,
+ onEditRoomName,
+ onStartNewRoomConstruction
+} from "./topology";
+import {
+ onFetchAuthorizationsOfCurrentUser,
+ onFetchLoggedInUser
+} from "./users";
+
+export default function* rootSaga() {
+ yield takeEvery(LOG_IN, onFetchLoggedInUser);
+
+ yield takeEvery(
+ FETCH_AUTHORIZATIONS_OF_CURRENT_USER,
+ onFetchAuthorizationsOfCurrentUser
+ );
+ yield takeEvery(ADD_SIMULATION, onSimulationAdd);
+ yield takeEvery(DELETE_SIMULATION, onSimulationDelete);
+
+ yield takeEvery(DELETE_CURRENT_USER, onDeleteCurrentUser);
+
+ yield takeEvery(OPEN_SIMULATION_SUCCEEDED, onOpenSimulationSucceeded);
+ yield takeEvery(OPEN_EXPERIMENT_SUCCEEDED, onOpenExperimentSucceeded);
+
+ 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(
+ FETCH_EXPERIMENTS_OF_SIMULATION,
+ onFetchExperimentsOfSimulation
+ );
+ yield takeEvery(ADD_EXPERIMENT, onAddExperiment);
+ yield takeEvery(DELETE_EXPERIMENT, onDeleteExperiment);
+}
diff --git a/frontend/src/sagas/objects.js b/frontend/src/sagas/objects.js
new file mode 100644
index 00000000..3cfd43a6
--- /dev/null
+++ b/frontend/src/sagas/objects.js
@@ -0,0 +1,140 @@
+import { call, put, select } from "redux-saga/effects";
+import { addToStore } from "../actions/objects";
+import { getDatacenter, getRoomsOfDatacenter } from "../api/routes/datacenters";
+import { getPath, getSectionsOfPath } from "../api/routes/paths";
+import { getTilesOfRoom } from "../api/routes/rooms";
+import { getAllSchedulers } from "../api/routes/schedulers";
+import { getSection } from "../api/routes/sections";
+import { getPathsOfSimulation, getSimulation } from "../api/routes/simulations";
+import {
+ getAllCPUs,
+ getAllGPUs,
+ getAllMemories,
+ getAllStorages,
+ getCoolingItem,
+ getCPU,
+ getFailureModel,
+ getGPU,
+ getMemory,
+ getPSU,
+ getStorage
+} from "../api/routes/specifications";
+import { getMachinesOfRackByTile, getRackByTile } from "../api/routes/tiles";
+import { getAllTraces } from "../api/routes/traces";
+import { getUser } from "../api/routes/users";
+
+export const OBJECT_SELECTORS = {
+ simulation: state => state.objects.simulation,
+ user: state => state.objects.user,
+ authorization: state => state.objects.authorization,
+ failureModel: state => state.objects.failureModel,
+ 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,
+ coolingItem: state => state.objects.coolingItem,
+ psu: state => state.objects.psu,
+ tile: state => state.objects.tile,
+ room: state => state.objects.room,
+ datacenter: state => state.objects.datacenter,
+ section: state => state.objects.section,
+ path: state => state.objects.path
+};
+
+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 index in objects) {
+ yield put(addToStore(objectType, objects[index]));
+ }
+ return objects;
+}
+
+export const fetchAndStoreSimulation = id =>
+ fetchAndStoreObject("simulation", id, call(getSimulation, id));
+
+export const fetchAndStoreUser = id =>
+ fetchAndStoreObject("user", id, call(getUser, id));
+
+export const fetchAndStoreFailureModel = id =>
+ fetchAndStoreObject("failureModel", id, call(getFailureModel, id));
+
+export const fetchAndStoreAllCPUs = () =>
+ fetchAndStoreObjects("cpu", call(getAllCPUs));
+
+export const fetchAndStoreCPU = id =>
+ fetchAndStoreObject("cpu", id, call(getCPU, id));
+
+export const fetchAndStoreAllGPUs = () =>
+ fetchAndStoreObjects("gpu", call(getAllGPUs));
+
+export const fetchAndStoreGPU = id =>
+ fetchAndStoreObject("gpu", id, call(getGPU, id));
+
+export const fetchAndStoreAllMemories = () =>
+ fetchAndStoreObjects("memory", call(getAllMemories));
+
+export const fetchAndStoreMemory = id =>
+ fetchAndStoreObject("memory", id, call(getMemory, id));
+
+export const fetchAndStoreAllStorages = () =>
+ fetchAndStoreObjects("storage", call(getAllStorages));
+
+export const fetchAndStoreStorage = id =>
+ fetchAndStoreObject("storage", id, call(getStorage, id));
+
+export const fetchAndStoreMachinesOfTile = tileId =>
+ fetchAndStoreObjects("machine", call(getMachinesOfRackByTile, tileId));
+
+export const fetchAndStoreRackOnTile = (id, tileId) =>
+ fetchAndStoreObject("rack", id, call(getRackByTile, tileId));
+
+export const fetchAndStoreCoolingItem = id =>
+ fetchAndStoreObject("coolingItem", id, call(getCoolingItem, id));
+
+export const fetchAndStorePSU = id =>
+ fetchAndStoreObject("psu", id, call(getPSU, id));
+
+export const fetchAndStoreTilesOfRoom = roomId =>
+ fetchAndStoreObjects("tile", call(getTilesOfRoom, roomId));
+
+export const fetchAndStoreRoomsOfDatacenter = datacenterId =>
+ fetchAndStoreObjects("room", call(getRoomsOfDatacenter, datacenterId));
+
+export const fetchAndStoreDatacenter = id =>
+ fetchAndStoreObject("datacenter", id, call(getDatacenter, id));
+
+export const fetchAndStoreSection = id =>
+ fetchAndStoreObject("section", id, call(getSection, id));
+
+export const fetchAndStoreSectionsOfPath = pathId =>
+ fetchAndStoreObjects("section", call(getSectionsOfPath, pathId));
+
+export const fetchAndStorePath = id =>
+ fetchAndStoreObject("path", id, call(getPath, id));
+
+export const fetchAndStorePathsOfSimulation = simulationId =>
+ fetchAndStoreObjects("path", call(getPathsOfSimulation, simulationId));
+
+export const fetchAndStoreAllTraces = () =>
+ fetchAndStoreObjects("trace", call(getAllTraces));
+
+export const fetchAndStoreAllSchedulers = function*() {
+ const objects = yield call(getAllSchedulers);
+ for (let index in objects) {
+ objects[index].id = objects[index].name;
+ yield put(addToStore("scheduler", objects[index]));
+ }
+ return objects;
+};
diff --git a/frontend/src/sagas/profile.js b/frontend/src/sagas/profile.js
new file mode 100644
index 00000000..31d4dd4f
--- /dev/null
+++ b/frontend/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/frontend/src/sagas/simulations.js b/frontend/src/sagas/simulations.js
new file mode 100644
index 00000000..9df4e4b5
--- /dev/null
+++ b/frontend/src/sagas/simulations.js
@@ -0,0 +1,51 @@
+import { call, put } from "redux-saga/effects";
+import { addToStore } from "../actions/objects";
+import {
+ addSimulationSucceeded,
+ deleteSimulationSucceeded
+} from "../actions/simulations";
+import {
+ addSimulation,
+ deleteSimulation,
+ getSimulation
+} from "../api/routes/simulations";
+import { fetchLatestDatacenter } from "./topology";
+
+export function* onOpenSimulationSucceeded(action) {
+ try {
+ const simulation = yield call(getSimulation, action.id);
+ yield put(addToStore("simulation", simulation));
+
+ yield fetchLatestDatacenter(action.id);
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onSimulationAdd(action) {
+ try {
+ const simulation = yield call(addSimulation, { name: action.name });
+ yield put(addToStore("simulation", simulation));
+
+ const authorization = {
+ simulationId: simulation.id,
+ userId: action.userId,
+ authorizationLevel: "OWN"
+ };
+ yield put(addToStore("authorization", authorization));
+ yield put(
+ addSimulationSucceeded([authorization.userId, authorization.simulationId])
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onSimulationDelete(action) {
+ try {
+ yield call(deleteSimulation, action.id);
+ yield put(deleteSimulationSucceeded(action.id));
+ } catch (error) {
+ console.error(error);
+ }
+}
diff --git a/frontend/src/sagas/topology.js b/frontend/src/sagas/topology.js
new file mode 100644
index 00000000..13b4ed17
--- /dev/null
+++ b/frontend/src/sagas/topology.js
@@ -0,0 +1,434 @@
+import { call, put, select } from "redux-saga/effects";
+import { goDownOneInteractionLevel } from "../actions/interaction-level";
+import {
+ addIdToStoreObjectListProp,
+ addPropToStoreObject,
+ addToStore,
+ removeIdFromStoreObjectListProp
+} from "../actions/objects";
+import {
+ cancelNewRoomConstructionSucceeded,
+ setCurrentDatacenter,
+ startNewRoomConstructionSucceeded
+} from "../actions/topology/building";
+import { addRoomToDatacenter } from "../api/routes/datacenters";
+import { addTileToRoom, deleteRoom, updateRoom } from "../api/routes/rooms";
+import {
+ addMachineToRackOnTile,
+ addRackToTile,
+ deleteMachineInRackOnTile,
+ deleteRackFromTile,
+ deleteTile,
+ updateMachineInRackOnTile,
+ updateRackOnTile
+} from "../api/routes/tiles";
+import {
+ DEFAULT_RACK_POWER_CAPACITY,
+ DEFAULT_RACK_SLOT_CAPACITY,
+ MAX_NUM_UNITS_PER_MACHINE
+} from "../components/app/map/MapConstants";
+import {
+ fetchAndStoreAllCPUs,
+ fetchAndStoreAllGPUs,
+ fetchAndStoreAllMemories,
+ fetchAndStoreAllStorages,
+ fetchAndStoreCoolingItem,
+ fetchAndStoreCPU,
+ fetchAndStoreDatacenter,
+ fetchAndStoreGPU,
+ fetchAndStoreMachinesOfTile,
+ fetchAndStoreMemory,
+ fetchAndStorePath,
+ fetchAndStorePathsOfSimulation,
+ fetchAndStorePSU,
+ fetchAndStoreRackOnTile,
+ fetchAndStoreRoomsOfDatacenter,
+ fetchAndStoreSectionsOfPath,
+ fetchAndStoreStorage,
+ fetchAndStoreTilesOfRoom
+} from "./objects";
+
+export function* fetchLatestDatacenter(simulationId) {
+ try {
+ const paths = yield fetchAndStorePathsOfSimulation(simulationId);
+ const latestPath = paths[paths.length - 1];
+ const sections = yield fetchAndStoreSectionsOfPath(latestPath.id);
+ const latestSection = sections[sections.length - 1];
+ yield fetchAllUnitSpecifications();
+ yield fetchDatacenter(latestSection.datacenterId);
+ yield put(setCurrentDatacenter(latestSection.datacenterId));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* fetchAllDatacentersOfExperiment(experiment) {
+ try {
+ const path = yield fetchAndStorePath(experiment.pathId);
+ const sections = yield fetchAndStoreSectionsOfPath(path.id);
+ path.sectionIds = sections.map(section => section.id);
+ yield fetchAllUnitSpecifications();
+
+ for (let i in sections) {
+ yield fetchDatacenter(sections[i].datacenterId);
+ }
+ yield put(setCurrentDatacenter(sections[0].datacenterId));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+function* fetchDatacenter(datacenterId) {
+ try {
+ yield fetchAndStoreDatacenter(datacenterId);
+ const rooms = yield fetchAndStoreRoomsOfDatacenter(datacenterId);
+ yield put(
+ addPropToStoreObject("datacenter", datacenterId, {
+ roomIds: rooms.map(room => room.id)
+ })
+ );
+
+ for (let index in rooms) {
+ yield fetchRoom(rooms[index].id);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+function* fetchAllUnitSpecifications() {
+ try {
+ yield fetchAndStoreAllCPUs();
+ yield fetchAndStoreAllGPUs();
+ yield fetchAndStoreAllMemories();
+ yield fetchAndStoreAllStorages();
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+function* fetchRoom(roomId) {
+ const tiles = yield fetchAndStoreTilesOfRoom(roomId);
+ yield put(
+ addPropToStoreObject("room", roomId, {
+ tileIds: tiles.map(tile => tile.id)
+ })
+ );
+
+ for (let index in tiles) {
+ yield fetchTile(tiles[index]);
+ }
+}
+
+function* fetchTile(tile) {
+ if (!tile.objectType) {
+ return;
+ }
+
+ switch (tile.objectType) {
+ case "RACK":
+ const rack = yield fetchAndStoreRackOnTile(tile.objectId, tile.id);
+ yield put(addPropToStoreObject("tile", tile.id, { rackId: rack.id }));
+ yield fetchMachinesOfRack(tile.id, rack);
+ break;
+ case "COOLING_ITEM":
+ const coolingItem = yield fetchAndStoreCoolingItem(tile.objectId);
+ yield put(
+ addPropToStoreObject("tile", tile.id, { coolingItemId: coolingItem.id })
+ );
+ break;
+ case "PSU":
+ const psu = yield fetchAndStorePSU(tile.objectId);
+ yield put(addPropToStoreObject("tile", tile.id, { psuId: psu.id }));
+ break;
+ default:
+ console.warn("Unknown rack type encountered while fetching tile objects");
+ }
+}
+
+function* fetchMachinesOfRack(tileId, rack) {
+ const machines = yield fetchAndStoreMachinesOfTile(tileId);
+ const machineIds = new Array(rack.capacity).fill(null);
+ machines.forEach(machine => (machineIds[machine.position - 1] = machine.id));
+
+ yield put(addPropToStoreObject("rack", rack.id, { machineIds }));
+
+ for (let index in machines) {
+ for (let i in machines[index].cpuIds) {
+ yield fetchAndStoreCPU(machines[index].cpuIds[i]);
+ }
+ for (let i in machines[index].gpuIds) {
+ yield fetchAndStoreGPU(machines[index].gpuIds[i]);
+ }
+ for (let i in machines[index].memoryIds) {
+ yield fetchAndStoreMemory(machines[index].memoryIds[i]);
+ }
+ for (let i in machines[index].storageIds) {
+ yield fetchAndStoreStorage(machines[index].storageIds[i]);
+ }
+ }
+}
+
+export function* onStartNewRoomConstruction() {
+ try {
+ const datacenterId = yield select(state => state.currentDatacenterId);
+ const room = yield call(addRoomToDatacenter, {
+ id: -1,
+ datacenterId,
+ roomType: "SERVER"
+ });
+ const roomWithEmptyTileList = Object.assign({}, room, { tileIds: [] });
+ yield put(addToStore("room", roomWithEmptyTileList));
+ yield put(
+ addIdToStoreObjectListProp("datacenter", datacenterId, "roomIds", room.id)
+ );
+ yield put(startNewRoomConstructionSucceeded(room.id));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onCancelNewRoomConstruction() {
+ try {
+ const datacenterId = yield select(state => state.currentDatacenterId);
+ const roomId = yield select(
+ state => state.construction.currentRoomInConstruction
+ );
+ yield call(deleteRoom, roomId);
+ yield put(
+ removeIdFromStoreObjectListProp(
+ "datacenter",
+ datacenterId,
+ "roomIds",
+ roomId
+ )
+ );
+ yield put(cancelNewRoomConstructionSucceeded());
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onAddTile(action) {
+ try {
+ const roomId = yield select(
+ state => state.construction.currentRoomInConstruction
+ );
+ const tile = yield call(addTileToRoom, {
+ roomId,
+ positionX: action.positionX,
+ positionY: action.positionY
+ });
+ yield put(addToStore("tile", tile));
+ yield put(addIdToStoreObjectListProp("room", roomId, "tileIds", tile.id));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onDeleteTile(action) {
+ try {
+ const roomId = yield select(
+ state => state.construction.currentRoomInConstruction
+ );
+ yield call(deleteTile, action.tileId);
+ yield put(
+ removeIdFromStoreObjectListProp("room", roomId, "tileIds", action.tileId)
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onEditRoomName(action) {
+ try {
+ const roomId = yield select(state => state.interactionLevel.roomId);
+ const room = Object.assign(
+ {},
+ yield select(state => state.objects.room[roomId])
+ );
+ room.name = action.name;
+ yield call(updateRoom, room);
+ yield put(addPropToStoreObject("room", roomId, { name: action.name }));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onDeleteRoom() {
+ try {
+ const datacenterId = yield select(state => state.currentDatacenterId);
+ const roomId = yield select(state => state.interactionLevel.roomId);
+ yield call(deleteRoom, roomId);
+ yield put(goDownOneInteractionLevel());
+ yield put(
+ removeIdFromStoreObjectListProp(
+ "datacenter",
+ datacenterId,
+ "roomIds",
+ roomId
+ )
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onEditRackName(action) {
+ try {
+ const tileId = yield select(state => state.interactionLevel.tileId);
+ const rackId = yield select(
+ state => state.objects.tile[state.interactionLevel.tileId].objectId
+ );
+ const rack = Object.assign(
+ {},
+ yield select(state => state.objects.rack[rackId])
+ );
+ rack.name = action.name;
+ yield call(updateRackOnTile, tileId, rack);
+ yield put(addPropToStoreObject("rack", rackId, { name: action.name }));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onDeleteRack() {
+ try {
+ const tileId = yield select(state => state.interactionLevel.tileId);
+ yield call(deleteRackFromTile, tileId);
+ yield put(goDownOneInteractionLevel());
+ yield put(addPropToStoreObject("tile", tileId, { objectType: undefined }));
+ yield put(addPropToStoreObject("tile", tileId, { objectId: undefined }));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onAddRackToTile(action) {
+ try {
+ const rack = yield call(addRackToTile, action.tileId, {
+ id: -1,
+ 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, { objectId: rack.id })
+ );
+ yield put(
+ addPropToStoreObject("tile", action.tileId, { objectType: "RACK" })
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onAddMachine(action) {
+ try {
+ const tileId = yield select(state => state.interactionLevel.tileId);
+ const rackId = yield select(
+ state => state.objects.tile[state.interactionLevel.tileId].objectId
+ );
+ const rack = yield select(state => state.objects.rack[rackId]);
+
+ const machine = yield call(addMachineToRackOnTile, tileId, {
+ id: -1,
+ rackId,
+ position: action.position,
+ tags: [],
+ 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 }));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onDeleteMachine() {
+ try {
+ 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].objectId]
+ );
+ yield call(deleteMachineInRackOnTile, tileId, position);
+ const machineIds = [...rack.machineIds];
+ machineIds[position - 1] = null;
+ yield put(goDownOneInteractionLevel());
+ yield put(addPropToStoreObject("rack", rack.id, { machineIds }));
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onAddUnit(action) {
+ try {
+ 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].objectId].machineIds[
+ position - 1
+ ]
+ ]
+ );
+
+ if (machine[action.unitType + "Ids"].length >= MAX_NUM_UNITS_PER_MACHINE) {
+ return;
+ }
+
+ const units = [...machine[action.unitType + "Ids"], action.id];
+ const updatedMachine = Object.assign({}, machine, {
+ [action.unitType + "Ids"]: units
+ });
+
+ yield call(updateMachineInRackOnTile, tileId, position, updatedMachine);
+
+ yield put(
+ addPropToStoreObject("machine", machine.id, {
+ [action.unitType + "Ids"]: units
+ })
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+export function* onDeleteUnit(action) {
+ try {
+ 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].objectId].machineIds[
+ position - 1
+ ]
+ ]
+ );
+ const unitIds = machine[action.unitType + "Ids"].slice();
+ unitIds.splice(action.index, 1);
+ const updatedMachine = Object.assign({}, machine, {
+ [action.unitType + "Ids"]: unitIds
+ });
+
+ yield call(updateMachineInRackOnTile, tileId, position, updatedMachine);
+ yield put(
+ addPropToStoreObject("machine", machine.id, {
+ [action.unitType + "Ids"]: unitIds
+ })
+ );
+ } catch (error) {
+ console.error(error);
+ }
+}
diff --git a/frontend/src/sagas/users.js b/frontend/src/sagas/users.js
new file mode 100644
index 00000000..3825443a
--- /dev/null
+++ b/frontend/src/sagas/users.js
@@ -0,0 +1,50 @@
+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, getAuthorizationsByUser } from "../api/routes/users";
+import { saveAuthLocalStorage } from "../auth/index";
+import { fetchAndStoreSimulation, 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 authorizations = yield call(getAuthorizationsByUser, action.userId);
+
+ for (const authorization of authorizations) {
+ yield put(addToStore("authorization", authorization));
+
+ yield fetchAndStoreSimulation(authorization.simulationId);
+ yield fetchAndStoreUser(authorization.userId);
+ }
+
+ const authorizationIds = authorizations.map(authorization => [
+ authorization.userId,
+ authorization.simulationId
+ ]);
+
+ yield put(fetchAuthorizationsOfCurrentUserSucceeded(authorizationIds));
+ } catch (error) {
+ console.error(error);
+ }
+}
diff --git a/frontend/src/shapes/index.js b/frontend/src/shapes/index.js
new file mode 100644
index 00000000..5570ef34
--- /dev/null
+++ b/frontend/src/shapes/index.js
@@ -0,0 +1,188 @@
+import PropTypes from "prop-types";
+
+const Shapes = {};
+
+Shapes.User = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ googleId: PropTypes.string.isRequired,
+ email: PropTypes.string.isRequired,
+ givenName: PropTypes.string.isRequired,
+ familyName: PropTypes.string.isRequired
+});
+
+Shapes.Simulation = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ datetimeCreated: PropTypes.string.isRequired,
+ datetimeLastEdited: PropTypes.string.isRequired
+});
+
+Shapes.Authorization = PropTypes.shape({
+ userId: PropTypes.number.isRequired,
+ user: Shapes.User,
+ simulationId: PropTypes.number.isRequired,
+ simulation: Shapes.Simulation,
+ authorizationLevel: PropTypes.string.isRequired
+});
+
+Shapes.FailureModel = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ rate: PropTypes.number.isRequired
+});
+
+Shapes.ProcessingUnit = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ manufacturer: PropTypes.string.isRequired,
+ family: PropTypes.string.isRequired,
+ generation: PropTypes.string.isRequired,
+ model: PropTypes.string.isRequired,
+ clockRateMhz: PropTypes.number.isRequired,
+ numberOfCores: PropTypes.number.isRequired,
+ energyConsumptionW: PropTypes.number.isRequired,
+ failureModelId: PropTypes.number.isRequired,
+ failureModel: Shapes.FailureModel
+});
+
+Shapes.StorageUnit = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ manufacturer: PropTypes.string.isRequired,
+ family: PropTypes.string.isRequired,
+ generation: PropTypes.string.isRequired,
+ model: PropTypes.string.isRequired,
+ speedMbPerS: PropTypes.number.isRequired,
+ sizeMb: PropTypes.number.isRequired,
+ energyConsumptionW: PropTypes.number.isRequired,
+ failureModelId: PropTypes.number.isRequired,
+ failureModel: Shapes.FailureModel
+});
+
+Shapes.Machine = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ rackId: PropTypes.number.isRequired,
+ position: PropTypes.number.isRequired,
+ cpuIds: PropTypes.arrayOf(PropTypes.number.isRequired),
+ cpus: PropTypes.arrayOf(Shapes.ProcessingUnit),
+ gpuIds: PropTypes.arrayOf(PropTypes.number.isRequired),
+ gpus: PropTypes.arrayOf(Shapes.ProcessingUnit),
+ memoryIds: PropTypes.arrayOf(PropTypes.number.isRequired),
+ memories: PropTypes.arrayOf(Shapes.StorageUnit),
+ storageIds: PropTypes.arrayOf(PropTypes.number.isRequired),
+ storages: PropTypes.arrayOf(Shapes.StorageUnit)
+});
+
+Shapes.Rack = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ capacity: PropTypes.number.isRequired,
+ powerCapacityW: PropTypes.number.isRequired,
+ machines: PropTypes.arrayOf(Shapes.Machine)
+});
+
+Shapes.CoolingItem = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ energyConsumptionW: PropTypes.number.isRequired,
+ type: PropTypes.string.isRequired,
+ failureModelId: PropTypes.number.isRequired,
+ failureModel: Shapes.FailureModel
+});
+
+Shapes.PSU = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ energyKwh: PropTypes.number.isRequired,
+ type: PropTypes.string.isRequired,
+ failureModelId: PropTypes.number.isRequired,
+ failureModel: Shapes.FailureModel
+});
+
+Shapes.Tile = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ roomId: PropTypes.number.isRequired,
+ positionX: PropTypes.number.isRequired,
+ positionY: PropTypes.number.isRequired,
+ objectId: PropTypes.number,
+ objectType: PropTypes.string,
+ rack: Shapes.Rack,
+ coolingItem: Shapes.CoolingItem,
+ psu: Shapes.PSU
+});
+
+Shapes.Room = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ datacenterId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ roomType: PropTypes.string.isRequired,
+ tiles: PropTypes.arrayOf(Shapes.Tile)
+});
+
+Shapes.Datacenter = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ rooms: PropTypes.arrayOf(Shapes.Room)
+});
+
+Shapes.Section = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ pathId: PropTypes.number.isRequired,
+ startTick: PropTypes.number.isRequired,
+ datacenterId: PropTypes.number.isRequired,
+ datacenter: Shapes.Datacenter
+});
+
+Shapes.Path = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ simulationId: PropTypes.number.isRequired,
+ name: PropTypes.string,
+ datetimeCreated: PropTypes.string.isRequired,
+ sections: PropTypes.arrayOf(Shapes.Section)
+});
+
+Shapes.Scheduler = PropTypes.shape({
+ name: PropTypes.string.isRequired
+});
+
+Shapes.Task = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ jobId: PropTypes.number.isRequired,
+ startTick: PropTypes.number.isRequired,
+ totalFlopCount: PropTypes.number.isRequired
+});
+
+Shapes.Job = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ traceId: PropTypes.number.isRequired,
+ taskIds: PropTypes.arrayOf(PropTypes.number)
+});
+
+Shapes.Trace = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ jobIds: PropTypes.arrayOf(PropTypes.number)
+});
+
+Shapes.Experiment = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ simulationId: PropTypes.number.isRequired,
+ traceId: PropTypes.number.isRequired,
+ trace: Shapes.Trace,
+ pathId: PropTypes.number.isRequired,
+ path: Shapes.Path,
+ schedulerName: PropTypes.string.isRequired,
+ scheduler: Shapes.Scheduler,
+ name: PropTypes.string.isRequired
+});
+
+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.number,
+ rackId: PropTypes.bool
+});
+
+export default Shapes;
diff --git a/frontend/src/shortcuts/keymap.js b/frontend/src/shortcuts/keymap.js
new file mode 100644
index 00000000..7bc24e83
--- /dev/null
+++ b/frontend/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/frontend/src/store/configure-store.js b/frontend/src/store/configure-store.js
new file mode 100644
index 00000000..29af25ab
--- /dev/null
+++ b/frontend/src/store/configure-store.js
@@ -0,0 +1,41 @@
+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/frontend/src/store/middlewares/dummy-middleware.js b/frontend/src/store/middlewares/dummy-middleware.js
new file mode 100644
index 00000000..468b15d1
--- /dev/null
+++ b/frontend/src/store/middlewares/dummy-middleware.js
@@ -0,0 +1,3 @@
+export const dummyMiddleware = store => next => action => {
+ next(action);
+};
diff --git a/frontend/src/store/middlewares/viewport-adjustment.js b/frontend/src/store/middlewares/viewport-adjustment.js
new file mode 100644
index 00000000..132391f3
--- /dev/null
+++ b/frontend/src/store/middlewares/viewport-adjustment.js
@@ -0,0 +1,90 @@
+import {
+ SET_MAP_DIMENSIONS,
+ setMapPosition,
+ setMapScale
+} from "../../actions/map";
+import { SET_CURRENT_DATACENTER } 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 datacenterId = -1;
+ let mapDimensions = {};
+ if (action.type === SET_CURRENT_DATACENTER && action.datacenterId !== -1) {
+ datacenterId = action.datacenterId;
+ mapDimensions = state.map.dimensions;
+ } else if (
+ action.type === SET_MAP_DIMENSIONS &&
+ state.currentDatacenterId !== -1
+ ) {
+ datacenterId = state.currentDatacenterId;
+ mapDimensions = { width: action.width, height: action.height };
+ }
+
+ if (datacenterId !== -1) {
+ const roomIds = state.objects.datacenter[datacenterId].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/frontend/src/style-globals/_mixins.sass b/frontend/src/style-globals/_mixins.sass
new file mode 100644
index 00000000..4ac5a9bc
--- /dev/null
+++ b/frontend/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/frontend/src/style-globals/_variables.sass b/frontend/src/style-globals/_variables.sass
new file mode 100644
index 00000000..00c2b479
--- /dev/null
+++ b/frontend/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: 250px
+$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/frontend/src/util/authorizations.js b/frontend/src/util/authorizations.js
new file mode 100644
index 00000000..ef649c9c
--- /dev/null
+++ b/frontend/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/frontend/src/util/colors.js b/frontend/src/util/colors.js
new file mode 100644
index 00000000..1e84e162
--- /dev/null
+++ b/frontend/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/frontend/src/util/date-time.js b/frontend/src/util/date-time.js
new file mode 100644
index 00000000..0b752600
--- /dev/null
+++ b/frontend/src/util/date-time.js
@@ -0,0 +1,104 @@
+/**
+ * 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/frontend/src/util/date-time.test.js b/frontend/src/util/date-time.test.js
new file mode 100644
index 00000000..6c7a6b16
--- /dev/null
+++ b/frontend/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/frontend/src/util/jquery.js b/frontend/src/util/jquery.js
new file mode 100644
index 00000000..12a64fc6
--- /dev/null
+++ b/frontend/src/util/jquery.js
@@ -0,0 +1,8 @@
+/**
+ * Binding of the global jQuery variable for use within React.
+ *
+ * This should be used instead of '$', to address ESLint warnings relating to undefined global variables.
+ */
+const jQuery = window["$"];
+
+export default jQuery;
diff --git a/frontend/src/util/room-types.js b/frontend/src/util/room-types.js
new file mode 100644
index 00000000..5cfe3887
--- /dev/null
+++ b/frontend/src/util/room-types.js
@@ -0,0 +1,7 @@
+export const ROOM_TYPE_TO_NAME_MAP = {
+ SERVER: "Server room",
+ HALLWAY: "Hallway",
+ OFFICE: "Office",
+ POWER: "Power room",
+ COOLING: "Cooling room"
+};
diff --git a/frontend/src/util/simulation-load.js b/frontend/src/util/simulation-load.js
new file mode 100644
index 00000000..95e17fed
--- /dev/null
+++ b/frontend/src/util/simulation-load.js
@@ -0,0 +1,37 @@
+import {
+ SIM_HIGH_COLOR,
+ SIM_LOW_COLOR,
+ SIM_MID_HIGH_COLOR,
+ SIM_MID_LOW_COLOR
+} from "./colors";
+
+export const LOAD_NAME_MAP = {
+ LOAD: "computational load",
+ TEMPERATURE: "temperature",
+ MEMORY: "memory use"
+};
+
+export function convertLoadToSimulationColor(load) {
+ if (load <= 0.25) {
+ return SIM_LOW_COLOR;
+ } else if (load <= 0.5) {
+ return SIM_MID_LOW_COLOR;
+ } else if (load <= 0.75) {
+ return SIM_MID_HIGH_COLOR;
+ } else {
+ return SIM_HIGH_COLOR;
+ }
+}
+
+export function getStateLoad(loadMetric, state) {
+ switch (loadMetric) {
+ case "LOAD":
+ return state.loadFraction;
+ case "TEMPERATURE":
+ return state.temperatureC / 100.0;
+ case "MEMORY":
+ return state.inUseMemoryMb / 10000.0;
+ default:
+ return -1;
+ }
+}
diff --git a/frontend/src/util/tile-calculations.js b/frontend/src/util/tile-calculations.js
new file mode 100644
index 00000000..95886eeb
--- /dev/null
+++ b/frontend/src/util/tile-calculations.js
@@ -0,0 +1,261 @@
+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/frontend/src/util/timeline.js b/frontend/src/util/timeline.js
new file mode 100644
index 00000000..e20d5823
--- /dev/null
+++ b/frontend/src/util/timeline.js
@@ -0,0 +1,19 @@
+export function convertTickToPercentage(tick, maxTick) {
+ if (maxTick === 0) {
+ return "0%";
+ } else if (tick > maxTick) {
+ return maxTick / (maxTick + 1) * 100 + "%";
+ }
+
+ return tick / (maxTick + 1) * 100 + "%";
+}
+
+export function getDatacenterIdOfTick(tick, sections) {
+ for (let i in sections.reverse()) {
+ if (tick >= sections[i].startTick) {
+ return sections[i].datacenterId;
+ }
+ }
+
+ return -1;
+}