[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
This commit is contained in:
Antoine Clausse
2025-01-17 09:06:55 +01:00
committed by Copybot
parent 170f5a72dd
commit ac61b4c407
9 changed files with 564 additions and 26 deletions

View File

@@ -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,
})
})
})
}
})
},
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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,
}
}

View File

@@ -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)
}