From 72091e9eaefd9f9ee1f6dcbcadb9b17164454400 Mon Sep 17 00:00:00 2001 From: Dave Richer Date: Mon, 14 Jul 2025 18:42:27 -0400 Subject: [PATCH] feature/IO-3303-Socket-IO-Optimization-Auto-Add-Watchers-Gate - SocketIO Optimization / Auto Add Watchers Gate --- ...nt-imgproxy-gallery.download.component.jsx | 1 - .../src/contexts/SocketIO/socketProvider.jsx | 67 +++++- server/job/job-costing.js | 10 +- server/job/job-lifecycle.js | 195 ++++++++++-------- server/notifications/autoAddWatchers.js | 7 +- 5 files changed, 182 insertions(+), 98 deletions(-) diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx index 65140d6b0..50716982b 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx @@ -3,7 +3,6 @@ import axios from "axios"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { logImEXEvent } from "../../firebase/firebase.utils"; -import cleanAxios from "../../utils/CleanAxios"; import formatBytes from "../../utils/formatbytes"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; diff --git a/client/src/contexts/SocketIO/socketProvider.jsx b/client/src/contexts/SocketIO/socketProvider.jsx index efc77ea7c..86e6ed763 100644 --- a/client/src/contexts/SocketIO/socketProvider.jsx +++ b/client/src/contexts/SocketIO/socketProvider.jsx @@ -16,7 +16,7 @@ import { import { useMutation } from "@apollo/client"; import { useTranslation } from "react-i18next"; import { useSplitTreatments } from "@splitsoftware/splitio-react"; -import { SocketContext, INITIAL_NOTIFICATIONS } from "./useSocket.js"; +import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js"; /** * Socket Provider - Scenario Notifications / Web Socket related items @@ -31,6 +31,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { const socketRef = useRef(null); const [clientId, setClientId] = useState(null); const [isConnected, setIsConnected] = useState(false); + const [socketInitialized, setSocketInitialized] = useState(false); const notification = useNotification(); const userAssociationId = bodyshop?.associations?.[0]?.id; const { t } = useTranslation(); @@ -146,6 +147,13 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err) }); + const checkAndReconnect = () => { + if (socketRef.current && !socketRef.current.connected) { + console.log("Attempting manual reconnect due to event trigger"); + socketRef.current.connect(); + } + }; + useEffect(() => { const initializeSocket = async (token) => { if (!bodyshop || !bodyshop.id || socketRef.current) return; @@ -157,10 +165,14 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { auth: { token, bodyshopId: bodyshop.id }, reconnectionAttempts: Infinity, reconnectionDelay: 2000, - reconnectionDelayMax: 10000 + reconnectionDelayMax: 60000, + randomizationFactor: 0.5, + transports: ["websocket", "polling"], // Add this to prefer WebSocket with polling fallback + rememberUpgrade: true }); socketRef.current = socketInstance; + setSocketInitialized(true); const handleBodyshopMessage = (message) => { if (!message || !message.type) return; @@ -469,6 +481,57 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => { t ]); + useEffect(() => { + if (!socketInitialized) return; + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + checkAndReconnect(); + } + }; + + const onFocus = () => { + checkAndReconnect(); + }; + + const onOnline = () => { + checkAndReconnect(); + }; + + const onPageShow = (event) => { + if (event.persisted) { + checkAndReconnect(); + } + }; + + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("focus", onFocus); + window.addEventListener("online", onOnline); + window.addEventListener("pageshow", onPageShow); + + // Sleep/wake detection using timer + let lastTime = Date.now(); + const intervalMs = 1000; // Check every second + const thresholdMs = 2000; // If more than 2 seconds elapsed, assume sleep/wake + + const sleepCheckInterval = setInterval(() => { + const currentTime = Date.now(); + if (currentTime > lastTime + intervalMs + thresholdMs) { + console.log("Detected potential wake from sleep/hibernate"); + checkAndReconnect(); + } + lastTime = currentTime; + }, intervalMs); + + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("focus", onFocus); + window.removeEventListener("online", onOnline); + window.removeEventListener("pageshow", onPageShow); + clearInterval(sleepCheckInterval); + }; + }, [socketInitialized]); + return ( { // Grab the jobids and statuses from the request body const { jobids, statuses } = req.body; + const { logger } = req; if (!jobids) { return res.status(400).json({ @@ -16,102 +17,118 @@ const jobLifecycle = async (req, res) => { } const jobIDs = _.isArray(jobids) ? jobids : [jobids]; - const client = req.userGraphQLClient; - const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs }); - const transitions = resp.transitions; + logger.log("job-lifecycle-start", "DEBUG", req?.user?.email, null, { + jobids: jobIDs + }); + + try { + const client = req.userGraphQLClient; + const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs }); + + const transitions = resp.transitions; + + if (!transitions) { + return res.status(200).json({ + jobIDs, + transitions: [] + }); + } + + const transitionsByJobId = _.groupBy(resp.transitions, "jobid"); + + const groupedTransitions = {}; + const allDurations = []; + + for (let jobId in transitionsByJobId) { + let lifecycle = transitionsByJobId[jobId].map((transition) => { + transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A"; + transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A"; + + if (transition.duration) { + transition.duration_seconds = Math.round(transition.duration / 1000); + transition.duration_minutes = Math.round(transition.duration_seconds / 60); + let duration = moment.duration(transition.duration); + transition.duration_readable = durationToHumanReadable(duration); + } else { + transition.duration_seconds = 0; + transition.duration_minutes = 0; + transition.duration_readable = "N/A"; + } + return transition; + }); + + const durations = calculateStatusDuration(lifecycle, statuses); + + groupedTransitions[jobId] = { + lifecycle, + durations + }; + + if (durations?.summations) { + allDurations.push(durations.summations); + } + } + + const finalSummations = []; + const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status"); + + const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => { + acc[status] = flatGroupedAllDurations[status].length; + return acc; + }, {}); + // Calculate total value of all statuses + const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => { + return total + statusArr.reduce((acc, curr) => acc + curr.value, 0); + }, 0); + + Object.keys(flatGroupedAllDurations).forEach((status) => { + const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0); + const humanReadable = durationToHumanReadable(moment.duration(value)); + const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0; + const color = getLifecycleStatusColor(status); + const roundedPercentage = `${Math.round(percentage)}%`; + const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0; + const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue)); + finalSummations.push({ + status, + value, + humanReadable, + percentage, + color, + roundedPercentage, + averageValue, + averageHumanReadable + }); + }); - if (!transitions) { return res.status(200).json({ jobIDs, - transitions: [] - }); - } - - const transitionsByJobId = _.groupBy(resp.transitions, "jobid"); - - const groupedTransitions = {}; - const allDurations = []; - - for (let jobId in transitionsByJobId) { - let lifecycle = transitionsByJobId[jobId].map((transition) => { - transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A"; - transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A"; - - if (transition.duration) { - transition.duration_seconds = Math.round(transition.duration / 1000); - transition.duration_minutes = Math.round(transition.duration_seconds / 60); - let duration = moment.duration(transition.duration); - transition.duration_readable = durationToHumanReadable(duration); - } else { - transition.duration_seconds = 0; - transition.duration_minutes = 0; - transition.duration_readable = "N/A"; + transition: groupedTransitions, + durations: { + jobs: jobIDs.length, + summations: finalSummations, + totalStatuses: finalSummations.length, + total: finalTotal, + statusCounts: finalStatusCounts, + humanReadable: durationToHumanReadable(moment.duration(finalTotal)), + averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0, + averageHumanReadable: + _.size(jobIDs) > 0 + ? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length)) + : durationToHumanReadable(moment.duration(0)) } - return transition; }); - - const durations = calculateStatusDuration(lifecycle, statuses); - - groupedTransitions[jobId] = { - lifecycle, - durations - }; - - if (durations?.summations) { - allDurations.push(durations.summations); - } + } catch (error) { + logger.log("job-lifecycle-error", "ERROR", req?.user?.email, null, { + jobids: jobIDs, + statuses: statuses ? JSON.stringify(statuses) : "N/A", + error: error.message + }); + return res.status(500).json({ + error: "Internal server error" + }); } - - const finalSummations = []; - const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status"); - - const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => { - acc[status] = flatGroupedAllDurations[status].length; - return acc; - }, {}); - // Calculate total value of all statuses - const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => { - return total + statusArr.reduce((acc, curr) => acc + curr.value, 0); - }, 0); - - Object.keys(flatGroupedAllDurations).forEach((status) => { - const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0); - const humanReadable = durationToHumanReadable(moment.duration(value)); - const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0; - const color = getLifecycleStatusColor(status); - const roundedPercentage = `${Math.round(percentage)}%`; - const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0; - const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue)); - finalSummations.push({ - status, - value, - humanReadable, - percentage, - color, - roundedPercentage, - averageValue, - averageHumanReadable - }); - }); - - return res.status(200).json({ - jobIDs, - transition: groupedTransitions, - durations: { - jobs: jobIDs.length, - summations: finalSummations, - totalStatuses: finalSummations.length, - total: finalTotal, - statusCounts: finalStatusCounts, - humanReadable: durationToHumanReadable(moment.duration(finalTotal)), - averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0, - averageHumanReadable: - _.size(jobIDs) > 0 - ? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length)) - : durationToHumanReadable(moment.duration(0)) - } - }); }; module.exports = jobLifecycle; diff --git a/server/notifications/autoAddWatchers.js b/server/notifications/autoAddWatchers.js index e2579cffd..704ca2550 100644 --- a/server/notifications/autoAddWatchers.js +++ b/server/notifications/autoAddWatchers.js @@ -50,7 +50,12 @@ const autoAddWatchers = async (req) => { try { // Fetch bodyshop data from Redis const bodyshopData = await getBodyshopFromRedis(shopId); - const notificationFollowers = bodyshopData?.notification_followers || []; + let notificationFollowers = bodyshopData?.notification_followers; + + // Bail if notification_followers is missing or not an array + if (!notificationFollowers || !Array.isArray(notificationFollowers)) { + return; + } // Execute queries in parallel const [notificationData, existingWatchersData] = await Promise.all([