diff options
Diffstat (limited to 'frontend/src')
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 + <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>×</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; +} |
