mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-25 10:10:08 +02:00
[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:
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
services/web/app/views/project/editor/socket_diagnostics.pug
Normal file
20
services/web/app/views/project/editor/socket_diagnostics.pug
Normal 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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
services/web/frontend/js/pages/socket-diagnostics.tsx
Normal file
10
services/web/frontend/js/pages/socket-diagnostics.tsx
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user