Przeglądaj źródła

[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
Antoine Clausse 1 rok temu
rodzic
commit
7bef003c56

+ 67 - 26
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,
+          })
         })
-      })
+      }
     })
   },
 }

+ 11 - 0
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

+ 3 - 0
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)

+ 20 - 0
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)

+ 71 - 0
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 }) => (
+  <Badge className="px-2 py-1" bg={variants[state]}>
+    {state}
+  </Badge>
+)
+
+export const DiagnosticItem = ({
+  icon,
+  label,
+  value,
+  type,
+}: {
+  icon: string
+  label: string
+  value: React.ReactNode
+  type?: 'success' | 'danger'
+}) => (
+  <div
+    className={classnames(
+      'py-2',
+      type === 'success' && 'text-success',
+      type === 'danger' && 'text-danger'
+    )}
+  >
+    <div className="d-flex gap-2 fw-bold align-items-center">
+      <MaterialIcon type={icon} />
+      <span>{label}</span>
+    </div>
+    <div>{value}</div>
+  </div>
+)
+
+export function ErrorAlert({ message }: { message: string }) {
+  return <OLNotification type="error" content={message} className="mt-3" />
+}
+
+export function ActionButton({
+  label,
+  icon,
+  onClick,
+  disabled,
+}: {
+  label: string
+  icon: string
+  onClick: () => void
+  disabled?: boolean
+}) {
+  return (
+    <Button
+      onClick={onClick}
+      disabled={disabled}
+      className="d-flex align-items-center"
+    >
+      <MaterialIcon className="me-2" type={icon} />
+      <span>{label}</span>
+    </Button>
+  )
+}

+ 201 - 0
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 <div>Network Information API not supported</div>
+  }
+
+  const connection = navigator.connection as NetworkInformation
+  return (
+    <>
+      <div>Downlink: {connection.downlink} Mbps</div>
+      <div>Effective Type: {connection.effectiveType}</div>
+      <div>Round Trip Time: {connection.rtt} ms</div>
+      <div>Save Data: {connection.saveData ? 'Enabled' : 'Disabled'}</div>
+      <div>Platform: {navigator.platform}</div>
+      {/* @ts-ignore */}
+      <div>Device Memory: {navigator.deviceMemory}</div>
+      <div>Hardware Concurrency: {navigator.hardwareConcurrency}</div>
+    </>
+  )
+}
+
+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 (
+    <Container>
+      <h1>Socket Diagnostics</h1>
+      <ConnectionBadge state={getConnectionState()} />
+
+      <div className="d-flex gap-2 mt-3">
+        <ActionButton
+          label="Reconnect"
+          icon="refresh"
+          onClick={forceReconnect}
+        />
+        <ActionButton
+          label="Disconnect"
+          icon="close"
+          onClick={disconnectSocket}
+          disabled={!socketState.connected}
+        />
+      </div>
+
+      {socketState.lastError && <ErrorAlert message={socketState.lastError} />}
+
+      <div className="card p-4 mt-3">
+        <h3 className="text-lg">
+          <MaterialIcon type="speed" /> Connection Stats
+        </h3>
+        <div className="space-y-2">
+          <DiagnosticItem
+            icon="network_ping"
+            label="Ping Count"
+            value={
+              <>
+                {debugInfo.received} / {debugInfo.sent}
+                {lastReceivedS !== null && (
+                  <>
+                    <br />
+                    Last received {lastReceivedS}s ago
+                  </>
+                )}
+              </>
+            }
+            type={
+              lastReceivedS !== null
+                ? lastReceivedS < 4
+                  ? 'success'
+                  : 'danger'
+                : undefined
+            }
+          />
+
+          <DiagnosticItem
+            icon="schedule"
+            label="Latency"
+            value={
+              debugInfo.latency ? (
+                <>
+                  {debugInfo.latency} ms
+                  <br />
+                  Max: {debugInfo.maxLatency} ms
+                </>
+              ) : (
+                '-'
+              )
+            }
+            type={
+              debugInfo.latency
+                ? debugInfo.latency < 150
+                  ? 'success'
+                  : 'danger'
+                : undefined
+            }
+          />
+          <DiagnosticItem
+            icon="difference"
+            label="Clock Delta"
+            value={
+              debugInfo.clockDelta === null
+                ? '-'
+                : `${Math.round(debugInfo.clockDelta / 1000)}s`
+            }
+            type={
+              debugInfo.clockDelta !== null
+                ? Math.abs(debugInfo.clockDelta) < 1500
+                  ? 'success'
+                  : 'danger'
+                : undefined
+            }
+          />
+          <DiagnosticItem
+            icon="signal_cellular_alt"
+            label="Online"
+            value={debugInfo.onLine?.toString() ?? '-'}
+            type={debugInfo.onLine ? 'success' : 'danger'}
+          />
+
+          <DiagnosticItem
+            icon="schedule"
+            label="Current time"
+            value={new Date().toUTCString()}
+          />
+          <DiagnosticItem
+            icon="hourglass"
+            label="Connection time"
+            value={
+              debugInfo.client?.connectedAt ? (
+                <>
+                  {new Date(debugInfo.client.connectedAt).toUTCString()} (
+                  {Math.round(
+                    (Date.now() - debugInfo.client.connectedAt) / 1000
+                  )}
+                  s)
+                </>
+              ) : (
+                '-'
+              )
+            }
+          />
+          <DiagnosticItem
+            icon="local_shipping"
+            label="Transport"
+            value={socket?.socket.transport?.name ?? '-'}
+          />
+          <DiagnosticItem
+            icon="badge"
+            label="Client Public ID"
+            value={debugInfo.client?.publicId ?? '-'}
+          />
+          <DiagnosticItem
+            icon="pin"
+            label="IP Address"
+            value={debugInfo.client?.remoteIp ?? '-'}
+          />
+          <DiagnosticItem
+            icon="web"
+            label="User agent"
+            value={debugInfo.client?.userAgent ?? '-'}
+          />
+
+          <DiagnosticItem
+            icon="directions_boat"
+            label="Navigator info"
+            value={<NavigatorInfo />}
+          />
+        </div>
+      </div>
+    </Container>
+  )
+}

+ 29 - 0
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'

+ 152 - 0
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<Socket | null>(null)
+
+  const [socketState, setSocketState] = useState<SocketState>({
+    connected: false,
+    connecting: false,
+    lastError: '',
+  })
+
+  const [debugInfo, setDebugInfo] = useState<DebugInfo>({
+    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,
+  }
+}

+ 10 - 0
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(<SocketDiagnostics />, socketDiagnosticsContainer)
+}