diff --git a/client/src/components/production-board-kanban/production-board-kanban.container.jsx b/client/src/components/production-board-kanban/production-board-kanban.container.jsx index 643204469..07164c63c 100644 --- a/client/src/components/production-board-kanban/production-board-kanban.container.jsx +++ b/client/src/components/production-board-kanban/production-board-kanban.container.jsx @@ -23,6 +23,9 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp const fired = useRef(false); const client = useApolloClient(); const { socket } = useContext(SocketContext); // Get the socket from context + const reconnectTimeout = useRef(null); // To store the reconnect timeout + const disconnectTime = useRef(null); // To track disconnection time + const acceptableReconnectTime = 2000; // 2 seconds threshold const { treatments: { Websocket_Production } @@ -126,19 +129,44 @@ function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionTyp } }; - const handleReconnect = () => { - //If we were disconnected from the board, we missed stuff. We need to refresh it entirely. - if (refetch) refetch(); + const handleDisconnect = () => { + // Capture the disconnection time + disconnectTime.current = Date.now(); }; - // Listen for 'job-changed' events + + const handleReconnect = () => { + const reconnectTime = Date.now(); + const disconnectionDuration = reconnectTime - disconnectTime.current; + + // Only refetch if disconnection was longer than the acceptable reconnect time + if (disconnectionDuration >= acceptableReconnectTime) { + if (!reconnectTimeout.current) { + reconnectTimeout.current = setTimeout(() => { + const randomDelay = Math.floor(Math.random() * (30000 - 10000 + 1)) + 10000; // Random delay between 10 and 30 seconds + setTimeout(() => { + if (refetch) refetch().catch((err) => console.error(`Issue `)); + reconnectTimeout.current = null; // Clear the timeout reference after refetch + }, randomDelay); + }, acceptableReconnectTime); + } + } + }; + + // Listen for 'job-changed', 'disconnect', and 'connect' events socket.on("production-job-updated", handleJobUpdates); - socket.on("reconnect", handleReconnect); + socket.on("disconnect", handleDisconnect); + socket.on("connect", handleReconnect); + // Clean up on unmount or when dependencies change return () => { socket.off("production-job-updated", handleJobUpdates); - socket.off("reconnect", handleReconnect); + socket.off("disconnect", handleDisconnect); + socket.off("connect", handleReconnect); + if (reconnectTimeout.current) { + clearTimeout(reconnectTimeout.current); + } }; - }, [subscriptionEnabled, socket, bodyshop, data, client, refetch]); + }, [subscriptionEnabled, socket, bodyshop, client, refetch]); const filteredAssociationSettings = useMemo(() => { return associationSettings?.associations[0] || null; diff --git a/client/src/components/production-list-table/production-list-table.container.jsx b/client/src/components/production-list-table/production-list-table.container.jsx index fedcf3ff5..aa0ccdd85 100644 --- a/client/src/components/production-list-table/production-list-table.container.jsx +++ b/client/src/components/production-list-table/production-list-table.container.jsx @@ -1,5 +1,5 @@ import { useApolloClient, useQuery, useSubscription } from "@apollo/client"; -import React, { useContext, useEffect, useState } from "react"; +import React, { useContext, useEffect, useState, useRef } from "react"; import { QUERY_EXACT_JOB_IN_PRODUCTION, QUERY_EXACT_JOBS_IN_PRODUCTION, @@ -16,6 +16,10 @@ export default function ProductionListTableContainer({ bodyshop, subscriptionTyp const client = useApolloClient(); const { socket } = useContext(SocketContext); const [joblist, setJoblist] = useState([]); + const reconnectTimeout = useRef(null); // To store the reconnect timeout + const disconnectTime = useRef(null); // To store the time of disconnection + + const acceptableReconnectTime = 2000; // 2 seconds threshold // Get Split treatment const { @@ -128,18 +132,47 @@ export default function ProductionListTableContainer({ bodyshop, subscriptionTyp } } }; - const handleReconnect = () => { - //If we were disconnected from the board, we missed stuff. We need to refresh it entirely. - if (refetch) refetch(); + + const handleDisconnect = () => { + // Capture the time when the disconnection happens + disconnectTime.current = Date.now(); }; - // Listen for 'production-job-updated' events + + const handleReconnect = () => { + // Calculate how long the disconnection lasted + const reconnectTime = Date.now(); + const disconnectionDuration = reconnectTime - disconnectTime.current; + + // If disconnection lasted less than acceptable reconnect time, do nothing + if (disconnectionDuration < acceptableReconnectTime) { + return; + } + + // Schedule a refetch with a random delay between 10 and 30 seconds + if (!reconnectTimeout.current) { + reconnectTimeout.current = setTimeout(() => { + const randomDelay = Math.floor(Math.random() * (30000 - 10000 + 1)) + 10000; // Random delay between 10 and 30 seconds + setTimeout(() => { + if (refetch) refetch(); + reconnectTimeout.current = null; // Clear the timeout reference after refetch + }, randomDelay); + }, acceptableReconnectTime); + } + }; + + // Listen for 'production-job-updated', 'disconnect', and 'connect' events socket.on("production-job-updated", handleJobUpdates); - socket.on("reconnect", handleReconnect); + socket.on("disconnect", handleDisconnect); + socket.on("connect", handleReconnect); // Clean up on unmount or when dependencies change return () => { socket.off("production-job-updated", handleJobUpdates); - socket.off("reconnect", handleReconnect); + socket.off("disconnect", handleDisconnect); + socket.off("connect", handleReconnect); + if (reconnectTimeout.current) { + clearTimeout(reconnectTimeout.current); + } }; }, [subscriptionEnabled, socket, bodyshop, client, refetch]); @@ -151,6 +184,7 @@ export default function ProductionListTableContainer({ bodyshop, subscriptionTyp fetchPolicy: "network-only" }); }; + const getUpdatedJobsData = (jobIds) => { client.query({ query: QUERY_EXACT_JOBS_IN_PRODUCTION, diff --git a/server.js b/server.js index f7d05bb6b..0ccfd9ed3 100644 --- a/server.js +++ b/server.js @@ -10,7 +10,7 @@ const { createClient } = require("redis"); const { createAdapter } = require("@socket.io/redis-adapter"); const logger = require("./server/utils/logger"); const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents"); -const { instrument, RedisStore } = require("@socket.io/admin-ui"); +const { instrument } = require("@socket.io/admin-ui"); const { isString, isEmpty } = require("lodash"); const applyRedisHelpers = require("./server/utils/redisHelpers"); @@ -63,13 +63,8 @@ const applyMiddleware = (app) => { app.use(cookieParser()); app.use(bodyParser.json({ limit: "50mb" })); app.use(bodyParser.urlencoded({ limit: "50mb", extended: true })); - app.use( - cors({ - origin: SOCKETIO_CORS_ORIGIN, - credentials: true, - exposedHeaders: ["set-cookie"] - }) - ); + app.use(cors({ credentials: true, exposedHeaders: ["set-cookie"] })); + // Helper middleware app.use((req, res, next) => { req.logger = logger; @@ -192,7 +187,7 @@ const main = async () => { const server = http.createServer(app); const { pubClient, ioRedis } = await applySocketIO(server, app); - const api = applyRedisHelpers(pubClient, app); + const api = applyRedisHelpers(pubClient, app, logger); const ioHelpers = applyIOHelpers(app, api, ioRedis, logger); // Legacy Socket Events @@ -211,11 +206,11 @@ const main = async () => { }; // Start server -try { - main(); -} catch (error) { +main().catch((error) => { logger.log(`Main-API-Error: Something was not caught in the application.`, "error", "api", null, { error: error.message, errorjson: JSON.stringify(error) }); -} + // Note: If we want the app to crash on all uncaught async operations, we would + // need to put a `process.exit(1);` here +}); diff --git a/server/utils/ioHelpers.js b/server/utils/ioHelpers.js index 7ef9098d9..2a68925f9 100644 --- a/server/utils/ioHelpers.js +++ b/server/utils/ioHelpers.js @@ -1,5 +1,5 @@ const applyIOHelpers = (app, api, io, logger) => { - const getBodyshopRoom = (bodyshopID) => `broadcast-room-${bodyshopID}`; + const getBodyshopRoom = (bodyshopID) => `bodyshop-broadcast-room:${bodyshopID}`; const ioHelpersAPI = { getBodyshopRoom diff --git a/server/utils/redisHelpers.js b/server/utils/redisHelpers.js index df2c10303..99801000b 100644 --- a/server/utils/redisHelpers.js +++ b/server/utils/redisHelpers.js @@ -1,48 +1,71 @@ +const logger = require("./logger"); /** * Apply Redis helper functions * @param pubClient * @param app */ -const applyRedisHelpers = (pubClient, app) => { +const applyRedisHelpers = (pubClient, app, logger) => { // Store session data in Redis const setSessionData = async (socketId, key, value) => { - await pubClient.hSet(`socket:${socketId}`, key, JSON.stringify(value)); // Use Redis pubClient + try { + await pubClient.hSet(`socket:${socketId}`, key, JSON.stringify(value)); // Use Redis pubClient + } catch (error) { + logger.log(`Error Setting Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); + } }; // Retrieve session data from Redis const getSessionData = async (socketId, key) => { - const data = await pubClient.hGet(`socket:${socketId}`, key); - return data ? JSON.parse(data) : null; + try { + const data = await pubClient.hGet(`socket:${socketId}`, key); + return data ? JSON.parse(data) : null; + } catch (error) { + logger.log(`Error Getting Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); + } }; // Clear session data from Redis const clearSessionData = async (socketId) => { - await pubClient.del(`socket:${socketId}`); + try { + await pubClient.del(`socket:${socketId}`); + } catch (error) { + logger.log(`Error Clearing Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); + } }; // Store multiple session data in Redis const setMultipleSessionData = async (socketId, keyValues) => { - // keyValues is expected to be an object { key1: value1, key2: value2, ... } - const entries = Object.entries(keyValues).map(([key, value]) => [key, JSON.stringify(value)]); - await pubClient.hSet(`socket:${socketId}`, ...entries.flat()); + try { + // keyValues is expected to be an object { key1: value1, key2: value2, ... } + const entries = Object.entries(keyValues).map(([key, value]) => [key, JSON.stringify(value)]); + await pubClient.hSet(`socket:${socketId}`, ...entries.flat()); + } catch (error) { + logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); + } }; // Retrieve multiple session data from Redis const getMultipleSessionData = async (socketId, keys) => { - const data = await pubClient.hmGet(`socket:${socketId}`, keys); - // Redis returns an object with null values for missing keys, so we parse the non-null ones - return Object.fromEntries(keys.map((key, index) => [key, data[index] ? JSON.parse(data[index]) : null])); + try { + const data = await pubClient.hmGet(`socket:${socketId}`, keys); + // Redis returns an object with null values for missing keys, so we parse the non-null ones + return Object.fromEntries(keys.map((key, index) => [key, data[index] ? JSON.parse(data[index]) : null])); + } catch (error) { + logger.log(`Error Getting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); + } }; const setMultipleFromArraySessionData = async (socketId, keyValueArray) => { - // Use Redis multi/pipeline to batch the commands - const multi = pubClient.multi(); - - keyValueArray.forEach(([key, value]) => { - multi.hSet(`socket:${socketId}`, key, JSON.stringify(value)); - }); - - await multi.exec(); // Execute all queued commands + try { + // Use Redis multi/pipeline to batch the commands + const multi = pubClient.multi(); + keyValueArray.forEach(([key, value]) => { + multi.hSet(`socket:${socketId}`, key, JSON.stringify(value)); + }); + await multi.exec(); // Execute all queued commands + } catch (error) { + logger.log(`Error Setting Multiple Session Data for socket ${socketId}: ${error}`, "ERROR", "redis"); + } }; // Helper function to add an item to the end of the Redis list @@ -50,7 +73,7 @@ const applyRedisHelpers = (pubClient, app) => { try { await pubClient.rPush(`socket:${socketId}:${key}`, JSON.stringify(newItem)); } catch (error) { - console.error(`Error adding item to the end of the list for socket ${socketId}:`, error); + logger.log(`Error adding item to the end of the list for socket ${socketId}: ${error}`, "ERROR", "redis"); } }; @@ -59,7 +82,7 @@ const applyRedisHelpers = (pubClient, app) => { try { await pubClient.lPush(`socket:${socketId}:${key}`, JSON.stringify(newItem)); } catch (error) { - console.error(`Error adding item to the beginning of the list for socket ${socketId}:`, error); + logger.log(`Error adding item to the beginning of the list for socket ${socketId}: ${error}`, "ERROR", "redis"); } }; @@ -68,33 +91,33 @@ const applyRedisHelpers = (pubClient, app) => { try { await pubClient.del(`socket:${socketId}:${key}`); } catch (error) { - console.error(`Error clearing list for socket ${socketId}:`, error); + logger.log(`Error clearing list for socket ${socketId}: ${error}`, "ERROR", "redis"); } }; // Add methods to manage room users - const addUserToRoom = async (bodyshopUUID, user) => { + const addUserToRoom = async (room, user) => { try { - await pubClient.sAdd(`bodyshopRoom:${bodyshopUUID}`, JSON.stringify(user)); - } catch (err) { - console.error(`Error adding user to room: ${bodyshopUUID}`); + await pubClient.sAdd(room, JSON.stringify(user)); + } catch (error) { + logger.log(`Error adding user to room ${room}: ${error}`, "ERROR", "redis"); } }; - const removeUserFromRoom = async (bodyshopUUID, user) => { + const removeUserFromRoom = async (room, user) => { try { - await pubClient.sRem(`bodyshopRoom:${bodyshopUUID}`, JSON.stringify(user)); - } catch (err) { - console.error(`Error remove user from room: ${bodyshopUUID}`); + await pubClient.sRem(room, JSON.stringify(user)); + } catch (error) { + logger.log(`Error removing user to room ${room}: ${error}`, "ERROR", "redis"); } }; - const getUsersInRoom = async (bodyshopUUID) => { + const getUsersInRoom = async (room) => { try { - const users = await pubClient.sMembers(`bodyshopRoom:${bodyshopUUID}`); + const users = await pubClient.sMembers(room); return users.map((user) => JSON.parse(user)); - } catch (err) { - console.error(`Error getUsersInRoom: ${bodyshopUUID}`); + } catch (error) { + logger.log(`Error getting users in room ${room}: ${error}`, "ERROR", "redis"); } }; diff --git a/server/web-sockets/redisSocketEvents.js b/server/web-sockets/redisSocketEvents.js index b090ddc3d..5cda88569 100644 --- a/server/web-sockets/redisSocketEvents.js +++ b/server/web-sockets/redisSocketEvents.js @@ -30,52 +30,67 @@ const redisSocketEvents = (io, { addUserToRoom, getUsersInRoom, removeUserFromRo // Room management and broadcasting events function registerRoomAndBroadcastEvents(socket) { socket.on("join-bodyshop-room", async (bodyshopUUID) => { - const room = getBodyshopRoom(bodyshopUUID); - socket.join(room); - await addUserToRoom(room, { uid: socket.user.uid, email: socket.user.email }); - createLogEvent(socket, "DEBUG", `Client joined bodyshop room: ${bodyshopUUID}`); + try { + const room = getBodyshopRoom(bodyshopUUID); + socket.join(room); + await addUserToRoom(room, { uid: socket.user.uid, email: socket.user.email, socket: socket.id }); + createLogEvent(socket, "DEBUG", `Client joined bodyshop room: ${room}`); - // Notify all users in the room about the updated user list - const usersInRoom = await getUsersInRoom(bodyshopUUID); - io.to(room).emit("room-users-updated", usersInRoom); + // Notify all users in the room about the updated user list + const usersInRoom = await getUsersInRoom(room); + io.to(room).emit("room-users-updated", usersInRoom); + } catch (error) { + createLogEvent(socket, "ERROR", `Error joining room: ${error}`); + } }); socket.on("leave-bodyshop-room", async (bodyshopUUID) => { - const room = getBodyshopRoom(bodyshopUUID); - socket.leave(room); - createLogEvent(socket, "DEBUG", `Client left bodyshop room: ${room}`); + try { + const room = getBodyshopRoom(bodyshopUUID); + socket.leave(room); + createLogEvent(socket, "DEBUG", `Client left bodyshop room: ${room}`); + } catch (error) { + createLogEvent(socket, "ERROR", `Error joining room: ${error}`); + } }); socket.on("get-room-users", async (bodyshopUUID, callback) => { - const usersInRoom = await getUsersInRoom(getBodyshopRoom(bodyshopUUID)); - callback(usersInRoom); + try { + const usersInRoom = await getUsersInRoom(getBodyshopRoom(bodyshopUUID)); + callback(usersInRoom); + } catch (error) { + createLogEvent(socket, "ERROR", `Error getting room: ${error}`); + } }); socket.on("broadcast-to-bodyshop", async (bodyshopUUID, message) => { - const room = getBodyshopRoom(bodyshopUUID); - io.to(room).emit("bodyshop-message", message); - createLogEvent(socket, "INFO", `Broadcast message to bodyshop ${room}`); + try { + const room = getBodyshopRoom(bodyshopUUID); + io.to(room).emit("bodyshop-message", message); + createLogEvent(socket, "INFO", `Broadcast message to bodyshop ${room}`); + } catch (error) { + createLogEvent(socket, "ERROR", `Error getting room: ${error}`); + } }); socket.on("disconnect", async () => { - createLogEvent(socket, "DEBUG", `User disconnected.`); + try { + createLogEvent(socket, "DEBUG", `User disconnected.`); - // Get all rooms the socket is part of - const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id); - - for (const bodyshopRoom of rooms) { - await removeUserFromRoom(bodyshopRoom, { uid: socket.user.uid, email: socket.user.email }); - - // Notify all users in the room about the updated user list - const usersInRoom = await getUsersInRoom(bodyshopRoom); - io.to(bodyshopRoom).emit("room-users-updated", usersInRoom); + // Get all rooms the socket is part of + const rooms = Array.from(socket.rooms).filter((room) => room !== socket.id); + for (const room of rooms) { + await removeUserFromRoom(room, { uid: socket.user.uid, email: socket.user.email, socket: socket.id }); + } + } catch (error) { + createLogEvent(socket, "ERROR", `Error getting room: ${error}`); } }); } // Register all socket events for a given socket connection function registerSocketEvents(socket) { - createLogEvent(socket, "DEBUG", `Connected and Authenticated.`); + createLogEvent(socket, "DEBUG", `Registering RedisIO Socket Events.`); // Register room and broadcasting events registerRoomAndBroadcastEvents(socket);