summaryrefslogtreecommitdiff
path: root/frontend/src/components/app
diff options
context:
space:
mode:
authorGeorgios Andreadis <info@gandreadis.com>2020-06-29 15:47:09 +0200
committerFabian Mastenbroek <mail.fabianm@gmail.com>2020-08-24 16:08:41 +0200
commit90fae26aa4bd0e0eb3272ff6e6524060e9004fbb (patch)
treebf6943882f5fa5f3114c01fc571503c79ee1056d /frontend/src/components/app
parent7032a007d4431f5a0c4c5e2d3f3bd20462d49950 (diff)
Prepare frontend repository for monorepo
This change prepares the frontend Git repository for the monorepo residing at https://github.com/atlarge-research.com/opendc. To accomodate for this, we move all files into a frontend subdirectory.
Diffstat (limited to 'frontend/src/components/app')
-rw-r--r--frontend/src/components/app/map/LoadingScreen.js11
-rw-r--r--frontend/src/components/app/map/MapConstants.js29
-rw-r--r--frontend/src/components/app/map/MapStageComponent.js126
-rw-r--r--frontend/src/components/app/map/controls/ExportCanvasComponent.js13
-rw-r--r--frontend/src/components/app/map/controls/ScaleIndicatorComponent.js14
-rw-r--r--frontend/src/components/app/map/controls/ScaleIndicatorComponent.sass9
-rw-r--r--frontend/src/components/app/map/controls/ToolPanelComponent.js13
-rw-r--r--frontend/src/components/app/map/controls/ToolPanelComponent.sass5
-rw-r--r--frontend/src/components/app/map/controls/ZoomControlComponent.js24
-rw-r--r--frontend/src/components/app/map/elements/Backdrop.js16
-rw-r--r--frontend/src/components/app/map/elements/GrayLayer.js17
-rw-r--r--frontend/src/components/app/map/elements/HoverTile.js30
-rw-r--r--frontend/src/components/app/map/elements/ImageComponent.js48
-rw-r--r--frontend/src/components/app/map/elements/RackFillBar.js89
-rw-r--r--frontend/src/components/app/map/elements/RoomTile.js20
-rw-r--r--frontend/src/components/app/map/elements/TileObject.js29
-rw-r--r--frontend/src/components/app/map/elements/TilePlusIcon.js52
-rw-r--r--frontend/src/components/app/map/elements/WallSegment.js39
-rw-r--r--frontend/src/components/app/map/groups/DatacenterGroup.js40
-rw-r--r--frontend/src/components/app/map/groups/GridGroup.js41
-rw-r--r--frontend/src/components/app/map/groups/RackGroup.js43
-rw-r--r--frontend/src/components/app/map/groups/RoomGroup.js56
-rw-r--r--frontend/src/components/app/map/groups/TileGroup.js43
-rw-r--r--frontend/src/components/app/map/groups/WallGroup.js22
-rw-r--r--frontend/src/components/app/map/layers/HoverLayerComponent.js85
-rw-r--r--frontend/src/components/app/map/layers/MapLayerComponent.js22
-rw-r--r--frontend/src/components/app/map/layers/ObjectHoverLayerComponent.js11
-rw-r--r--frontend/src/components/app/map/layers/RoomHoverLayerComponent.js6
-rw-r--r--frontend/src/components/app/sidebars/Sidebar.js50
-rw-r--r--frontend/src/components/app/sidebars/Sidebar.sass50
-rw-r--r--frontend/src/components/app/sidebars/elements/LoadBarComponent.js22
-rw-r--r--frontend/src/components/app/sidebars/elements/LoadChartComponent.js90
-rw-r--r--frontend/src/components/app/sidebars/simulation/ExperimentMetadataComponent.js23
-rw-r--r--frontend/src/components/app/sidebars/simulation/LoadMetricComponent.js40
-rw-r--r--frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.js22
-rw-r--r--frontend/src/components/app/sidebars/simulation/SimulationSidebarComponent.sass8
-rw-r--r--frontend/src/components/app/sidebars/simulation/TaskComponent.js58
-rw-r--r--frontend/src/components/app/sidebars/simulation/TraceComponent.js20
-rw-r--r--frontend/src/components/app/sidebars/topology/NameComponent.js13
-rw-r--r--frontend/src/components/app/sidebars/topology/TopologySidebarComponent.js31
-rw-r--r--frontend/src/components/app/sidebars/topology/building/BuildingSidebarComponent.js20
-rw-r--r--frontend/src/components/app/sidebars/topology/building/NewRoomConstructionComponent.js31
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/BackToRackComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/DeleteMachineComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/MachineNameComponent.js7
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/MachineSidebarComponent.js27
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitAddComponent.js46
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitComponent.js78
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitListComponent.js29
-rw-r--r--frontend/src/components/app/sidebars/topology/machine/UnitTabsComponent.js65
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/BackToRoomComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/DeleteRackComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/EmptySlotComponent.js19
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/MachineComponent.js78
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/MachineListComponent.js26
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/MachineListComponent.sass2
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/RackNameComponent.js8
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.js34
-rw-r--r--frontend/src/components/app/sidebars/topology/rack/RackSidebarComponent.sass11
-rw-r--r--frontend/src/components/app/sidebars/topology/room/BackToBuildingComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/room/DeleteRoomComponent.js10
-rw-r--r--frontend/src/components/app/sidebars/topology/room/EditRoomComponent.js27
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RackConstructionComponent.js32
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RoomNameComponent.js8
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RoomSidebarComponent.js38
-rw-r--r--frontend/src/components/app/sidebars/topology/room/RoomTypeComponent.js8
-rw-r--r--frontend/src/components/app/timeline/PlayButtonComponent.js30
-rw-r--r--frontend/src/components/app/timeline/Timeline.sass116
-rw-r--r--frontend/src/components/app/timeline/TimelineComponent.js37
-rw-r--r--frontend/src/components/app/timeline/TimelineControlsComponent.js49
-rw-r--r--frontend/src/components/app/timeline/TimelineLabelsComponent.js15
71 files changed, 2281 insertions, 0 deletions
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;