summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-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
261 files changed, 9817 insertions, 0 deletions
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;
+}