From ac61b4c4070bd2aa8c23eb2a43df55cafd4e53ae Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Fri, 17 Jan 2025 09:06:55 +0100 Subject: [PATCH] [real-time, web] Create a UI to test socket connection (#22907) * Create a UI to test socket connection * Add Clock Delta to the measurements * Add colors to DiagnosticItem * Update icon * Add more info to the diagnostics screen * Add logs in backend on debug messages, disconnections and connection * Add last received ping info * Reorder DiagnosticItems * Remove "warning" text color (too light) * Replace Phosphor icons by Material Icons GitOrigin-RevId: 6a015b4928cd19849ff287cf254f671840ed44af --- services/real-time/app/js/Router.js | 93 +++++--- .../SocketDiagnostics/SocketDiagnostics.mjs | 11 + services/web/app/src/router.mjs | 3 + .../project/editor/socket_diagnostics.pug | 20 ++ .../components/diagnostic-component.tsx | 71 +++++++ .../components/socket-diagnostics.tsx | 201 ++++++++++++++++++ .../socket-diagnostics/components/types.ts | 29 +++ .../components/use-socket-manager.ts | 152 +++++++++++++ .../frontend/js/pages/socket-diagnostics.tsx | 10 + 9 files changed, 564 insertions(+), 26 deletions(-) create mode 100644 services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs create mode 100644 services/web/app/views/project/editor/socket_diagnostics.pug create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/types.ts create mode 100644 services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts create mode 100644 services/web/frontend/js/pages/socket-diagnostics.tsx diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js index 8aaad2a164..37845015b0 100644 --- a/services/real-time/app/js/Router.js +++ b/services/real-time/app/js/Router.js @@ -169,20 +169,27 @@ module.exports = Router = { } return } - + const isDebugging = !!client.handshake?.query?.debugging const projectId = client.handshake?.query?.projectId - try { - Joi.assert(projectId, JOI_OBJECT_ID) - } catch (error) { - metrics.inc('socket-io.connection', 1, { - status: client.transport, - method: projectId ? 'bad-project-id' : 'missing-project-id', - }) - client.emit('connectionRejected', { - message: 'missing/bad ?projectId=... query flag on handshake', - }) - client.disconnect() - return + + if (isDebugging) { + client.connectedAt = Date.now() + } + + if (!isDebugging) { + try { + Joi.assert(projectId, JOI_OBJECT_ID) + } catch (error) { + metrics.inc('socket-io.connection', 1, { + status: client.transport, + method: projectId ? 'bad-project-id' : 'missing-project-id', + }) + client.emit('connectionRejected', { + message: 'missing/bad ?projectId=... query flag on handshake', + }) + client.disconnect() + return + } } // The client.id is security sensitive. Generate a publicId for sending to other clients. @@ -198,7 +205,10 @@ module.exports = Router = { }) metrics.gauge('socket-io.clients', io.sockets.clients().length) - logger.debug({ session, clientId: client.id }, 'client connected') + logger.debug( + { session, clientId: client.id, isDebugging }, + 'client connected' + ) let user if (session && session.passport && session.passport.user) { @@ -222,7 +232,30 @@ module.exports = Router = { callback(HOSTNAME) }) } + client.on('debug', (data, callback) => { + if (typeof callback !== 'function') { + return Router._handleInvalidArguments(client, 'debug', arguments) + } + logger.debug({ clientId: client.id }, 'received debug message') + + const response = { + serverTime: Date.now(), + data, + client: { + publicId: client.publicId, + remoteIp: client.remoteIp, + userAgent: client.userAgent, + connected: !client.disconnected, + connectedAt: client.connectedAt, + }, + server: { + hostname: settings.exposeHostname ? HOSTNAME : undefined, + }, + } + + callback(response) + }) const joinProject = function (callback) { WebsocketController.joinProject( client, @@ -245,6 +278,12 @@ module.exports = Router = { metrics.inc('socket-io.disconnect', 1, { status: client.transport }) metrics.gauge('socket-io.clients', io.sockets.clients().length) + if (client.isDebugging) { + const duration = Date.now() - client.connectedAt + metrics.timing('socket-io.debugging.duration', duration) + logger.debug({ duration }, 'debug client disconnected') + } + WebsocketController.leaveProject(io, client, function (err) { if (err) { Router._handleError(function () {}, err, client, 'leaveProject') @@ -435,19 +474,21 @@ module.exports = Router = { ) }) - joinProject((err, project, permissionsLevel, protocolVersion) => { - if (err) { - client.emit('connectionRejected', err) - client.disconnect() - return - } - client.emit('joinProjectResponse', { - publicId: client.publicId, - project, - permissionsLevel, - protocolVersion, + if (!isDebugging) { + joinProject((err, project, permissionsLevel, protocolVersion) => { + if (err) { + client.emit('connectionRejected', err) + client.disconnect() + return + } + client.emit('joinProjectResponse', { + publicId: client.publicId, + project, + permissionsLevel, + protocolVersion, + }) }) - }) + } }) }, } diff --git a/services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs b/services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs new file mode 100644 index 0000000000..74672bde4e --- /dev/null +++ b/services/web/app/src/Features/SocketDiagnostics/SocketDiagnostics.mjs @@ -0,0 +1,11 @@ +import { expressify } from '@overleaf/promise-utils' + +const index = async (req, res) => { + res.render('project/editor/socket_diagnostics') +} + +const SocketDiagnostics = { + index: expressify(index), +} + +export default SocketDiagnostics diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 44175b455a..125fdfd385 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -66,6 +66,7 @@ import logger from '@overleaf/logger' import _ from 'lodash' import { plainTextResponse } from './infrastructure/Response.js' import PublicAccessLevels from './Features/Authorization/PublicAccessLevels.js' +import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs' const ClsiCookieManager = ClsiCookieManagerFactory( Settings.apis.clsi != null ? Settings.apis.clsi.backendGroupName : undefined ) @@ -231,6 +232,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { webRouter.get('/account-suspended', UserPagesController.accountSuspended) + webRouter.get('/socket-diagnostics', SocketDiagnostics.index) + if (Settings.enableLegacyLogin) { AuthenticationController.addEndpointToLoginWhitelist('/login/legacy') webRouter.get('/login/legacy', UserPagesController.loginPage) diff --git a/services/web/app/views/project/editor/socket_diagnostics.pug b/services/web/app/views/project/editor/socket_diagnostics.pug new file mode 100644 index 0000000000..7093bc8343 --- /dev/null +++ b/services/web/app/views/project/editor/socket_diagnostics.pug @@ -0,0 +1,20 @@ +extends ../../layout-marketing + +block vars + - var suppressNavbar = true + - var suppressFooter = true + - var suppressGoogleAnalytics = true + - bootstrap5PageStatus = 'enabled' + - isWebsiteRedesign = 'true' + +block entrypointVar + - entrypoint = 'pages/socket-diagnostics' + +block append meta + +block content + main.content.content-alt#main-content + #socket-diagnostics + +block prepend foot-scripts + script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js', defer=deferScripts) diff --git a/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx b/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx new file mode 100644 index 0000000000..7adddc5329 --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/diagnostic-component.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import classnames from 'classnames' +import type { ConnectionStatus } from './types' +import { Badge, Button } from 'react-bootstrap-5' +import OLNotification from '@/features/ui/components/ol/ol-notification' +import MaterialIcon from '@/shared/components/material-icon' + +const variants = { + connected: 'success', + connecting: 'warning', + disconnected: 'danger', +} + +export const ConnectionBadge = ({ state }: { state: ConnectionStatus }) => ( + + {state} + +) + +export const DiagnosticItem = ({ + icon, + label, + value, + type, +}: { + icon: string + label: string + value: React.ReactNode + type?: 'success' | 'danger' +}) => ( +
+
+ + {label} +
+
{value}
+
+) + +export function ErrorAlert({ message }: { message: string }) { + return +} + +export function ActionButton({ + label, + icon, + onClick, + disabled, +}: { + label: string + icon: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} diff --git a/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx b/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx new file mode 100644 index 0000000000..03d16427cf --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/socket-diagnostics.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import type { ConnectionStatus } from './types' +import { useSocketManager } from './use-socket-manager' +import { + ActionButton, + ConnectionBadge, + DiagnosticItem, + ErrorAlert, +} from './diagnostic-component' +import { Container } from 'react-bootstrap-5' +import MaterialIcon from '@/shared/components/material-icon' + +type NetworkInformation = { + downlink: number + effectiveType: string + rtt: number + saveData: boolean + type: string +} + +const NavigatorInfo = () => { + if (!('connection' in navigator)) { + return
Network Information API not supported
+ } + + const connection = navigator.connection as NetworkInformation + return ( + <> +
Downlink: {connection.downlink} Mbps
+
Effective Type: {connection.effectiveType}
+
Round Trip Time: {connection.rtt} ms
+
Save Data: {connection.saveData ? 'Enabled' : 'Disabled'}
+
Platform: {navigator.platform}
+ {/* @ts-ignore */} +
Device Memory: {navigator.deviceMemory}
+
Hardware Concurrency: {navigator.hardwareConcurrency}
+ + ) +} + +export const SocketDiagnostics = () => { + const { socketState, debugInfo, disconnectSocket, forceReconnect, socket } = + useSocketManager() + + const getConnectionState = (): ConnectionStatus => { + if (socketState.connected) return 'connected' + if (socketState.connecting) return 'connecting' + return 'disconnected' + } + + const lastReceivedS = debugInfo.lastReceived + ? Math.round((Date.now() - debugInfo.lastReceived) / 1000) + : null + + return ( + +

Socket Diagnostics

+ + +
+ + +
+ + {socketState.lastError && } + +
+

+ Connection Stats +

+
+ + {debugInfo.received} / {debugInfo.sent} + {lastReceivedS !== null && ( + <> +
+ Last received {lastReceivedS}s ago + + )} + + } + type={ + lastReceivedS !== null + ? lastReceivedS < 4 + ? 'success' + : 'danger' + : undefined + } + /> + + + {debugInfo.latency} ms +
+ Max: {debugInfo.maxLatency} ms + + ) : ( + '-' + ) + } + type={ + debugInfo.latency + ? debugInfo.latency < 150 + ? 'success' + : 'danger' + : undefined + } + /> + + + + + + {new Date(debugInfo.client.connectedAt).toUTCString()} ( + {Math.round( + (Date.now() - debugInfo.client.connectedAt) / 1000 + )} + s) + + ) : ( + '-' + ) + } + /> + + + + + + } + /> +
+
+
+ ) +} diff --git a/services/web/frontend/js/features/socket-diagnostics/components/types.ts b/services/web/frontend/js/features/socket-diagnostics/components/types.ts new file mode 100644 index 0000000000..edfc9bdf44 --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/types.ts @@ -0,0 +1,29 @@ +export interface SocketState { + connected: boolean + connecting: boolean + lastError: string +} + +export interface DebugInfo { + sent: number + received: number + latency: number | null + maxLatency: number | null + clockDelta: number | null + onLine: boolean | null + client: Client | null + lastReceived: number | null +} + +interface Client { + id: string + publicId: string + remoteIp: string + userAgent: string + connected: boolean + readable: boolean + ackPackets: number + connectedAt: number +} + +export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' diff --git a/services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts b/services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts new file mode 100644 index 0000000000..4ff97cc7c1 --- /dev/null +++ b/services/web/frontend/js/features/socket-diagnostics/components/use-socket-manager.ts @@ -0,0 +1,152 @@ +import { useState, useEffect, useCallback } from 'react' +import SocketIoShim from '@/ide/connection/SocketIoShim' +import type { Socket } from '@/features/ide-react/connection/types/socket' +import type { DebugInfo, SocketState } from './types' + +export function useSocketManager() { + const [socket, setSocket] = useState(null) + + const [socketState, setSocketState] = useState({ + connected: false, + connecting: false, + lastError: '', + }) + + const [debugInfo, setDebugInfo] = useState({ + sent: 0, + received: 0, + latency: null, + maxLatency: null, + onLine: null, + clockDelta: null, + client: null, + lastReceived: null, + }) + + const connectSocket = useCallback(() => { + const parsedURL = new URL('/socket.io', window.origin) + + setSocketState(prev => ({ + ...prev, + connecting: true, + lastAttempt: Date.now(), + })) + + const newSocket = SocketIoShim.connect(parsedURL.origin, { + resource: parsedURL.pathname.slice(1), + 'auto connect': false, + 'connect timeout': 30 * 1000, + 'force new connection': true, + query: new URLSearchParams({ debugging: 'true' }).toString(), + reconnect: false, + }) as unknown as Socket + + setSocket(newSocket) + return newSocket + }, []) + + const disconnectSocket = useCallback(() => { + socket?.disconnect() + setSocket(null) + setSocketState(prev => ({ + ...prev, + connected: false, + connecting: false, + lastError: 'Manually disconnected', + })) + }, [socket]) + + const forceReconnect = useCallback(() => { + disconnectSocket() + setTimeout(connectSocket, 1000) + }, [disconnectSocket, connectSocket]) + + useEffect(() => { + connectSocket() + }, [connectSocket]) + + useEffect(() => { + if (!socket) return + + const statsInterval = setInterval(() => { + if (socket.socket.connected) { + setDebugInfo(prev => ({ ...prev, sent: prev.sent + 1 })) + socket.emit('debug', { time: Date.now() }, (info: any) => { + const beforeTime = info.data.time + const now = Date.now() + const latency = now - beforeTime + const clockDelta = (beforeTime + beforeTime) / 2 - info.serverTime + setDebugInfo(prev => ({ + ...prev, + received: prev.received + 1, + latency, + maxLatency: Math.max(prev.maxLatency ?? 0, latency), + clockDelta, + client: info.client, + lastReceived: now, + })) + }) + } + }, 2000) + + socket.on('connect', () => { + setSocketState(prev => ({ + ...prev, + connected: true, + connecting: false, + lastSuccess: Date.now(), + lastError: '', + })) + }) + + socket.on('disconnect', (reason: string) => { + setSocketState(prev => ({ + ...prev, + connected: false, + connecting: false, + lastError: `Disconnected: ${reason}`, + })) + }) + + socket.on('connect_error', (error: Error) => { + setSocketState(prev => ({ + ...prev, + connecting: false, + lastError: `Connection error: ${error?.message || 'Unknown'}`, + })) + }) + + socket.socket.connect() + + return () => { + clearInterval(statsInterval) + socket.disconnect() + } + }, [socket]) + + useEffect(() => { + const updateNetworkInfo = () => { + if ('connection' in navigator) { + setDebugInfo(prev => ({ ...prev, onLine: navigator.onLine })) + } + } + + window.addEventListener('online', updateNetworkInfo) + window.addEventListener('offline', updateNetworkInfo) + updateNetworkInfo() + + return () => { + window.removeEventListener('online', updateNetworkInfo) + window.removeEventListener('offline', updateNetworkInfo) + } + }, []) + + return { + socketState, + debugInfo, + connectSocket, + disconnectSocket, + forceReconnect, + socket, + } +} diff --git a/services/web/frontend/js/pages/socket-diagnostics.tsx b/services/web/frontend/js/pages/socket-diagnostics.tsx new file mode 100644 index 0000000000..982209b378 --- /dev/null +++ b/services/web/frontend/js/pages/socket-diagnostics.tsx @@ -0,0 +1,10 @@ +import '../marketing' + +import ReactDOM from 'react-dom' +import { SocketDiagnostics } from '@/features/socket-diagnostics/components/socket-diagnostics' + +const socketDiagnosticsContainer = document.getElementById('socket-diagnostics') + +if (socketDiagnosticsContainer) { + ReactDOM.render(, socketDiagnosticsContainer) +}