summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/src/controllers/createRobot.ts18
-rw-r--r--backend/src/controllers/getRobots.ts14
-rw-r--r--backend/src/controllers/loginUser.ts32
-rw-r--r--backend/src/controllers/moveAllRobots.ts25
-rw-r--r--backend/src/controllers/moveRobot.ts30
-rw-r--r--backend/src/controllers/stopAllRobots.ts21
-rw-r--r--backend/src/controllers/stopRobot.ts27
-rw-r--r--backend/src/routes/router.ts10
-rw-r--r--backend/src/simulation/robotMovementSimulator.ts241
-rw-r--r--backend/src/types/express.d.ts5
-rw-r--r--backend/src/types/robot.ts30
-rw-r--r--backend/src/types/user.ts7
-rw-r--r--frontend/.gitignore17
-rw-r--r--frontend/src/components/AddRobotForm.tsx11
-rw-r--r--frontend/src/components/CityMap.tsx6
-rw-r--r--frontend/src/components/RobotList.tsx98
-rw-r--r--frontend/src/components/Sidebar.tsx26
-rw-r--r--frontend/src/components/SimulationActions.tsx35
-rw-r--r--frontend/src/pages/Dashboard.tsx13
-rw-r--r--frontend/src/styles/index.css16
-rw-r--r--frontend/src/types/robot.ts23
21 files changed, 612 insertions, 93 deletions
diff --git a/backend/src/controllers/createRobot.ts b/backend/src/controllers/createRobot.ts
index fa483af..dbc1959 100644
--- a/backend/src/controllers/createRobot.ts
+++ b/backend/src/controllers/createRobot.ts
@@ -1,14 +1,16 @@
import { Request, Response } from "express";
import { QueryResult } from "pg";
+import { Server } from "socket.io";
import db from "../database/postgres.js";
import redisClient from "../database/redis.js";
+import { ErrorResponse } from "../types/error.js";
import { CreateRequest } from "../types/request.js";
-import { Robot } from "../types/robot.js";
+import { CreateRobotResponse, Robot } from "../types/robot.js";
const ROBOTS_CACHE_KEY = "allMyRobots";
async function createRobot(req: Request, res: Response) {
- const io = req.app.get("io");
+ const io: Server = req.app.get("io");
const { name } = req.body as CreateRequest;
@@ -44,16 +46,20 @@ async function createRobot(req: Request, res: Response) {
io.emit("robots_update", allRobots);
console.log("WebSocket update with newly created robot.");
- return res.status(201).json({
+ const createRobotResponse: CreateRobotResponse = {
message: "Robot successfully created.",
robot: newRobot,
- });
+ };
+
+ return res.status(201).json(createRobotResponse);
} catch (error) {
console.error("Error creating the robot: ", error);
- return res.status(500).json({
+
+ const errorResponse: ErrorResponse = {
message: "Internal server error during robot creation.",
error,
- });
+ };
+ return res.status(500).json(errorResponse);
}
}
diff --git a/backend/src/controllers/getRobots.ts b/backend/src/controllers/getRobots.ts
index b634306..80b430f 100644
--- a/backend/src/controllers/getRobots.ts
+++ b/backend/src/controllers/getRobots.ts
@@ -17,9 +17,14 @@ async function getRobots(_req: Request, res: Response) {
console.log("Data served from Redis cache.");
const robots: Robot[] = JSON.parse(cachedData);
+ const simulationRunning = robots?.every(
+ (robot) => robot.status === "moving"
+ );
+
const response: RobotsResponse = {
source: "cache",
- data: robots,
+ robots,
+ simulationRunning,
};
return res.status(200).json(response);
@@ -31,6 +36,10 @@ async function getRobots(_req: Request, res: Response) {
);
const robots = robotsQuery.rows;
+ const simulationRunning = robots?.every(
+ (robot) => robot.status === "moving"
+ );
+
await redisClient.set(ROBOTS_CACHE_KEY, JSON.stringify(robots), {
EX: CACHE_TTL,
});
@@ -38,7 +47,8 @@ async function getRobots(_req: Request, res: Response) {
console.log("Cache miss: data queried from PostgreSQL.");
const response: RobotsResponse = {
source: "database",
- data: robots,
+ robots,
+ simulationRunning,
};
return res.status(200).json(response);
diff --git a/backend/src/controllers/loginUser.ts b/backend/src/controllers/loginUser.ts
index 860bce2..9cfb992 100644
--- a/backend/src/controllers/loginUser.ts
+++ b/backend/src/controllers/loginUser.ts
@@ -3,15 +3,20 @@ import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import { QueryResult } from "pg";
import db from "../database/postgres.js";
+import { ErrorResponse } from "../types/error.js";
import type { LoginRequest } from "../types/request.js";
-import type { AuthorizedUser, DatabaseUser } from "../types/user.js";
+import type {
+ AuthorizedUser,
+ DatabaseUser,
+ LoginResponse,
+} from "../types/user.js";
async function loginUser(req: Request, res: Response) {
const { email, password } = req.body as LoginRequest;
if (!email || !password) {
return res.status(400).json({
- message: "E-Mail und Passwort sind erforderlich.",
+ message: "E-mail and password are required.",
});
}
@@ -25,7 +30,7 @@ async function loginUser(req: Request, res: Response) {
const user = queryResult.rows[0];
if (!user) {
- return res.status(401).json({ message: "Login Daten ungültig." });
+ return res.status(401).json({ message: "Login data invalid." });
}
// Check if password is correct
@@ -36,7 +41,7 @@ async function loginUser(req: Request, res: Response) {
if (!isValidPassword) {
return res.status(401).json({
- message: "Das Passwort ist nicht korrekt.",
+ message: "The password is incorrect.",
});
}
@@ -49,16 +54,21 @@ async function loginUser(req: Request, res: Response) {
// Create token for authentication
const token = jwt.sign(userData, process.env.JWT_SECRET!);
- return res.status(200).json({
- message: "Erfolgreiche Anmeldung.",
+ const loginResponse: LoginResponse = {
+ message: "Successful login.",
user: userData,
token,
- });
+ };
+
+ return res.status(200).json(loginResponse);
} catch (error) {
- console.error("Fehler beim Login: ", error);
- return res
- .status(500)
- .json({ message: "Interner Serverfehler beim Login.", error });
+ console.error("Error on login attempt: ", error);
+
+ const errorResponse: ErrorResponse = {
+ message: "Internal server error on login attempt.",
+ error,
+ };
+ return res.status(500).json(errorResponse);
}
}
diff --git a/backend/src/controllers/moveAllRobots.ts b/backend/src/controllers/moveAllRobots.ts
new file mode 100644
index 0000000..e4d1252
--- /dev/null
+++ b/backend/src/controllers/moveAllRobots.ts
@@ -0,0 +1,25 @@
+import { Request, Response } from "express";
+import { Server } from "socket.io";
+import { setAllRobotsMoving } from "../simulation/robotMovementSimulator";
+import { ErrorResponse } from "../types/error";
+import { SimulationResponse } from "../types/robot";
+
+async function moveAllRobots(req: Request, res: Response) {
+ const io: Server = req.app.get("io");
+
+ try {
+ const result: SimulationResponse = await setAllRobotsMoving(io);
+ return res.status(200).json(result);
+ } catch (error) {
+ console.error("Error on trying to set all robots moving:", error);
+
+ const errorResponse: ErrorResponse = {
+ message:
+ "Internal server error on trying to set all robots moving.",
+ error,
+ };
+ return res.status(500).json(errorResponse);
+ }
+}
+
+export default moveAllRobots;
diff --git a/backend/src/controllers/moveRobot.ts b/backend/src/controllers/moveRobot.ts
new file mode 100644
index 0000000..1b53f17
--- /dev/null
+++ b/backend/src/controllers/moveRobot.ts
@@ -0,0 +1,30 @@
+import { Request, Response } from "express";
+import { Server } from "socket.io";
+import { setRobotMoving } from "../simulation/robotMovementSimulator";
+import { ErrorResponse } from "../types/error";
+import { SimulationResponse } from "../types/robot";
+
+async function moveRobot(req: Request, res: Response) {
+ const io: Server = req.app.get("io");
+
+ const robotId = Number(req.params.id);
+
+ if (!robotId || Number.isNaN(robotId)) {
+ return res.status(400).json({ message: "Invalid robot ID." });
+ }
+
+ try {
+ const result: SimulationResponse = await setRobotMoving(io, robotId);
+ return res.status(200).json(result);
+ } catch (error) {
+ console.error(`Error on trying to start robot ID ${robotId}: `, error);
+
+ const errorResponse: ErrorResponse = {
+ message: `Internal server error on trying to start robot ID ${robotId}.`,
+ error,
+ };
+ return res.status(500).json(errorResponse);
+ }
+}
+
+export default moveRobot;
diff --git a/backend/src/controllers/stopAllRobots.ts b/backend/src/controllers/stopAllRobots.ts
new file mode 100644
index 0000000..7218e66
--- /dev/null
+++ b/backend/src/controllers/stopAllRobots.ts
@@ -0,0 +1,21 @@
+import { Request, Response } from "express";
+import { setAllRobotsIdle } from "../simulation/robotMovementSimulator";
+import { ErrorResponse } from "../types/error";
+import { SimulationResponse } from "../types/robot";
+
+async function stopAllRobots(_req: Request, res: Response) {
+ try {
+ const result: SimulationResponse = await setAllRobotsIdle();
+ return res.status(200).json(result);
+ } catch (error) {
+ console.error("Error on trying to stop all robots:", error);
+
+ const errorResponse: ErrorResponse = {
+ message: "Internal server error on trying to stop all robots.",
+ error,
+ };
+ return res.status(500).json(errorResponse);
+ }
+}
+
+export default stopAllRobots;
diff --git a/backend/src/controllers/stopRobot.ts b/backend/src/controllers/stopRobot.ts
new file mode 100644
index 0000000..d0d7c4f
--- /dev/null
+++ b/backend/src/controllers/stopRobot.ts
@@ -0,0 +1,27 @@
+import { Request, Response } from "express";
+import { setRobotIdle } from "../simulation/robotMovementSimulator";
+import { ErrorResponse } from "../types/error";
+import { SimulationResponse } from "../types/robot";
+
+async function stopRobot(req: Request, res: Response) {
+ const robotId = Number(req.params.id);
+
+ if (!robotId || Number.isNaN(robotId)) {
+ return res.status(400).json({ message: "Ungültige Roboter ID." });
+ }
+
+ try {
+ const result: SimulationResponse = await setRobotIdle(robotId);
+ return res.status(200).json(result);
+ } catch (error) {
+ console.error(`Error on trying to stop robot ID ${robotId}: `, error);
+
+ const errorResponse: ErrorResponse = {
+ message: `Internal server error on trying to stop robot ID ${robotId}.`,
+ error,
+ };
+ return res.status(500).json(errorResponse);
+ }
+}
+
+export default stopRobot;
diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts
index b526a9b..72fd885 100644
--- a/backend/src/routes/router.ts
+++ b/backend/src/routes/router.ts
@@ -3,6 +3,10 @@ import createRobot from "../controllers/createRobot.js";
import generateAdmin from "../controllers/generateAdmin.js";
import getRobots from "../controllers/getRobots.js";
import loginUser from "../controllers/loginUser.js";
+import moveAllRobots from "../controllers/moveAllRobots.js";
+import moveRobot from "../controllers/moveRobot.js";
+import stopAllRobots from "../controllers/stopAllRobots.js";
+import stopRobot from "../controllers/stopRobot.js";
import authenticateUser from "../middleware/authCheck.js";
const router = Router();
@@ -15,6 +19,12 @@ router.post("/auth/login", loginUser);
router.get("/robots", authenticateUser, getRobots);
// Create a new robot; protected route
router.post("/robots", authenticateUser, createRobot);
+// All robots move or stop; protected routes
+router.post("/robots/move", authenticateUser, moveAllRobots);
+router.post("/robots/stop", authenticateUser, stopAllRobots);
+// Single robot move or stop; protected routes
+router.post("/robots/:id/move", authenticateUser, moveRobot);
+router.post("/robots/:id/stop", authenticateUser, stopRobot);
export default router;
diff --git a/backend/src/simulation/robotMovementSimulator.ts b/backend/src/simulation/robotMovementSimulator.ts
new file mode 100644
index 0000000..2299f43
--- /dev/null
+++ b/backend/src/simulation/robotMovementSimulator.ts
@@ -0,0 +1,241 @@
+import { QueryResult } from "pg";
+import { Server } from "socket.io";
+import db from "../database/postgres.js";
+import {
+ Robot,
+ RobotPosition,
+ RobotsUpdateBroadcast,
+ SimulationResponse,
+} from "../types/robot.js";
+
+// Coordinates for the boundaries of the frontend map
+const LEIPZIG_AREA = {
+ WEST: 12.22,
+ SOUTH: 51.26,
+ EAST: 12.54,
+ NORTH: 51.44,
+} as const;
+
+const LEIPZIG_CENTER = {
+ LAT: 51.340863,
+ LON: 12.375919,
+} as const;
+
+const SIMULATION_INTERVAL_MS = 2000;
+
+// IDs of moving robots
+const movingRobots = new Set<number>();
+
+// Global simulation timer
+let simulationTimer: NodeJS.Timeout | null = null;
+
+/*
+ MOVEMENT FUNCTION
+ generate random position within Leipzig based on current position
+*/
+
+function generateRandomPosition(currentLat: string, currentLon: string) {
+ // 0.0005 degrees ca. 50-70 meters
+ const deltaLat = (Math.random() - 0.5) * 0.0005;
+ const deltaLon = (Math.random() - 0.5) * 0.0005;
+
+ let newLat = parseFloat(currentLat) + deltaLat;
+ let newLon = parseFloat(currentLon) + deltaLon;
+
+ // Check if robot's new position would be outside of Leipzig, if yes set back to center
+ const outsideLatBoundary =
+ newLat < LEIPZIG_AREA.SOUTH || newLat > LEIPZIG_AREA.NORTH;
+ const outsideLonBoundary =
+ newLon < LEIPZIG_AREA.WEST || newLon > LEIPZIG_AREA.EAST;
+
+ if (outsideLatBoundary || outsideLonBoundary) {
+ newLat = LEIPZIG_CENTER.LAT;
+ newLon = LEIPZIG_CENTER.LON;
+ }
+
+ return {
+ lat: newLat.toFixed(7),
+ lon: newLon.toFixed(7),
+ };
+}
+
+/*
+ MAIN FUNCTION:
+ move a robot, update database, websocket broadcast
+*/
+
+async function updateRobotPositions(io: Server) {
+ let client;
+
+ try {
+ client = await db.connect();
+
+ const allRobotsQuery: QueryResult<Robot> = await client.query(
+ "SELECT * FROM robots ORDER BY id;"
+ );
+ const allRobots = allRobotsQuery.rows;
+
+ // Update database
+ await client.query("BEGIN");
+
+ const updatedRobots: Robot[] = [];
+
+ for (const robot of allRobots) {
+ // If not moving, keep position & set idle
+ if (!movingRobots?.has(robot.id)) {
+ if (robot.status === "moving") {
+ const idleQuery: QueryResult<Robot> = await client.query(
+ `
+ UPDATE robots
+ SET status = 'idle',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = $1
+ RETURNING *;
+ `,
+ [robot.id]
+ );
+ updatedRobots.push(idleQuery.rows[0]);
+ } else {
+ updatedRobots.push(robot);
+ }
+ continue;
+ }
+
+ // If moving, assign new position and add previous to log
+ const newPos: RobotPosition = generateRandomPosition(
+ robot.lat,
+ robot.lon
+ );
+
+ const previousPositionsLog: RobotPosition[] =
+ robot.robot_positions || [];
+
+ previousPositionsLog.push({ lat: robot.lat, lon: robot.lon });
+
+ // Limit log to 10 entries, remove oldest when more
+ if (previousPositionsLog.length > 10) {
+ previousPositionsLog.shift();
+ }
+
+ const updateQuery: QueryResult<Robot> = await client.query(
+ `
+ UPDATE robots
+ SET
+ lat = $1,
+ lon = $2,
+ status = 'moving',
+ robot_positions = $3::jsonb,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = $4
+ RETURNING *;
+ `,
+ [
+ newPos.lat,
+ newPos.lon,
+ JSON.stringify(previousPositionsLog),
+ robot.id,
+ ]
+ );
+
+ updatedRobots.push(updateQuery.rows[0]);
+ }
+
+ await client.query("COMMIT");
+
+ // Websocket broadcast, make sure robots stay in correct order
+ updatedRobots.sort((a, b) => a.id - b.id);
+
+ const broadcastPayload: RobotsUpdateBroadcast = { updatedRobots };
+
+ io.emit("robots_update", broadcastPayload);
+
+ // Check if this was the final update, setting all to idle with no more robots moving
+ if (movingRobots.size === 0) {
+ if (simulationTimer) {
+ clearInterval(simulationTimer);
+ simulationTimer = null;
+ }
+ }
+ } catch (error) {
+ console.error("Error in the global simulation timer: ", error);
+ if (client) await client.query("ROLLBACK");
+
+ // Stop simulation on error
+ if (simulationTimer) {
+ clearInterval(simulationTimer);
+ simulationTimer = null;
+ }
+ } finally {
+ if (client) client.release();
+ }
+}
+
+/*
+ SIMULATION FUNCTION
+ Runs the simulation by repeating the main function in intervals
+*/
+
+async function startSimulation(io: Server) {
+ if (simulationTimer) return; // Already running
+
+ // Immediate execution right away without delay
+ await updateRobotPositions(io);
+
+ // Then interval for subsequent updates
+ simulationTimer = setInterval(async () => {
+ await updateRobotPositions(io);
+ }, SIMULATION_INTERVAL_MS);
+}
+
+/*
+ PUBLIC CONTROLLER FUNCTIONS:
+ start or stop movement of one or all robots
+*/
+
+export async function setRobotMoving(
+ io: Server,
+ robotId: number
+): Promise<SimulationResponse> {
+ movingRobots.add(Number(robotId));
+ await startSimulation(io);
+
+ return {
+ message: `Robot ID ${robotId} set in motion.`,
+ status: "moving",
+ };
+}
+
+export async function setRobotIdle(
+ robotId: number
+): Promise<SimulationResponse> {
+ movingRobots.delete(Number(robotId));
+
+ return {
+ message: `Robot ID ${robotId} stopped.`,
+ status: "idle",
+ };
+}
+
+export async function setAllRobotsMoving(
+ io: Server
+): Promise<SimulationResponse> {
+ const allRobotIDsQuery: QueryResult<{ id: Robot["id"] }> = await db.query(
+ "SELECT id FROM robots;"
+ );
+ allRobotIDsQuery.rows.forEach((queryObject) =>
+ movingRobots.add(queryObject.id)
+ );
+
+ await startSimulation(io);
+
+ return {
+ message: "All robots set in motion.",
+ };
+}
+
+export async function setAllRobotsIdle(): Promise<SimulationResponse> {
+ movingRobots.clear();
+ return {
+ message: "All robots stopped.",
+ };
+}
diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts
index 266a9f9..7e93345 100644
--- a/backend/src/types/express.d.ts
+++ b/backend/src/types/express.d.ts
@@ -1,12 +1,7 @@
import type { AuthorizedUser } from "./user.js";
-import { Server } from "socket.io";
declare global {
namespace Express {
- interface Application {
- io: Server;
- }
-
interface Request {
user?: AuthorizedUser;
}
diff --git a/backend/src/types/robot.ts b/backend/src/types/robot.ts
index 40ce282..fe20422 100644
--- a/backend/src/types/robot.ts
+++ b/backend/src/types/robot.ts
@@ -1,14 +1,16 @@
export type RobotPosition = {
- lat: number;
- lon: number;
+ lat: string;
+ lon: string;
};
+export type RobotStatus = "idle" | "moving";
+
export type Robot = {
id: number;
name: string;
- status: "idle" | "moving";
- lat: number;
- lon: number;
+ status: RobotStatus;
+ lat: string;
+ lon: string;
robot_positions: RobotPosition[];
created_at: string;
updated_at: string;
@@ -16,5 +18,21 @@ export type Robot = {
export type RobotsResponse = {
source: "cache" | "database";
- data: Robot[];
+ robots: Robot[];
+ simulationRunning: boolean;
+};
+
+export type CreateRobotResponse = {
+ message: string;
+ robot: Robot;
+};
+
+export type RobotsUpdateBroadcast = {
+ updatedRobots: Robot[];
+};
+
+export type SimulationResponse = {
+ message: string;
+ status?: RobotStatus;
};
+
diff --git a/backend/src/types/user.ts b/backend/src/types/user.ts
index b2c7ffc..15b24c3 100644
--- a/backend/src/types/user.ts
+++ b/backend/src/types/user.ts
@@ -16,3 +16,10 @@ export type DatabaseUser = {
password_hash: string;
created_at: string;
};
+
+export type LoginResponse = {
+ message: string;
+ user: AuthorizedUser;
+ token: string;
+};
+
diff --git a/frontend/.gitignore b/frontend/.gitignore
index a547bf3..0aa16e3 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -1,24 +1,9 @@
-# Logs
logs
*.log
npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
node_modules
dist
dist-ssr
+.env
*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
diff --git a/frontend/src/components/AddRobotForm.tsx b/frontend/src/components/AddRobotForm.tsx
index c178657..0d2a00f 100644
--- a/frontend/src/components/AddRobotForm.tsx
+++ b/frontend/src/components/AddRobotForm.tsx
@@ -5,22 +5,26 @@ import {
type SetStateAction,
} from "react";
import type { ErrorResponse } from "../types/error";
-import type { CreateRobotResponse } from "../types/robot";
+import type { CreateRobotResponse, Robot } from "../types/robot";
type Props = {
apiUrl: string;
errorMessage: string;
+ robots: Robot[];
token: string | null;
setErrorMessage: Dispatch<SetStateAction<string>>;
setIsAddingRobot: Dispatch<SetStateAction<boolean>>;
+ setRobots: Dispatch<SetStateAction<Robot[]>>;
};
function AddRobotForm({
apiUrl,
errorMessage,
+ robots,
token,
setErrorMessage,
setIsAddingRobot,
+ setRobots,
}: Props) {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [newRobotName, setNewRobotName] = useState<string>("");
@@ -69,7 +73,10 @@ function AddRobotForm({
}
const data: CreateRobotResponse = await response.json();
- console.log("Robot created successfully: ", data);
+ console.log(data.message, data.robot);
+
+ const newRobotList = [...robots, data.robot];
+ setRobots(newRobotList);
// Reset form and close input field
setNewRobotName("");
diff --git a/frontend/src/components/CityMap.tsx b/frontend/src/components/CityMap.tsx
index b61e69a..795598e 100644
--- a/frontend/src/components/CityMap.tsx
+++ b/frontend/src/components/CityMap.tsx
@@ -65,9 +65,11 @@ function CityMap({ robots }: Props) {
source.clear();
- robots.forEach((robot) => {
+ robots?.forEach((robot) => {
const feature = new Feature({
- geometry: new Point(fromLonLat([robot?.lon, robot?.lat])),
+ geometry: new Point(
+ fromLonLat([parseFloat(robot?.lon), parseFloat(robot?.lat)])
+ ),
robotId: robot?.id,
});
diff --git a/frontend/src/components/RobotList.tsx b/frontend/src/components/RobotList.tsx
index d711372..21d8e63 100644
--- a/frontend/src/components/RobotList.tsx
+++ b/frontend/src/components/RobotList.tsx
@@ -1,11 +1,22 @@
-import { useState } from "react";
-import type { Robot } from "../types/robot";
+import {
+ useState,
+ type Dispatch,
+ type MouseEvent,
+ type SetStateAction,
+} from "react";
+import type { ErrorResponse } from "../types/error";
+import type { Robot, SimulationResponse } from "../types/robot";
type ExpandedRobotsState = Record<number, boolean>;
-type Props = { robots: Robot[]; };
+type Props = {
+ apiUrl: string;
+ robots: Robot[];
+ token: string | null;
+ setErrorMessage: Dispatch<SetStateAction<string>>;
+};
-function RobotList({ robots }: Props) {
+function RobotList({ apiUrl, robots, token, setErrorMessage }: Props) {
const [expandedRobots, setExpandedRobots] = useState<ExpandedRobotsState>(
{}
);
@@ -17,15 +28,89 @@ function RobotList({ robots }: Props) {
}));
}
+ // Move or stop individual robot
+ async function controlSingleRobot(
+ event: MouseEvent<HTMLButtonElement>,
+ robotId: number,
+ robotStatus: Robot["status"]
+ ) {
+ const isRobotMoving = robotStatus === "moving";
+ const button = event.currentTarget;
+ button.disabled = true; // prevent spamming
+
+ try {
+ // Make button clickable again after 1 second
+ setTimeout(() => {
+ button.disabled = false;
+ }, 1000);
+
+ const response = await fetch(
+ `${apiUrl}/robots/${robotId}/${
+ isRobotMoving ? "stop" : "move"
+ }`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorData: ErrorResponse = await response.json();
+ throw new Error(
+ errorData.message ||
+ `Failed to ${
+ isRobotMoving ? "stop" : "start"
+ } robot ID ${robotId}.`
+ );
+ }
+
+ const data: SimulationResponse = await response.json();
+ console.log(data.message);
+ } catch (error) {
+ console.error(
+ `Error ${
+ isRobotMoving ? "stopping" : "starting"
+ } robot ID ${robotId}: `,
+ error
+ );
+
+ if (error instanceof Error) {
+ setErrorMessage(error.message);
+ } else {
+ setErrorMessage("An unexpected error occurred.");
+ }
+ }
+ }
+
return (
<ul className="sidebar-robot-list">
- {robots.map((robot) => {
+ {robots?.map((robot) => {
const isExpanded = expandedRobots[robot?.id];
return (
<li key={robot?.id}>
<p className="robot-name">{robot?.name}</p>
+ {/* Move/stop individual robot */}
+ <button
+ className={`btn btn-single-robot btn-${
+ robot?.status === "idle" ? "start" : "stop"
+ }`}
+ onClick={(event) =>
+ controlSingleRobot(
+ event,
+ robot?.id,
+ robot?.status
+ )
+ }
+ >
+ {robot?.status === "idle" ? "MOVE" : "STOP"}
+ </button>
+
+ {/* Movement status */}
<p>
Status:{" "}
<span className={`robot-status-${robot?.status}`}>
@@ -33,12 +118,14 @@ function RobotList({ robots }: Props) {
</span>
</p>
+ {/* Current position */}
<p className="robot-coordinates-label">Position:</p>
<ul className="robot-coordinates">
<li>Lat: {robot?.lat}</li>
<li>Lon: {robot?.lon}</li>
</ul>
+ {/* Expand position log */}
<button
className="btn btn-robot-history-toggle"
onClick={() => toggleRobotHistory(robot?.id)}
@@ -52,6 +139,7 @@ function RobotList({ robots }: Props) {
</span>
</button>
+ {/* Position log/history */}
<div
className={`robot-history ${
isExpanded ? "expanded" : ""
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 8bd795b..3760d4e 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -8,13 +8,24 @@ import RobotList from "./RobotList";
import SimulationActions from "./SimulationActions";
type Props = {
+ activeSimulation: boolean;
errorMessage: string;
robots: Robot[];
token: string | null;
+ setActiveSimulation: Dispatch<SetStateAction<boolean>>;
setErrorMessage: Dispatch<SetStateAction<string>>;
+ setRobots: Dispatch<SetStateAction<Robot[]>>;
};
-function Sidebar({ errorMessage, robots, token, setErrorMessage }: Props) {
+function Sidebar({
+ activeSimulation,
+ errorMessage,
+ robots,
+ token,
+ setActiveSimulation,
+ setErrorMessage,
+ setRobots,
+}: Props) {
const [isAddingRobot, setIsAddingRobot] = useState(false);
function handleAddClick() {
@@ -35,13 +46,15 @@ function Sidebar({ errorMessage, robots, token, setErrorMessage }: Props) {
</button>
</div>
- {isAddingRobot && (
+ {isAddingRobot && (
<AddRobotForm
apiUrl={API_URL}
errorMessage={errorMessage}
+ robots={robots}
token={token}
setErrorMessage={setErrorMessage}
setIsAddingRobot={setIsAddingRobot}
+ setRobots={setRobots}
/>
)}
@@ -50,12 +63,19 @@ function Sidebar({ errorMessage, robots, token, setErrorMessage }: Props) {
)}
<SimulationActions
+ activeSimulation={activeSimulation}
apiUrl={API_URL}
token={token}
+ setActiveSimulation={setActiveSimulation}
setErrorMessage={setErrorMessage}
/>
- <RobotList robots={robots} />
+ <RobotList
+ apiUrl={API_URL}
+ robots={robots}
+ token={token}
+ setErrorMessage={setErrorMessage}
+ />
</div>
);
}
diff --git a/frontend/src/components/SimulationActions.tsx b/frontend/src/components/SimulationActions.tsx
index c91b933..0bfca3f 100644
--- a/frontend/src/components/SimulationActions.tsx
+++ b/frontend/src/components/SimulationActions.tsx
@@ -1,19 +1,24 @@
-import { useState, type Dispatch, type SetStateAction } from "react";
+import { type Dispatch, type SetStateAction } from "react";
import type { ErrorResponse } from "../types/error";
+import type { SimulationResponse } from "../types/robot";
type Props = {
+ activeSimulation: boolean;
apiUrl: string;
token: string | null;
+ setActiveSimulation: Dispatch<SetStateAction<boolean>>;
setErrorMessage: Dispatch<SetStateAction<string>>;
};
-function SimulationActions({ apiUrl, token, setErrorMessage }: Props) {
- const [isSimulationActive, setIsSimulationActive] =
- useState<boolean>(false);
-
- // TODO type responses
+function SimulationActions({
+ activeSimulation,
+ apiUrl,
+ token,
+ setActiveSimulation,
+ setErrorMessage,
+}: Props) {
async function handleStartAllRobots() {
- setIsSimulationActive(true);
+ setActiveSimulation(true);
try {
const response = await fetch(`${apiUrl}/robots/move`, {
@@ -31,7 +36,8 @@ function SimulationActions({ apiUrl, token, setErrorMessage }: Props) {
);
}
- console.log("All robots set moving.");
+ const data: SimulationResponse = await response.json();
+ console.log(data.message);
} catch (error) {
console.error("Error starting robots:", error);
@@ -41,12 +47,12 @@ function SimulationActions({ apiUrl, token, setErrorMessage }: Props) {
setErrorMessage("An unexpected error occurred.");
}
- setIsSimulationActive(false);
+ setActiveSimulation(false);
}
}
async function handleStopAllRobots() {
- setIsSimulationActive(false);
+ setActiveSimulation(false);
try {
const response = await fetch(`${apiUrl}/robots/stop`, {
@@ -64,7 +70,8 @@ function SimulationActions({ apiUrl, token, setErrorMessage }: Props) {
);
}
- console.log("All robots set idle.");
+ const data: SimulationResponse = await response.json();
+ console.log(data.message);
} catch (error) {
console.error("Error stopping robots:", error);
@@ -74,7 +81,7 @@ function SimulationActions({ apiUrl, token, setErrorMessage }: Props) {
setErrorMessage("An unexpected error occurred.");
}
- setIsSimulationActive(true);
+ setActiveSimulation(true);
}
}
@@ -83,7 +90,7 @@ function SimulationActions({ apiUrl, token, setErrorMessage }: Props) {
<button
className="btn btn-start"
onClick={handleStartAllRobots}
- disabled={isSimulationActive}
+ disabled={activeSimulation}
>
Start simulation
</button>
@@ -91,7 +98,7 @@ function SimulationActions({ apiUrl, token, setErrorMessage }: Props) {
<button
className="btn btn-stop"
onClick={handleStopAllRobots}
- disabled={!isSimulationActive}
+ disabled={!activeSimulation}
>
Stop simulation
</button>
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 088fee2..b1b38e4 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -14,6 +14,8 @@ import type { Robot, RobotsResponse } from "../types/robot";
function Dashboard() {
const [errorMessage, setErrorMessage] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
+ const [isSimulationActive, setIsSimulationActive] =
+ useState<boolean>(false);
const [robots, setRobots] = useState<Robot[]>([]);
const navigate = useNavigate();
@@ -25,6 +27,7 @@ function Dashboard() {
async function handleLogout() {
localStorage.removeItem("token-robot-tracker");
localStorage.removeItem("user");
+ // setIsSimulationActive(false);
navigate("/login", { replace: true });
}
@@ -57,7 +60,9 @@ function Dashboard() {
}
const data: RobotsResponse = await response.json();
- setRobots(data.data);
+
+ setRobots(data.robots);
+ setIsSimulationActive(data.simulationRunning);
} catch (error) {
console.error("Failed to load the robots:", error);
@@ -66,7 +71,6 @@ function Dashboard() {
} else {
setErrorMessage("An unexpected error occurred.");
}
-
} finally {
setIsLoading(false);
}
@@ -78,7 +82,7 @@ function Dashboard() {
const socket = io(API_URL);
// Listen for real-time robot updates
- socket.on("robots_update", (updatedRobots) => {
+ socket.on("robots_update", ({ updatedRobots }) => {
setRobots(updatedRobots);
});
@@ -95,10 +99,13 @@ function Dashboard() {
<Header user={user} logout={handleLogout} />
<CityMap robots={robots} />
<Sidebar
+ activeSimulation={isSimulationActive}
errorMessage={errorMessage}
robots={robots}
token={token}
+ setActiveSimulation={setIsSimulationActive}
setErrorMessage={setErrorMessage}
+ setRobots={setRobots}
/>
</div>
);
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
index dcc2590..bd4eacd 100644
--- a/frontend/src/styles/index.css
+++ b/frontend/src/styles/index.css
@@ -20,25 +20,17 @@
--text-small: 0.9rem;
box-sizing: border-box;
+
+ background-color: #fff;
+ color: #213547;
+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
-
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-
- @media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #fff;
- }
- }
}
body {
diff --git a/frontend/src/types/robot.ts b/frontend/src/types/robot.ts
index 965afea..d9b2e7d 100644
--- a/frontend/src/types/robot.ts
+++ b/frontend/src/types/robot.ts
@@ -1,14 +1,16 @@
export type RobotPosition = {
- lat: number;
- lon: number;
+ lat: string;
+ lon: string;
};
+export type RobotStatus = "idle" | "moving";
+
export type Robot = {
id: number;
name: string;
- status: "idle" | "moving";
- lat: number;
- lon: number;
+ status: RobotStatus;
+ lat: string;
+ lon: string;
robot_positions: RobotPosition[];
created_at: string;
updated_at: string;
@@ -16,7 +18,8 @@ export type Robot = {
export type RobotsResponse = {
source: "cache" | "database";
- data: Robot[];
+ robots: Robot[];
+ simulationRunning: boolean;
};
export type CreateRobotResponse = {
@@ -24,3 +27,11 @@ export type CreateRobotResponse = {
robot: Robot;
};
+export type RobotsUpdateBroadcast = {
+ updatedRobots: Robot[];
+};
+
+export type SimulationResponse = {
+ message: string;
+ status?: RobotStatus;
+};