summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-ui/src/redux
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2021-05-18 20:34:13 +0200
committerGitHub <noreply@github.com>2021-05-18 20:34:13 +0200
commit56bd2ef6b0583fee1dd2da5dceaf57feb07649c9 (patch)
tree6d4cfbc44c97cd3ec1e30aa977cd08f404b41b0d /opendc-web/opendc-web-ui/src/redux
parent02776c958a3254735b2be7d9fb1627f75e7f80cd (diff)
parentce95cfdf803043e66e2279d0f76c6bfc64e7864e (diff)
Migrate to Auth0 as Identity Provider
This pull request removes the hard dependency on Google for authenticating users and migrates to Auth0 as Identity Provider for OpenDC. This has as benefit that we can authenticate users without having to manage user data ourselves and do not have a dependency on Google accounts anymore. - Frontend cleanup: - Use CSS modules everywhere to encapsulate the styling of React components. - Perform all communication in the frontend via the REST API (as opposed to WebSockets). The original approach was aimed at collaborative editing, but made normal operations harder to implement and debug. If we want to implement collaborative editing in the future, we can expose only a small WebSocket API specifically for collaborative editing. - Move to FontAwesome 5 (using the official React libraries) - Use Reactstrap where possible. Previously, we mixed raw Bootstrap classes with Reactstrap, which is confusing. - Reduce the scope of the Redux state. Some state in the frontend application can be kept locally and does not need to be managed by Redux. - Migrate from Create React App (CRA) to Next.js since it allows us to pre-render multiple pages as well as opt-in to Server Side Rendering. - Remove the Google login and use Auth0 for authentication now. - Use Node 16 - Backend cleanup: - Remove Socket.IO endpoint from backend, since it is not needed by the frontend anymore. Removing it reduces the attack surface of OpenDC as well as the maintenance efforts. - Use Auth0 JWT token for authorizing API accesses - Refactor API endpoints to use Flask Restful as opposed to our custom in-house routing logic. Previously, this was needed to support the Socket.IO endpoint, but increases maintenance effort. - Expose Swagger UI from API - Use Python 3.9 and uwsgi to host Flask application - Actualize OpenAPI schema and update to version 3.0. **Breaking API Changes** * This pull request removes the users collection from the database table. Instead, we now use the user identifier passed by Auth0 to identify the data that belongs to a user.
Diffstat (limited to 'opendc-web/opendc-web-ui/src/redux')
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/interaction-level.js50
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/map.js83
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/objects.js41
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/portfolios.js41
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/prefabs.js32
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/projects.js55
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/scenarios.js43
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topologies.js17
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/building.js105
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/machine.js25
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js23
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/room.js48
-rw-r--r--opendc-web/opendc-web-ui/src/redux/index.js59
-rw-r--r--opendc-web/opendc-web-ui/src/redux/middleware/viewport-adjustment.js73
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/construction-mode.js52
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/current-ids.js54
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/index.js21
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/interaction-level.js61
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/map.js35
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/objects.js64
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/projects.js14
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/index.js72
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/objects.js234
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/portfolios.js136
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/prefabs.js16
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/projects.js53
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/scenarios.js69
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/topology.js314
28 files changed, 1890 insertions, 0 deletions
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/interaction-level.js b/opendc-web/opendc-web-ui/src/redux/actions/interaction-level.js
new file mode 100644
index 00000000..ff6b1fa3
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/interaction-level.js
@@ -0,0 +1,50 @@
+export const GO_FROM_BUILDING_TO_ROOM = 'GO_FROM_BUILDING_TO_ROOM'
+export const GO_FROM_ROOM_TO_RACK = 'GO_FROM_ROOM_TO_RACK'
+export const GO_FROM_RACK_TO_MACHINE = 'GO_FROM_RACK_TO_MACHINE'
+export const GO_DOWN_ONE_INTERACTION_LEVEL = 'GO_DOWN_ONE_INTERACTION_LEVEL'
+
+export function goFromBuildingToRoom(roomId) {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ if (interactionLevel.mode !== 'BUILDING') {
+ return
+ }
+
+ dispatch({
+ type: GO_FROM_BUILDING_TO_ROOM,
+ roomId,
+ })
+ }
+}
+
+export function goFromRoomToRack(tileId) {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ if (interactionLevel.mode !== 'ROOM') {
+ return
+ }
+ dispatch({
+ type: GO_FROM_ROOM_TO_RACK,
+ tileId,
+ })
+ }
+}
+
+export function goFromRackToMachine(position) {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ if (interactionLevel.mode !== 'RACK') {
+ return
+ }
+ dispatch({
+ type: GO_FROM_RACK_TO_MACHINE,
+ position,
+ })
+ }
+}
+
+export function goDownOneInteractionLevel() {
+ return {
+ type: GO_DOWN_ONE_INTERACTION_LEVEL,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/map.js b/opendc-web/opendc-web-ui/src/redux/actions/map.js
new file mode 100644
index 00000000..aa14cacd
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/map.js
@@ -0,0 +1,83 @@
+import {
+ MAP_MAX_SCALE,
+ MAP_MIN_SCALE,
+ MAP_SCALE_PER_EVENT,
+ MAP_SIZE_IN_PIXELS,
+} from '../../components/app/map/MapConstants'
+
+export const SET_MAP_POSITION = 'SET_MAP_POSITION'
+export const SET_MAP_DIMENSIONS = 'SET_MAP_DIMENSIONS'
+export const SET_MAP_SCALE = 'SET_MAP_SCALE'
+
+export function setMapPosition(x, y) {
+ return {
+ type: SET_MAP_POSITION,
+ x,
+ y,
+ }
+}
+
+export function setMapDimensions(width, height) {
+ return {
+ type: SET_MAP_DIMENSIONS,
+ width,
+ height,
+ }
+}
+
+export function setMapScale(scale) {
+ return {
+ type: SET_MAP_SCALE,
+ scale,
+ }
+}
+
+export function zoomInOnCenter(zoomIn) {
+ return (dispatch, getState) => {
+ const state = getState()
+
+ dispatch(zoomInOnPosition(zoomIn, state.map.dimensions.width / 2, state.map.dimensions.height / 2))
+ }
+}
+
+export function zoomInOnPosition(zoomIn, x, y) {
+ return (dispatch, getState) => {
+ const state = getState()
+
+ const centerPoint = {
+ x: x / state.map.scale - state.map.position.x / state.map.scale,
+ y: y / state.map.scale - state.map.position.y / state.map.scale,
+ }
+ const newScale = zoomIn ? state.map.scale * MAP_SCALE_PER_EVENT : state.map.scale / MAP_SCALE_PER_EVENT
+ const boundedScale = Math.min(Math.max(MAP_MIN_SCALE, newScale), MAP_MAX_SCALE)
+
+ const newX = -(centerPoint.x - x / boundedScale) * boundedScale
+ const newY = -(centerPoint.y - y / boundedScale) * boundedScale
+
+ dispatch(setMapPositionWithBoundsCheck(newX, newY))
+ dispatch(setMapScale(boundedScale))
+ }
+}
+
+export function setMapPositionWithBoundsCheck(x, y) {
+ return (dispatch, getState) => {
+ const state = getState()
+
+ const scaledMapSize = MAP_SIZE_IN_PIXELS * state.map.scale
+
+ const updatedX =
+ x > 0
+ ? 0
+ : x < -scaledMapSize + state.map.dimensions.width
+ ? -scaledMapSize + state.map.dimensions.width
+ : x
+ const updatedY =
+ y > 0
+ ? 0
+ : y < -scaledMapSize + state.map.dimensions.height
+ ? -scaledMapSize + state.map.dimensions.height
+ : y
+
+ dispatch(setMapPosition(updatedX, updatedY))
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/objects.js b/opendc-web/opendc-web-ui/src/redux/actions/objects.js
new file mode 100644
index 00000000..7b648b18
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/objects.js
@@ -0,0 +1,41 @@
+export const ADD_TO_STORE = 'ADD_TO_STORE'
+export const ADD_PROP_TO_STORE_OBJECT = 'ADD_PROP_TO_STORE_OBJECT'
+export const ADD_ID_TO_STORE_OBJECT_LIST_PROP = 'ADD_ID_TO_STORE_OBJECT_LIST_PROP'
+export const REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP = 'REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP'
+
+export function addToStore(objectType, object) {
+ return {
+ type: ADD_TO_STORE,
+ objectType,
+ object,
+ }
+}
+
+export function addPropToStoreObject(objectType, objectId, propObject) {
+ return {
+ type: ADD_PROP_TO_STORE_OBJECT,
+ objectType,
+ objectId,
+ propObject,
+ }
+}
+
+export function addIdToStoreObjectListProp(objectType, objectId, propName, id) {
+ return {
+ type: ADD_ID_TO_STORE_OBJECT_LIST_PROP,
+ objectType,
+ objectId,
+ propName,
+ id,
+ }
+}
+
+export function removeIdFromStoreObjectListProp(objectType, objectId, propName, id) {
+ return {
+ type: REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP,
+ objectType,
+ objectId,
+ propName,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/portfolios.js b/opendc-web/opendc-web-ui/src/redux/actions/portfolios.js
new file mode 100644
index 00000000..d37886d8
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/portfolios.js
@@ -0,0 +1,41 @@
+export const ADD_PORTFOLIO = 'ADD_PORTFOLIO'
+export const UPDATE_PORTFOLIO = 'UPDATE_PORTFOLIO'
+export const DELETE_PORTFOLIO = 'DELETE_PORTFOLIO'
+export const OPEN_PORTFOLIO_SUCCEEDED = 'OPEN_PORTFOLIO_SUCCEEDED'
+export const SET_CURRENT_PORTFOLIO = 'SET_CURRENT_PORTFOLIO'
+
+export function addPortfolio(portfolio) {
+ return {
+ type: ADD_PORTFOLIO,
+ portfolio,
+ }
+}
+
+export function updatePortfolio(portfolio) {
+ return {
+ type: UPDATE_PORTFOLIO,
+ portfolio,
+ }
+}
+
+export function deletePortfolio(id) {
+ return {
+ type: DELETE_PORTFOLIO,
+ id,
+ }
+}
+
+export function openPortfolioSucceeded(projectId, portfolioId) {
+ return {
+ type: OPEN_PORTFOLIO_SUCCEEDED,
+ projectId,
+ portfolioId,
+ }
+}
+
+export function setCurrentPortfolio(portfolioId) {
+ return {
+ type: SET_CURRENT_PORTFOLIO,
+ portfolioId,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/prefabs.js b/opendc-web/opendc-web-ui/src/redux/actions/prefabs.js
new file mode 100644
index 00000000..c112feed
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/prefabs.js
@@ -0,0 +1,32 @@
+export const ADD_PREFAB = 'ADD_PREFAB'
+export const DELETE_PREFAB = 'DELETE_PREFAB'
+export const DELETE_PREFAB_SUCCEEDED = 'DELETE_PREFAB_SUCCEEDED'
+export const OPEN_PREFAB_SUCCEEDED = 'OPEN_PREFAB_SUCCEEDED'
+
+export function addPrefab(name) {
+ return {
+ type: ADD_PREFAB,
+ name,
+ }
+}
+
+export function deletePrefab(id) {
+ return {
+ type: DELETE_PREFAB,
+ id,
+ }
+}
+
+export function deletePrefabSucceeded(id) {
+ return {
+ type: DELETE_PREFAB_SUCCEEDED,
+ id,
+ }
+}
+
+export function openPrefabSucceeded(id) {
+ return {
+ type: OPEN_PREFAB_SUCCEEDED,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/projects.js b/opendc-web/opendc-web-ui/src/redux/actions/projects.js
new file mode 100644
index 00000000..a6324c43
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/projects.js
@@ -0,0 +1,55 @@
+export const FETCH_PROJECTS = 'FETCH_PROJECTS'
+export const FETCH_PROJECTS_SUCCEEDED = 'FETCH_PROJECTS_SUCCEEDED'
+export const ADD_PROJECT = 'ADD_PROJECT'
+export const ADD_PROJECT_SUCCEEDED = 'ADD_PROJECT_SUCCEEDED'
+export const DELETE_PROJECT = 'DELETE_PROJECT'
+export const DELETE_PROJECT_SUCCEEDED = 'DELETE_PROJECT_SUCCEEDED'
+export const OPEN_PROJECT_SUCCEEDED = 'OPEN_PROJECT_SUCCEEDED'
+
+export function fetchProjects() {
+ return {
+ type: FETCH_PROJECTS,
+ }
+}
+
+export function fetchProjectsSucceeded(projects) {
+ return {
+ type: FETCH_PROJECTS_SUCCEEDED,
+ projects,
+ }
+}
+
+export function addProject(name) {
+ return {
+ type: ADD_PROJECT,
+ name,
+ }
+}
+
+export function addProjectSucceeded(project) {
+ return {
+ type: ADD_PROJECT_SUCCEEDED,
+ project,
+ }
+}
+
+export function deleteProject(id) {
+ return {
+ type: DELETE_PROJECT,
+ id,
+ }
+}
+
+export function deleteProjectSucceeded(id) {
+ return {
+ type: DELETE_PROJECT_SUCCEEDED,
+ id,
+ }
+}
+
+export function openProjectSucceeded(id) {
+ return {
+ type: OPEN_PROJECT_SUCCEEDED,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/scenarios.js b/opendc-web/opendc-web-ui/src/redux/actions/scenarios.js
new file mode 100644
index 00000000..c8a90762
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/scenarios.js
@@ -0,0 +1,43 @@
+export const ADD_SCENARIO = 'ADD_SCENARIO'
+export const UPDATE_SCENARIO = 'UPDATE_SCENARIO'
+export const DELETE_SCENARIO = 'DELETE_SCENARIO'
+export const OPEN_SCENARIO_SUCCEEDED = 'OPEN_SCENARIO_SUCCEEDED'
+export const SET_CURRENT_SCENARIO = 'SET_CURRENT_SCENARIO'
+
+export function addScenario(scenario) {
+ return {
+ type: ADD_SCENARIO,
+ scenario,
+ }
+}
+
+export function updateScenario(scenario) {
+ return {
+ type: UPDATE_SCENARIO,
+ scenario,
+ }
+}
+
+export function deleteScenario(id) {
+ return {
+ type: DELETE_SCENARIO,
+ id,
+ }
+}
+
+export function openScenarioSucceeded(projectId, portfolioId, scenarioId) {
+ return {
+ type: OPEN_SCENARIO_SUCCEEDED,
+ projectId,
+ portfolioId,
+ scenarioId,
+ }
+}
+
+export function setCurrentScenario(portfolioId, scenarioId) {
+ return {
+ type: SET_CURRENT_SCENARIO,
+ portfolioId,
+ scenarioId,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topologies.js b/opendc-web/opendc-web-ui/src/redux/actions/topologies.js
new file mode 100644
index 00000000..dcce3b7d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topologies.js
@@ -0,0 +1,17 @@
+export const ADD_TOPOLOGY = 'ADD_TOPOLOGY'
+export const DELETE_TOPOLOGY = 'DELETE_TOPOLOGY'
+
+export function addTopology(name, duplicateId) {
+ return {
+ type: ADD_TOPOLOGY,
+ name,
+ duplicateId,
+ }
+}
+
+export function deleteTopology(id) {
+ return {
+ type: DELETE_TOPOLOGY,
+ id,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js
new file mode 100644
index 00000000..72deda6f
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js
@@ -0,0 +1,105 @@
+export const SET_CURRENT_TOPOLOGY = 'SET_CURRENT_TOPOLOGY'
+export const START_NEW_ROOM_CONSTRUCTION = 'START_NEW_ROOM_CONSTRUCTION'
+export const START_NEW_ROOM_CONSTRUCTION_SUCCEEDED = 'START_NEW_ROOM_CONSTRUCTION_SUCCEEDED'
+export const FINISH_NEW_ROOM_CONSTRUCTION = 'FINISH_NEW_ROOM_CONSTRUCTION'
+export const CANCEL_NEW_ROOM_CONSTRUCTION = 'CANCEL_NEW_ROOM_CONSTRUCTION'
+export const CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED = 'CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED'
+export const START_ROOM_EDIT = 'START_ROOM_EDIT'
+export const FINISH_ROOM_EDIT = 'FINISH_ROOM_EDIT'
+export const ADD_TILE = 'ADD_TILE'
+export const DELETE_TILE = 'DELETE_TILE'
+
+export function setCurrentTopology(topologyId) {
+ return {
+ type: SET_CURRENT_TOPOLOGY,
+ topologyId,
+ }
+}
+
+export function startNewRoomConstruction() {
+ return {
+ type: START_NEW_ROOM_CONSTRUCTION,
+ }
+}
+
+export function startNewRoomConstructionSucceeded(roomId) {
+ return {
+ type: START_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ roomId,
+ }
+}
+
+export function finishNewRoomConstruction() {
+ return (dispatch, getState) => {
+ const { objects, construction } = getState()
+ if (objects.room[construction.currentRoomInConstruction].tileIds.length === 0) {
+ dispatch(cancelNewRoomConstruction())
+ return
+ }
+
+ dispatch({
+ type: FINISH_NEW_ROOM_CONSTRUCTION,
+ })
+ }
+}
+
+export function cancelNewRoomConstruction() {
+ return {
+ type: CANCEL_NEW_ROOM_CONSTRUCTION,
+ }
+}
+
+export function cancelNewRoomConstructionSucceeded() {
+ return {
+ type: CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ }
+}
+
+export function startRoomEdit() {
+ return (dispatch, getState) => {
+ const { interactionLevel } = getState()
+ dispatch({
+ type: START_ROOM_EDIT,
+ roomId: interactionLevel.roomId,
+ })
+ }
+}
+
+export function finishRoomEdit() {
+ return {
+ type: FINISH_ROOM_EDIT,
+ }
+}
+
+export function toggleTileAtLocation(positionX, positionY) {
+ return (dispatch, getState) => {
+ const { objects, construction } = getState()
+
+ const tileIds = objects.room[construction.currentRoomInConstruction].tileIds
+ for (let index in tileIds) {
+ if (
+ objects.tile[tileIds[index]].positionX === positionX &&
+ objects.tile[tileIds[index]].positionY === positionY
+ ) {
+ dispatch(deleteTile(tileIds[index]))
+ return
+ }
+ }
+ dispatch(addTile(positionX, positionY))
+ }
+}
+
+export function addTile(positionX, positionY) {
+ return {
+ type: ADD_TILE,
+ positionX,
+ positionY,
+ }
+}
+
+export function deleteTile(tileId) {
+ return {
+ type: DELETE_TILE,
+ tileId,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/machine.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/machine.js
new file mode 100644
index 00000000..17ccce5d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/machine.js
@@ -0,0 +1,25 @@
+export const DELETE_MACHINE = 'DELETE_MACHINE'
+export const ADD_UNIT = 'ADD_UNIT'
+export const DELETE_UNIT = 'DELETE_UNIT'
+
+export function deleteMachine() {
+ return {
+ type: DELETE_MACHINE,
+ }
+}
+
+export function addUnit(unitType, id) {
+ return {
+ type: ADD_UNIT,
+ unitType,
+ id,
+ }
+}
+
+export function deleteUnit(unitType, index) {
+ return {
+ type: DELETE_UNIT,
+ unitType,
+ index,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js
new file mode 100644
index 00000000..b117402e
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js
@@ -0,0 +1,23 @@
+export const EDIT_RACK_NAME = 'EDIT_RACK_NAME'
+export const DELETE_RACK = 'DELETE_RACK'
+export const ADD_MACHINE = 'ADD_MACHINE'
+
+export function editRackName(name) {
+ return {
+ type: EDIT_RACK_NAME,
+ name,
+ }
+}
+
+export function deleteRack() {
+ return {
+ type: DELETE_RACK,
+ }
+}
+
+export function addMachine(position) {
+ return {
+ type: ADD_MACHINE,
+ position,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js
new file mode 100644
index 00000000..61eea7fe
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js
@@ -0,0 +1,48 @@
+import { findTileWithPosition } from '../../../util/tile-calculations'
+
+export const EDIT_ROOM_NAME = 'EDIT_ROOM_NAME'
+export const DELETE_ROOM = 'DELETE_ROOM'
+export const START_RACK_CONSTRUCTION = 'START_RACK_CONSTRUCTION'
+export const STOP_RACK_CONSTRUCTION = 'STOP_RACK_CONSTRUCTION'
+export const ADD_RACK_TO_TILE = 'ADD_RACK_TO_TILE'
+
+export function editRoomName(name) {
+ return {
+ type: EDIT_ROOM_NAME,
+ name,
+ }
+}
+
+export function startRackConstruction() {
+ return {
+ type: START_RACK_CONSTRUCTION,
+ }
+}
+
+export function stopRackConstruction() {
+ return {
+ type: STOP_RACK_CONSTRUCTION,
+ }
+}
+
+export function addRackToTile(positionX, positionY) {
+ return (dispatch, getState) => {
+ const { objects, interactionLevel } = getState()
+ const currentRoom = objects.room[interactionLevel.roomId]
+ const tiles = currentRoom.tileIds.map((tileId) => objects.tile[tileId])
+ const tile = findTileWithPosition(tiles, positionX, positionY)
+
+ if (tile !== null) {
+ dispatch({
+ type: ADD_RACK_TO_TILE,
+ tileId: tile._id,
+ })
+ }
+ }
+}
+
+export function deleteRoom() {
+ return {
+ type: DELETE_ROOM,
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/index.js b/opendc-web/opendc-web-ui/src/redux/index.js
new file mode 100644
index 00000000..5c908957
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/index.js
@@ -0,0 +1,59 @@
+import { useMemo } from 'react'
+import {applyMiddleware, compose, createStore} from 'redux'
+import { createLogger } from 'redux-logger'
+import createSagaMiddleware from 'redux-saga'
+import thunk from 'redux-thunk'
+import rootReducer from './reducers'
+import rootSaga from './sagas'
+import { viewportAdjustmentMiddleware } from './middleware/viewport-adjustment'
+import { createReduxEnhancer } from "@sentry/react";
+
+let store
+
+function initStore(initialState, ctx) {
+ const sagaMiddleware = createSagaMiddleware({ context: ctx })
+
+ const middlewares = [thunk, sagaMiddleware, viewportAdjustmentMiddleware]
+
+ if (process.env.NODE_ENV !== 'production') {
+ middlewares.push(createLogger())
+ }
+
+ let middleware = applyMiddleware(...middlewares)
+
+ if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
+ middleware = compose(middleware, createReduxEnhancer())
+ }
+
+ const configuredStore = createStore(rootReducer, initialState, middleware)
+ sagaMiddleware.run(rootSaga)
+ store = configuredStore
+
+ return configuredStore
+}
+
+export const initializeStore = (preloadedState, ctx) => {
+ let _store = store ?? initStore(preloadedState, ctx)
+
+ // After navigating to a page with an initial Redux state, merge that state
+ // with the current state in the store, and create a new store
+ if (preloadedState && store) {
+ _store = initStore({
+ ...store.getState(),
+ ...preloadedState,
+ })
+ // Reset the current store
+ store = undefined
+ }
+
+ // For SSG and SSR always create a new store
+ if (typeof window === 'undefined') return _store
+ // Create the store once in the client
+ if (!store) store = _store
+
+ return _store
+}
+
+export function useStore(initialState, ctx) {
+ return useMemo(() => initializeStore(initialState, ctx), [initialState, ctx])
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/middleware/viewport-adjustment.js b/opendc-web/opendc-web-ui/src/redux/middleware/viewport-adjustment.js
new file mode 100644
index 00000000..6b22eb80
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/middleware/viewport-adjustment.js
@@ -0,0 +1,73 @@
+import { SET_MAP_DIMENSIONS, setMapPosition, setMapScale } from '../actions/map'
+import { SET_CURRENT_TOPOLOGY } from '../actions/topology/building'
+import {
+ MAP_MAX_SCALE,
+ MAP_MIN_SCALE,
+ SIDEBAR_WIDTH,
+ TILE_SIZE_IN_PIXELS,
+ VIEWPORT_PADDING,
+} from '../../components/app/map/MapConstants'
+import { calculateRoomListBounds } from '../../util/tile-calculations'
+
+export const viewportAdjustmentMiddleware = (store) => (next) => (action) => {
+ const state = store.getState()
+
+ let topologyId = '-1'
+ let mapDimensions = {}
+ if (action.type === SET_CURRENT_TOPOLOGY && action.topologyId !== '-1') {
+ topologyId = action.topologyId
+ mapDimensions = state.map.dimensions
+ } else if (action.type === SET_MAP_DIMENSIONS && state.currentTopologyId !== '-1') {
+ topologyId = state.currentTopologyId
+ mapDimensions = { width: action.width, height: action.height }
+ }
+
+ if (topologyId !== '-1') {
+ const roomIds = state.objects.topology[topologyId].roomIds
+ const rooms = roomIds.map((id) => Object.assign({}, state.objects.room[id]))
+ rooms.forEach((room) => (room.tiles = room.tileIds.map((tileId) => state.objects.tile[tileId])))
+
+ let hasNoTiles = true
+ for (let i in rooms) {
+ if (rooms[i].tiles.length > 0) {
+ hasNoTiles = false
+ break
+ }
+ }
+
+ if (!hasNoTiles) {
+ const viewportParams = calculateParametersToZoomInOnRooms(rooms, mapDimensions.width, mapDimensions.height)
+ store.dispatch(setMapPosition(viewportParams.newX, viewportParams.newY))
+ store.dispatch(setMapScale(viewportParams.newScale))
+ }
+ }
+
+ next(action)
+}
+
+function calculateParametersToZoomInOnRooms(rooms, mapWidth, mapHeight) {
+ const bounds = calculateRoomListBounds(rooms)
+ const newScale = calculateNewScale(bounds, mapWidth, mapHeight)
+
+ // Coordinates of the center of the room, relative to the global origin of the map
+ const roomCenterCoordinates = {
+ x: bounds.center.x * TILE_SIZE_IN_PIXELS * newScale,
+ y: bounds.center.y * TILE_SIZE_IN_PIXELS * newScale,
+ }
+
+ const newX = -roomCenterCoordinates.x + mapWidth / 2
+ const newY = -roomCenterCoordinates.y + mapHeight / 2
+
+ return { newScale, newX, newY }
+}
+
+function calculateNewScale(bounds, mapWidth, mapHeight) {
+ const width = bounds.max.x - bounds.min.x
+ const height = bounds.max.y - bounds.min.y
+
+ const scaleX = (mapWidth - 2 * SIDEBAR_WIDTH) / (width * TILE_SIZE_IN_PIXELS + 2 * VIEWPORT_PADDING)
+ const scaleY = mapHeight / (height * TILE_SIZE_IN_PIXELS + 2 * VIEWPORT_PADDING)
+ const newScale = Math.min(scaleX, scaleY)
+
+ return Math.min(Math.max(MAP_MIN_SCALE, newScale), MAP_MAX_SCALE)
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/construction-mode.js b/opendc-web/opendc-web-ui/src/redux/reducers/construction-mode.js
new file mode 100644
index 00000000..257dddd2
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/construction-mode.js
@@ -0,0 +1,52 @@
+import { combineReducers } from 'redux'
+import { GO_DOWN_ONE_INTERACTION_LEVEL } from '../actions/interaction-level'
+import {
+ CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ FINISH_NEW_ROOM_CONSTRUCTION,
+ FINISH_ROOM_EDIT,
+ SET_CURRENT_TOPOLOGY,
+ START_NEW_ROOM_CONSTRUCTION_SUCCEEDED,
+ START_ROOM_EDIT,
+} from '../actions/topology/building'
+import { DELETE_ROOM, START_RACK_CONSTRUCTION, STOP_RACK_CONSTRUCTION } from '../actions/topology/room'
+import { OPEN_PORTFOLIO_SUCCEEDED } from '../actions/portfolios'
+import { OPEN_SCENARIO_SUCCEEDED } from '../actions/scenarios'
+
+export function currentRoomInConstruction(state = '-1', action) {
+ switch (action.type) {
+ case START_NEW_ROOM_CONSTRUCTION_SUCCEEDED:
+ return action.roomId
+ case START_ROOM_EDIT:
+ return action.roomId
+ case CANCEL_NEW_ROOM_CONSTRUCTION_SUCCEEDED:
+ case FINISH_NEW_ROOM_CONSTRUCTION:
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ case FINISH_ROOM_EDIT:
+ case SET_CURRENT_TOPOLOGY:
+ case DELETE_ROOM:
+ return '-1'
+ default:
+ return state
+ }
+}
+
+export function inRackConstructionMode(state = false, action) {
+ switch (action.type) {
+ case START_RACK_CONSTRUCTION:
+ return true
+ case STOP_RACK_CONSTRUCTION:
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ case GO_DOWN_ONE_INTERACTION_LEVEL:
+ return false
+ default:
+ return state
+ }
+}
+
+export const construction = combineReducers({
+ currentRoomInConstruction,
+ inRackConstructionMode,
+})
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/current-ids.js b/opendc-web/opendc-web-ui/src/redux/reducers/current-ids.js
new file mode 100644
index 00000000..9b46aa60
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/current-ids.js
@@ -0,0 +1,54 @@
+import { OPEN_PORTFOLIO_SUCCEEDED, SET_CURRENT_PORTFOLIO } from '../actions/portfolios'
+import { OPEN_PROJECT_SUCCEEDED } from '../actions/projects'
+import { SET_CURRENT_TOPOLOGY } from '../actions/topology/building'
+import { OPEN_SCENARIO_SUCCEEDED, SET_CURRENT_SCENARIO } from '../actions/scenarios'
+
+export function currentTopologyId(state = '-1', action) {
+ switch (action.type) {
+ case SET_CURRENT_TOPOLOGY:
+ return action.topologyId
+ default:
+ return state
+ }
+}
+
+export function currentProjectId(state = '-1', action) {
+ switch (action.type) {
+ case OPEN_PROJECT_SUCCEEDED:
+ return action.id
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ return action.projectId
+ default:
+ return state
+ }
+}
+
+export function currentPortfolioId(state = '-1', action) {
+ switch (action.type) {
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case SET_CURRENT_PORTFOLIO:
+ case SET_CURRENT_SCENARIO:
+ return action.portfolioId
+ case OPEN_SCENARIO_SUCCEEDED:
+ return action.portfolioId
+ case OPEN_PROJECT_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ return '-1'
+ default:
+ return state
+ }
+}
+export function currentScenarioId(state = '-1', action) {
+ switch (action.type) {
+ case OPEN_SCENARIO_SUCCEEDED:
+ case SET_CURRENT_SCENARIO:
+ return action.scenarioId
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ case OPEN_PROJECT_SUCCEEDED:
+ return '-1'
+ default:
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/index.js b/opendc-web/opendc-web-ui/src/redux/reducers/index.js
new file mode 100644
index 00000000..b143d417
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/index.js
@@ -0,0 +1,21 @@
+import { combineReducers } from 'redux'
+import { construction } from './construction-mode'
+import { currentPortfolioId, currentProjectId, currentScenarioId, currentTopologyId } from './current-ids'
+import { interactionLevel } from './interaction-level'
+import { map } from './map'
+import { objects } from './objects'
+import { projects } from './projects'
+
+const rootReducer = combineReducers({
+ objects,
+ projects,
+ construction,
+ map,
+ currentProjectId,
+ currentTopologyId,
+ currentPortfolioId,
+ currentScenarioId,
+ interactionLevel,
+})
+
+export default rootReducer
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/interaction-level.js b/opendc-web/opendc-web-ui/src/redux/reducers/interaction-level.js
new file mode 100644
index 00000000..eafcb269
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/interaction-level.js
@@ -0,0 +1,61 @@
+import { OPEN_PORTFOLIO_SUCCEEDED } from '../actions/portfolios'
+import {
+ GO_DOWN_ONE_INTERACTION_LEVEL,
+ GO_FROM_BUILDING_TO_ROOM,
+ GO_FROM_RACK_TO_MACHINE,
+ GO_FROM_ROOM_TO_RACK,
+} from '../actions/interaction-level'
+import { OPEN_PROJECT_SUCCEEDED } from '../actions/projects'
+import { SET_CURRENT_TOPOLOGY } from '../actions/topology/building'
+import { OPEN_SCENARIO_SUCCEEDED } from '../actions/scenarios'
+
+export function interactionLevel(state = { mode: 'BUILDING' }, action) {
+ switch (action.type) {
+ case OPEN_PORTFOLIO_SUCCEEDED:
+ case OPEN_SCENARIO_SUCCEEDED:
+ case OPEN_PROJECT_SUCCEEDED:
+ case SET_CURRENT_TOPOLOGY:
+ return {
+ mode: 'BUILDING',
+ }
+ case GO_FROM_BUILDING_TO_ROOM:
+ return {
+ mode: 'ROOM',
+ roomId: action.roomId,
+ }
+ case GO_FROM_ROOM_TO_RACK:
+ return {
+ mode: 'RACK',
+ roomId: state.roomId,
+ tileId: action.tileId,
+ }
+ case GO_FROM_RACK_TO_MACHINE:
+ return {
+ mode: 'MACHINE',
+ roomId: state.roomId,
+ tileId: state.tileId,
+ position: action.position,
+ }
+ case GO_DOWN_ONE_INTERACTION_LEVEL:
+ if (state.mode === 'ROOM') {
+ return {
+ mode: 'BUILDING',
+ }
+ } else if (state.mode === 'RACK') {
+ return {
+ mode: 'ROOM',
+ roomId: state.roomId,
+ }
+ } else if (state.mode === 'MACHINE') {
+ return {
+ mode: 'RACK',
+ roomId: state.roomId,
+ tileId: state.tileId,
+ }
+ } else {
+ return state
+ }
+ default:
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/map.js b/opendc-web/opendc-web-ui/src/redux/reducers/map.js
new file mode 100644
index 00000000..de712c15
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/map.js
@@ -0,0 +1,35 @@
+import { combineReducers } from 'redux'
+import { SET_MAP_DIMENSIONS, SET_MAP_POSITION, SET_MAP_SCALE } from '../actions/map'
+
+export function position(state = { x: 0, y: 0 }, action) {
+ switch (action.type) {
+ case SET_MAP_POSITION:
+ return { x: action.x, y: action.y }
+ default:
+ return state
+ }
+}
+
+export function dimensions(state = { width: 600, height: 400 }, action) {
+ switch (action.type) {
+ case SET_MAP_DIMENSIONS:
+ return { width: action.width, height: action.height }
+ default:
+ return state
+ }
+}
+
+export function scale(state = 1, action) {
+ switch (action.type) {
+ case SET_MAP_SCALE:
+ return action.scale
+ default:
+ return state
+ }
+}
+
+export const map = combineReducers({
+ position,
+ dimensions,
+ scale,
+})
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/objects.js b/opendc-web/opendc-web-ui/src/redux/reducers/objects.js
new file mode 100644
index 00000000..a2483b43
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/objects.js
@@ -0,0 +1,64 @@
+import { combineReducers } from 'redux'
+import {
+ ADD_ID_TO_STORE_OBJECT_LIST_PROP,
+ ADD_PROP_TO_STORE_OBJECT,
+ ADD_TO_STORE,
+ REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP,
+} from '../actions/objects'
+import { CPU_UNITS, GPU_UNITS, MEMORY_UNITS, STORAGE_UNITS } from '../../util/unit-specifications'
+
+export const objects = combineReducers({
+ project: object('project'),
+ user: object('user'),
+ authorization: objectWithId('authorization', (object) => [object.userId, object.projectId]),
+ cpu: object('cpu', CPU_UNITS),
+ gpu: object('gpu', GPU_UNITS),
+ memory: object('memory', MEMORY_UNITS),
+ storage: object('storage', STORAGE_UNITS),
+ machine: object('machine'),
+ rack: object('rack'),
+ tile: object('tile'),
+ room: object('room'),
+ topology: object('topology'),
+ trace: object('trace'),
+ scheduler: object('scheduler'),
+ portfolio: object('portfolio'),
+ scenario: object('scenario'),
+ prefab: object('prefab'),
+})
+
+function object(type, defaultState = {}) {
+ return objectWithId(type, (object) => object._id, defaultState)
+}
+
+function objectWithId(type, getId, defaultState = {}) {
+ return (state = defaultState, action) => {
+ if (action.objectType !== type) {
+ return state
+ }
+
+ if (action.type === ADD_TO_STORE) {
+ return Object.assign({}, state, {
+ [getId(action.object)]: action.object,
+ })
+ } else if (action.type === ADD_PROP_TO_STORE_OBJECT) {
+ return Object.assign({}, state, {
+ [action.objectId]: Object.assign({}, state[action.objectId], action.propObject),
+ })
+ } else if (action.type === ADD_ID_TO_STORE_OBJECT_LIST_PROP) {
+ return Object.assign({}, state, {
+ [action.objectId]: Object.assign({}, state[action.objectId], {
+ [action.propName]: [...state[action.objectId][action.propName], action.id],
+ }),
+ })
+ } else if (action.type === REMOVE_ID_FROM_STORE_OBJECT_LIST_PROP) {
+ return Object.assign({}, state, {
+ [action.objectId]: Object.assign({}, state[action.objectId], {
+ [action.propName]: state[action.objectId][action.propName].filter((id) => id !== action.id),
+ }),
+ })
+ }
+
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/projects.js b/opendc-web/opendc-web-ui/src/redux/reducers/projects.js
new file mode 100644
index 00000000..a920e47f
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/projects.js
@@ -0,0 +1,14 @@
+import { ADD_PROJECT_SUCCEEDED, DELETE_PROJECT_SUCCEEDED, FETCH_PROJECTS_SUCCEEDED } from '../actions/projects'
+
+export function projects(state = [], action) {
+ switch (action.type) {
+ case FETCH_PROJECTS_SUCCEEDED:
+ return action.projects
+ case ADD_PROJECT_SUCCEEDED:
+ return [...state, action.project]
+ case DELETE_PROJECT_SUCCEEDED:
+ return state.filter((project) => project._id !== action.id)
+ default:
+ return state
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/index.js b/opendc-web/opendc-web-ui/src/redux/sagas/index.js
new file mode 100644
index 00000000..a8f44843
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/index.js
@@ -0,0 +1,72 @@
+import { takeEvery } from 'redux-saga/effects'
+import { ADD_PORTFOLIO, DELETE_PORTFOLIO, OPEN_PORTFOLIO_SUCCEEDED, UPDATE_PORTFOLIO } from '../actions/portfolios'
+import { ADD_PROJECT, DELETE_PROJECT, FETCH_PROJECTS, OPEN_PROJECT_SUCCEEDED } from '../actions/projects'
+import {
+ ADD_TILE,
+ CANCEL_NEW_ROOM_CONSTRUCTION,
+ DELETE_TILE,
+ START_NEW_ROOM_CONSTRUCTION,
+} from '../actions/topology/building'
+import { ADD_UNIT, DELETE_MACHINE, DELETE_UNIT } from '../actions/topology/machine'
+import { ADD_MACHINE, DELETE_RACK, EDIT_RACK_NAME } from '../actions/topology/rack'
+import { ADD_RACK_TO_TILE, DELETE_ROOM, EDIT_ROOM_NAME } from '../actions/topology/room'
+import { onAddPortfolio, onDeletePortfolio, onOpenPortfolioSucceeded, onUpdatePortfolio } from './portfolios'
+import { onFetchProjects, onOpenProjectSucceeded, onProjectAdd, onProjectDelete } from './projects'
+import {
+ onAddMachine,
+ onAddRackToTile,
+ onAddTile,
+ onAddTopology,
+ onAddUnit,
+ onCancelNewRoomConstruction,
+ onDeleteMachine,
+ onDeleteRack,
+ onDeleteRoom,
+ onDeleteTile,
+ onDeleteTopology,
+ onDeleteUnit,
+ onEditRackName,
+ onEditRoomName,
+ onStartNewRoomConstruction,
+} from './topology'
+import { ADD_TOPOLOGY, DELETE_TOPOLOGY } from '../actions/topologies'
+import { ADD_SCENARIO, DELETE_SCENARIO, OPEN_SCENARIO_SUCCEEDED, UPDATE_SCENARIO } from '../actions/scenarios'
+import { onAddScenario, onDeleteScenario, onOpenScenarioSucceeded, onUpdateScenario } from './scenarios'
+import { onAddPrefab } from './prefabs'
+import { ADD_PREFAB } from '../actions/prefabs'
+
+export default function* rootSaga() {
+ yield takeEvery(FETCH_PROJECTS, onFetchProjects)
+ yield takeEvery(ADD_PROJECT, onProjectAdd)
+ yield takeEvery(DELETE_PROJECT, onProjectDelete)
+
+ yield takeEvery(OPEN_PROJECT_SUCCEEDED, onOpenProjectSucceeded)
+ yield takeEvery(OPEN_PORTFOLIO_SUCCEEDED, onOpenPortfolioSucceeded)
+ yield takeEvery(OPEN_SCENARIO_SUCCEEDED, onOpenScenarioSucceeded)
+
+ yield takeEvery(ADD_TOPOLOGY, onAddTopology)
+ yield takeEvery(DELETE_TOPOLOGY, onDeleteTopology)
+ yield takeEvery(START_NEW_ROOM_CONSTRUCTION, onStartNewRoomConstruction)
+ yield takeEvery(CANCEL_NEW_ROOM_CONSTRUCTION, onCancelNewRoomConstruction)
+ yield takeEvery(ADD_TILE, onAddTile)
+ yield takeEvery(DELETE_TILE, onDeleteTile)
+ yield takeEvery(EDIT_ROOM_NAME, onEditRoomName)
+ yield takeEvery(DELETE_ROOM, onDeleteRoom)
+ yield takeEvery(EDIT_RACK_NAME, onEditRackName)
+ yield takeEvery(DELETE_RACK, onDeleteRack)
+ yield takeEvery(ADD_RACK_TO_TILE, onAddRackToTile)
+ yield takeEvery(ADD_MACHINE, onAddMachine)
+ yield takeEvery(DELETE_MACHINE, onDeleteMachine)
+ yield takeEvery(ADD_UNIT, onAddUnit)
+ yield takeEvery(DELETE_UNIT, onDeleteUnit)
+
+ yield takeEvery(ADD_PORTFOLIO, onAddPortfolio)
+ yield takeEvery(UPDATE_PORTFOLIO, onUpdatePortfolio)
+ yield takeEvery(DELETE_PORTFOLIO, onDeletePortfolio)
+
+ yield takeEvery(ADD_SCENARIO, onAddScenario)
+ yield takeEvery(UPDATE_SCENARIO, onUpdateScenario)
+ yield takeEvery(DELETE_SCENARIO, onDeleteScenario)
+
+ yield takeEvery(ADD_PREFAB, onAddPrefab)
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/objects.js b/opendc-web/opendc-web-ui/src/redux/sagas/objects.js
new file mode 100644
index 00000000..e5fd092d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/objects.js
@@ -0,0 +1,234 @@
+import { call, put, select, getContext } from 'redux-saga/effects'
+import { addToStore } from '../actions/objects'
+import { getAllSchedulers } from '../../api/schedulers'
+import { getProject } from '../../api/projects'
+import { getAllTraces } from '../../api/traces'
+import { getTopology, updateTopology } from '../../api/topologies'
+import { uuid } from 'uuidv4'
+
+export const OBJECT_SELECTORS = {
+ project: (state) => state.objects.project,
+ user: (state) => state.objects.user,
+ authorization: (state) => state.objects.authorization,
+ portfolio: (state) => state.objects.portfolio,
+ scenario: (state) => state.objects.scenario,
+ cpu: (state) => state.objects.cpu,
+ gpu: (state) => state.objects.gpu,
+ memory: (state) => state.objects.memory,
+ storage: (state) => state.objects.storage,
+ machine: (state) => state.objects.machine,
+ rack: (state) => state.objects.rack,
+ tile: (state) => state.objects.tile,
+ room: (state) => state.objects.room,
+ topology: (state) => state.objects.topology,
+}
+
+function* fetchAndStoreObject(objectType, id, apiCall) {
+ const objectStore = yield select(OBJECT_SELECTORS[objectType])
+ let object = objectStore[id]
+ if (!object) {
+ object = yield apiCall
+ yield put(addToStore(objectType, object))
+ }
+ return object
+}
+
+function* fetchAndStoreObjects(objectType, apiCall) {
+ const objects = yield apiCall
+ for (let object of objects) {
+ yield put(addToStore(objectType, object))
+ }
+ return objects
+}
+
+export const fetchAndStoreProject = function* (id) {
+ const auth = yield getContext('auth')
+ return yield fetchAndStoreObject('project', id, call(getProject, auth, id))
+}
+
+export const fetchAndStoreTopology = function* (id) {
+ const topologyStore = yield select(OBJECT_SELECTORS['topology'])
+ const roomStore = yield select(OBJECT_SELECTORS['room'])
+ const tileStore = yield select(OBJECT_SELECTORS['tile'])
+ const rackStore = yield select(OBJECT_SELECTORS['rack'])
+ const machineStore = yield select(OBJECT_SELECTORS['machine'])
+ const auth = yield getContext('auth')
+
+ let topology = topologyStore[id]
+ if (!topology) {
+ const fullTopology = yield call(getTopology, auth, id)
+
+ for (let roomIdx in fullTopology.rooms) {
+ const fullRoom = fullTopology.rooms[roomIdx]
+
+ generateIdIfNotPresent(fullRoom)
+
+ if (!roomStore[fullRoom._id]) {
+ for (let tileIdx in fullRoom.tiles) {
+ const fullTile = fullRoom.tiles[tileIdx]
+
+ generateIdIfNotPresent(fullTile)
+
+ if (!tileStore[fullTile._id]) {
+ if (fullTile.rack) {
+ const fullRack = fullTile.rack
+
+ generateIdIfNotPresent(fullRack)
+
+ if (!rackStore[fullRack._id]) {
+ for (let machineIdx in fullRack.machines) {
+ const fullMachine = fullRack.machines[machineIdx]
+
+ generateIdIfNotPresent(fullMachine)
+
+ if (!machineStore[fullMachine._id]) {
+ let machine = (({ _id, position, cpus, gpus, memories, storages }) => ({
+ _id,
+ rackId: fullRack._id,
+ position,
+ cpuIds: cpus.map((u) => u._id),
+ gpuIds: gpus.map((u) => u._id),
+ memoryIds: memories.map((u) => u._id),
+ storageIds: storages.map((u) => u._id),
+ }))(fullMachine)
+ yield put(addToStore('machine', machine))
+ }
+ }
+
+ const filledSlots = new Array(fullRack.capacity).fill(null)
+ fullRack.machines.forEach(
+ (machine) => (filledSlots[machine.position - 1] = machine._id)
+ )
+ let rack = (({ _id, name, capacity, powerCapacityW }) => ({
+ _id,
+ name,
+ capacity,
+ powerCapacityW,
+ machineIds: filledSlots,
+ }))(fullRack)
+ yield put(addToStore('rack', rack))
+ }
+ }
+
+ let tile = (({ _id, positionX, positionY, rack }) => ({
+ _id,
+ roomId: fullRoom._id,
+ positionX,
+ positionY,
+ rackId: rack ? rack._id : undefined,
+ }))(fullTile)
+ yield put(addToStore('tile', tile))
+ }
+ }
+
+ let room = (({ _id, name, tiles }) => ({ _id, name, tileIds: tiles.map((t) => t._id) }))(fullRoom)
+ yield put(addToStore('room', room))
+ }
+ }
+
+ topology = (({ _id, name, rooms }) => ({ _id, name, roomIds: rooms.map((r) => r._id) }))(fullTopology)
+ yield put(addToStore('topology', topology))
+
+ // TODO consider pushing the IDs
+ }
+
+ return topology
+}
+
+const generateIdIfNotPresent = (obj) => {
+ if (!obj._id) {
+ obj._id = uuid()
+ }
+}
+
+export const updateTopologyOnServer = function* (id) {
+ const topology = yield getTopologyAsObject(id, true)
+ const auth = yield getContext('auth')
+ yield call(updateTopology, auth, topology)
+}
+
+export const getTopologyAsObject = function* (id, keepIds) {
+ const topologyStore = yield select(OBJECT_SELECTORS['topology'])
+ const rooms = yield getAllRooms(topologyStore[id].roomIds, keepIds)
+ return {
+ _id: keepIds ? id : undefined,
+ name: topologyStore[id].name,
+ rooms: rooms,
+ }
+}
+
+export const getAllRooms = function* (roomIds, keepIds) {
+ const roomStore = yield select(OBJECT_SELECTORS['room'])
+
+ let rooms = []
+
+ for (let id of roomIds) {
+ let tiles = yield getAllRoomTiles(roomStore[id], keepIds)
+ rooms.push({
+ _id: keepIds ? id : undefined,
+ name: roomStore[id].name,
+ tiles: tiles,
+ })
+ }
+ return rooms
+}
+
+export const getAllRoomTiles = function* (roomStore, keepIds) {
+ let tiles = []
+
+ for (let id of roomStore.tileIds) {
+ tiles.push(yield getTileById(id, keepIds))
+ }
+ return tiles
+}
+
+export const getTileById = function* (id, keepIds) {
+ const tileStore = yield select(OBJECT_SELECTORS['tile'])
+ return {
+ _id: keepIds ? id : undefined,
+ positionX: tileStore[id].positionX,
+ positionY: tileStore[id].positionY,
+ rack: !tileStore[id].rackId ? undefined : yield getRackById(tileStore[id].rackId, keepIds),
+ }
+}
+
+export const getRackById = function* (id, keepIds) {
+ const rackStore = yield select(OBJECT_SELECTORS['rack'])
+ const machineStore = yield select(OBJECT_SELECTORS['machine'])
+ const cpuStore = yield select(OBJECT_SELECTORS['cpu'])
+ const gpuStore = yield select(OBJECT_SELECTORS['gpu'])
+ const memoryStore = yield select(OBJECT_SELECTORS['memory'])
+ const storageStore = yield select(OBJECT_SELECTORS['storage'])
+
+ return {
+ _id: keepIds ? rackStore[id]._id : undefined,
+ name: rackStore[id].name,
+ capacity: rackStore[id].capacity,
+ powerCapacityW: rackStore[id].powerCapacityW,
+ machines: rackStore[id].machineIds
+ .filter((m) => m !== null)
+ .map((machineId) => ({
+ _id: keepIds ? machineId : undefined,
+ position: machineStore[machineId].position,
+ cpus: machineStore[machineId].cpuIds.map((id) => cpuStore[id]),
+ gpus: machineStore[machineId].gpuIds.map((id) => gpuStore[id]),
+ memories: machineStore[machineId].memoryIds.map((id) => memoryStore[id]),
+ storages: machineStore[machineId].storageIds.map((id) => storageStore[id]),
+ })),
+ }
+}
+
+export const fetchAndStoreAllTraces = function* () {
+ const auth = yield getContext('auth')
+ return yield fetchAndStoreObjects('trace', call(getAllTraces, auth))
+}
+
+export const fetchAndStoreAllSchedulers = function* () {
+ const auth = yield getContext('auth')
+ const objects = yield call(getAllSchedulers, auth)
+ for (let object of objects) {
+ object._id = object.name
+ yield put(addToStore('scheduler', object))
+ }
+ return objects
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/portfolios.js b/opendc-web/opendc-web-ui/src/redux/sagas/portfolios.js
new file mode 100644
index 00000000..340cb490
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/portfolios.js
@@ -0,0 +1,136 @@
+import { call, put, select, delay, getContext } from 'redux-saga/effects'
+import { addPropToStoreObject, addToStore } from '../actions/objects'
+import { addPortfolio, deletePortfolio, getPortfolio, updatePortfolio } from '../../api/portfolios'
+import { getProject } from '../../api/projects'
+import { fetchAndStoreAllSchedulers, fetchAndStoreAllTraces } from './objects'
+import { fetchAndStoreAllTopologiesOfProject } from './topology'
+import { getScenario } from '../../api/scenarios'
+
+export function* onOpenPortfolioSucceeded(action) {
+ try {
+ const auth = yield getContext('auth')
+ const project = yield call(getProject, auth, action.projectId)
+ yield put(addToStore('project', project))
+ yield fetchAndStoreAllTopologiesOfProject(project._id)
+ yield fetchPortfoliosOfProject()
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+
+ yield watchForPortfolioResults()
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* watchForPortfolioResults() {
+ try {
+ const currentPortfolioId = yield select((state) => state.currentPortfolioId)
+ let unfinishedScenarios = yield getCurrentUnfinishedScenarios()
+
+ while (unfinishedScenarios.length > 0) {
+ yield delay(3000)
+ yield fetchPortfolioWithScenarios(currentPortfolioId)
+ unfinishedScenarios = yield getCurrentUnfinishedScenarios()
+ }
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* getCurrentUnfinishedScenarios() {
+ try {
+ const currentPortfolioId = yield select((state) => state.currentPortfolioId)
+ const scenarioIds = yield select((state) => state.objects.portfolio[currentPortfolioId].scenarioIds)
+ const scenarioObjects = yield select((state) => state.objects.scenario)
+ const scenarios = scenarioIds.map((s) => scenarioObjects[s])
+ return scenarios.filter((s) => !s || s.simulation.state === 'QUEUED' || s.simulation.state === 'RUNNING')
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* fetchPortfoliosOfProject() {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+ const currentProject = yield select((state) => state.objects.project[currentProjectId])
+
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+
+ for (let i in currentProject.portfolioIds) {
+ yield fetchPortfolioWithScenarios(currentProject.portfolioIds[i])
+ }
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* fetchPortfolioWithScenarios(portfolioId) {
+ try {
+ const auth = yield getContext('auth')
+ const portfolio = yield call(getPortfolio, auth, portfolioId)
+ yield put(addToStore('portfolio', portfolio))
+
+ for (let i in portfolio.scenarioIds) {
+ const scenario = yield call(getScenario, auth, portfolio.scenarioIds[i])
+ yield put(addToStore('scenario', scenario))
+ }
+ return portfolio
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddPortfolio(action) {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+ const auth = yield getContext('auth')
+ const portfolio = yield call(
+ addPortfolio,
+ auth,
+ currentProjectId,
+ Object.assign({}, action.portfolio, {
+ projectId: currentProjectId,
+ scenarioIds: [],
+ })
+ )
+ yield put(addToStore('portfolio', portfolio))
+
+ const portfolioIds = yield select((state) => state.objects.project[currentProjectId].portfolioIds)
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ portfolioIds: portfolioIds.concat([portfolio._id]),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onUpdatePortfolio(action) {
+ try {
+ const auth = yield getContext('auth')
+ const portfolio = yield call(updatePortfolio, auth, action.portfolio._id, action.portfolio)
+ yield put(addToStore('portfolio', portfolio))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeletePortfolio(action) {
+ try {
+ const auth = yield getContext('auth')
+ yield call(deletePortfolio, auth, action.id)
+
+ const currentProjectId = yield select((state) => state.currentProjectId)
+ const portfolioIds = yield select((state) => state.objects.project[currentProjectId].portfolioIds)
+
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ portfolioIds: portfolioIds.filter((id) => id !== action.id),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/prefabs.js b/opendc-web/opendc-web-ui/src/redux/sagas/prefabs.js
new file mode 100644
index 00000000..ec679391
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/prefabs.js
@@ -0,0 +1,16 @@
+import { call, put, select, getContext } from 'redux-saga/effects'
+import { addToStore } from '../actions/objects'
+import { addPrefab } from '../../api/prefabs'
+import { getRackById } from './objects'
+
+export function* onAddPrefab(action) {
+ try {
+ const currentRackId = yield select((state) => state.objects.tile[state.interactionLevel.tileId].rackId)
+ const currentRackJson = yield getRackById(currentRackId, false)
+ const auth = yield getContext('auth')
+ const prefab = yield call(addPrefab, auth, { name: action.name, rack: currentRackJson })
+ yield put(addToStore('prefab', prefab))
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/projects.js b/opendc-web/opendc-web-ui/src/redux/sagas/projects.js
new file mode 100644
index 00000000..506df6ed
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/projects.js
@@ -0,0 +1,53 @@
+import { call, put, getContext } from 'redux-saga/effects'
+import { addToStore } from '../actions/objects'
+import { addProjectSucceeded, deleteProjectSucceeded, fetchProjectsSucceeded } from '../actions/projects'
+import { addProject, deleteProject, getProject, getProjects } from '../../api/projects'
+import { fetchAndStoreAllTopologiesOfProject } from './topology'
+import { fetchAndStoreAllSchedulers, fetchAndStoreAllTraces } from './objects'
+import { fetchPortfoliosOfProject } from './portfolios'
+
+export function* onOpenProjectSucceeded(action) {
+ try {
+ const auth = yield getContext('auth')
+ const project = yield call(getProject, auth, action.id)
+ yield put(addToStore('project', project))
+
+ yield fetchAndStoreAllTopologiesOfProject(action.id, true)
+ yield fetchPortfoliosOfProject()
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onProjectAdd(action) {
+ try {
+ const auth = yield getContext('auth')
+ const project = yield call(addProject, auth, { name: action.name })
+ yield put(addToStore('project', project))
+ yield put(addProjectSucceeded(project))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onProjectDelete(action) {
+ try {
+ const auth = yield getContext('auth')
+ yield call(deleteProject, auth, action.id)
+ yield put(deleteProjectSucceeded(action.id))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onFetchProjects(action) {
+ try {
+ const auth = yield getContext('auth')
+ const projects = yield call(getProjects, auth)
+ yield put(fetchProjectsSucceeded(projects))
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/scenarios.js b/opendc-web/opendc-web-ui/src/redux/sagas/scenarios.js
new file mode 100644
index 00000000..bdb7c45d
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/scenarios.js
@@ -0,0 +1,69 @@
+import { call, put, select, getContext } from 'redux-saga/effects'
+import { addPropToStoreObject, addToStore } from '../actions/objects'
+import { getProject } from '../../api/projects'
+import { fetchAndStoreAllSchedulers, fetchAndStoreAllTraces } from './objects'
+import { fetchAndStoreAllTopologiesOfProject } from './topology'
+import { addScenario, deleteScenario, updateScenario } from '../../api/scenarios'
+import { fetchPortfolioWithScenarios, watchForPortfolioResults } from './portfolios'
+
+export function* onOpenScenarioSucceeded(action) {
+ try {
+ const auth = yield getContext('auth')
+ const project = yield call(getProject, auth, action.projectId)
+ yield put(addToStore('project', project))
+ yield fetchAndStoreAllTopologiesOfProject(project._id)
+ yield fetchAndStoreAllSchedulers()
+ yield fetchAndStoreAllTraces()
+ yield fetchPortfolioWithScenarios(action.portfolioId)
+
+ // TODO Fetch scenario-specific metrics
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddScenario(action) {
+ try {
+ const auth = yield getContext('auth')
+ const scenario = yield call(addScenario, auth, action.scenario.portfolioId, action.scenario)
+ yield put(addToStore('scenario', scenario))
+
+ const scenarioIds = yield select((state) => state.objects.portfolio[action.scenario.portfolioId].scenarioIds)
+ yield put(
+ addPropToStoreObject('portfolio', action.scenario.portfolioId, {
+ scenarioIds: scenarioIds.concat([scenario._id]),
+ })
+ )
+ yield watchForPortfolioResults()
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onUpdateScenario(action) {
+ try {
+ const auth = yield getContext('auth')
+ const scenario = yield call(updateScenario, auth, action.scenario._id, action.scenario)
+ yield put(addToStore('scenario', scenario))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteScenario(action) {
+ try {
+ const auth = yield getContext('auth')
+ yield call(deleteScenario, auth, action.id)
+
+ const currentPortfolioId = yield select((state) => state.currentPortfolioId)
+ const scenarioIds = yield select((state) => state.objects.portfolio[currentPortfolioId].scenarioIds)
+
+ yield put(
+ addPropToStoreObject('scenario', currentPortfolioId, {
+ scenarioIds: scenarioIds.filter((id) => id !== action.id),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/topology.js b/opendc-web/opendc-web-ui/src/redux/sagas/topology.js
new file mode 100644
index 00000000..e5fd3d39
--- /dev/null
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/topology.js
@@ -0,0 +1,314 @@
+import { call, put, select, getContext } from 'redux-saga/effects'
+import { goDownOneInteractionLevel } from '../actions/interaction-level'
+import {
+ addIdToStoreObjectListProp,
+ addPropToStoreObject,
+ addToStore,
+ removeIdFromStoreObjectListProp,
+} from '../actions/objects'
+import {
+ cancelNewRoomConstructionSucceeded,
+ setCurrentTopology,
+ startNewRoomConstructionSucceeded,
+} from '../actions/topology/building'
+import {
+ DEFAULT_RACK_POWER_CAPACITY,
+ DEFAULT_RACK_SLOT_CAPACITY,
+ MAX_NUM_UNITS_PER_MACHINE,
+} from '../../components/app/map/MapConstants'
+import { fetchAndStoreTopology, getTopologyAsObject, updateTopologyOnServer } from './objects'
+import { uuid } from 'uuidv4'
+import { addTopology, deleteTopology } from '../../api/topologies'
+
+export function* fetchAndStoreAllTopologiesOfProject(projectId, setTopology = false) {
+ try {
+ const project = yield select((state) => state.objects.project[projectId])
+
+ for (let i in project.topologyIds) {
+ yield fetchAndStoreTopology(project.topologyIds[i])
+ }
+
+ if (setTopology) {
+ yield put(setCurrentTopology(project.topologyIds[0]))
+ }
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddTopology(action) {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+
+ let topologyToBeCreated
+ if (action.duplicateId) {
+ topologyToBeCreated = yield getTopologyAsObject(action.duplicateId, false)
+ topologyToBeCreated = Object.assign({}, topologyToBeCreated, {
+ name: action.name,
+ })
+ } else {
+ topologyToBeCreated = { name: action.name, rooms: [] }
+ }
+
+ const auth = yield getContext('auth')
+ const topology = yield call(
+ addTopology,
+ auth,
+ Object.assign({}, topologyToBeCreated, {
+ projectId: currentProjectId,
+ })
+ )
+ yield fetchAndStoreTopology(topology._id)
+
+ const topologyIds = yield select((state) => state.objects.project[currentProjectId].topologyIds)
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ topologyIds: topologyIds.concat([topology._id]),
+ })
+ )
+ yield put(setCurrentTopology(topology._id))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteTopology(action) {
+ try {
+ const currentProjectId = yield select((state) => state.currentProjectId)
+ const topologyIds = yield select((state) => state.objects.project[currentProjectId].topologyIds)
+ const currentTopologyId = yield select((state) => state.currentTopologyId)
+ if (currentTopologyId === action.id) {
+ yield put(setCurrentTopology(topologyIds.filter((t) => t !== action.id)[0]))
+ }
+
+ const auth = yield getContext('auth')
+ yield call(deleteTopology, auth, action.id)
+
+ yield put(
+ addPropToStoreObject('project', currentProjectId, {
+ topologyIds: topologyIds.filter((id) => id !== action.id),
+ })
+ )
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onStartNewRoomConstruction() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const room = {
+ _id: uuid(),
+ name: 'Room',
+ topologyId,
+ tileIds: [],
+ }
+ yield put(addToStore('room', room))
+ yield put(addIdToStoreObjectListProp('topology', topologyId, 'roomIds', room._id))
+ yield updateTopologyOnServer(topologyId)
+ yield put(startNewRoomConstructionSucceeded(room._id))
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onCancelNewRoomConstruction() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.construction.currentRoomInConstruction)
+ yield put(removeIdFromStoreObjectListProp('topology', topologyId, 'roomIds', roomId))
+ // TODO remove room from store, too
+ yield updateTopologyOnServer(topologyId)
+ yield put(cancelNewRoomConstructionSucceeded())
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddTile(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.construction.currentRoomInConstruction)
+ const tile = {
+ _id: uuid(),
+ roomId,
+ positionX: action.positionX,
+ positionY: action.positionY,
+ }
+ yield put(addToStore('tile', tile))
+ yield put(addIdToStoreObjectListProp('room', roomId, 'tileIds', tile._id))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteTile(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.construction.currentRoomInConstruction)
+ yield put(removeIdFromStoreObjectListProp('room', roomId, 'tileIds', action.tileId))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onEditRoomName(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.interactionLevel.roomId)
+ const room = Object.assign({}, yield select((state) => state.objects.room[roomId]))
+ room.name = action.name
+ yield put(addPropToStoreObject('room', roomId, { name: action.name }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteRoom() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const roomId = yield select((state) => state.interactionLevel.roomId)
+ yield put(goDownOneInteractionLevel())
+ yield put(removeIdFromStoreObjectListProp('topology', topologyId, 'roomIds', roomId))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onEditRackName(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const rackId = yield select((state) => state.objects.tile[state.interactionLevel.tileId].rackId)
+ const rack = Object.assign({}, yield select((state) => state.objects.rack[rackId]))
+ rack.name = action.name
+ yield put(addPropToStoreObject('rack', rackId, { name: action.name }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteRack() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ yield put(goDownOneInteractionLevel())
+ yield put(addPropToStoreObject('tile', tileId, { rackId: undefined }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddRackToTile(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const rack = {
+ _id: uuid(),
+ name: 'Rack',
+ capacity: DEFAULT_RACK_SLOT_CAPACITY,
+ powerCapacityW: DEFAULT_RACK_POWER_CAPACITY,
+ }
+ rack.machineIds = new Array(rack.capacity).fill(null)
+ yield put(addToStore('rack', rack))
+ yield put(addPropToStoreObject('tile', action.tileId, { rackId: rack._id }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddMachine(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const rackId = yield select((state) => state.objects.tile[state.interactionLevel.tileId].rackId)
+ const rack = yield select((state) => state.objects.rack[rackId])
+
+ const machine = {
+ _id: uuid(),
+ rackId,
+ position: action.position,
+ cpuIds: [],
+ gpuIds: [],
+ memoryIds: [],
+ storageIds: [],
+ }
+ yield put(addToStore('machine', machine))
+
+ const machineIds = [...rack.machineIds]
+ machineIds[machine.position - 1] = machine._id
+ yield put(addPropToStoreObject('rack', rackId, { machineIds }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteMachine() {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ const position = yield select((state) => state.interactionLevel.position)
+ const rack = yield select((state) => state.objects.rack[state.objects.tile[tileId].rackId])
+ const machineIds = [...rack.machineIds]
+ machineIds[position - 1] = null
+ yield put(goDownOneInteractionLevel())
+ yield put(addPropToStoreObject('rack', rack._id, { machineIds }))
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onAddUnit(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ const position = yield select((state) => state.interactionLevel.position)
+ const machine = yield select(
+ (state) =>
+ state.objects.machine[state.objects.rack[state.objects.tile[tileId].rackId].machineIds[position - 1]]
+ )
+
+ if (machine[action.unitType + 'Ids'].length >= MAX_NUM_UNITS_PER_MACHINE) {
+ return
+ }
+
+ const units = [...machine[action.unitType + 'Ids'], action.id]
+ yield put(
+ addPropToStoreObject('machine', machine._id, {
+ [action.unitType + 'Ids']: units,
+ })
+ )
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
+export function* onDeleteUnit(action) {
+ try {
+ const topologyId = yield select((state) => state.currentTopologyId)
+ const tileId = yield select((state) => state.interactionLevel.tileId)
+ const position = yield select((state) => state.interactionLevel.position)
+ const machine = yield select(
+ (state) =>
+ state.objects.machine[state.objects.rack[state.objects.tile[tileId].rackId].machineIds[position - 1]]
+ )
+ const unitIds = machine[action.unitType + 'Ids'].slice()
+ unitIds.splice(action.index, 1)
+
+ yield put(
+ addPropToStoreObject('machine', machine._id, {
+ [action.unitType + 'Ids']: unitIds,
+ })
+ )
+ yield updateTopologyOnServer(topologyId)
+ } catch (error) {
+ console.error(error)
+ }
+}