first commit
This commit is contained in:
@@ -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,230 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import type { ConnectionStatus } from './types'
|
||||
import { useSocketManager } from './use-socket-manager'
|
||||
import {
|
||||
ActionButton,
|
||||
ConnectionBadge,
|
||||
DiagnosticItem,
|
||||
ErrorAlert,
|
||||
} from './diagnostic-component'
|
||||
import { Col, Container, Row } from 'react-bootstrap-5'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
|
||||
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
|
||||
|
||||
type NetworkInformation = {
|
||||
downlink: number
|
||||
effectiveType: string
|
||||
rtt: number
|
||||
saveData: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
const navigatorInfo = (): string[] => {
|
||||
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}`,
|
||||
]
|
||||
}
|
||||
|
||||
const useCurrentTime = () => {
|
||||
const [, setTime] = React.useState(new Date())
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTime(new Date()), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
}
|
||||
|
||||
type DiagnosticProps = {
|
||||
icon: string
|
||||
label: string
|
||||
text: string[]
|
||||
type?: 'success' | 'danger'
|
||||
}
|
||||
|
||||
export const SocketDiagnostics = () => {
|
||||
const {
|
||||
socketState,
|
||||
debugInfo,
|
||||
disconnectSocket,
|
||||
forceReconnect,
|
||||
socket,
|
||||
autoping,
|
||||
setAutoping,
|
||||
} = useSocketManager()
|
||||
useCurrentTime()
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const getConnectionState = (): ConnectionStatus => {
|
||||
if (socketState.connected) return 'connected'
|
||||
if (socketState.connecting) return 'connecting'
|
||||
return 'disconnected'
|
||||
}
|
||||
|
||||
const lastReceivedS = debugInfo.lastReceived
|
||||
? Math.round((now.getTime() - debugInfo.lastReceived) / 1000)
|
||||
: null
|
||||
|
||||
const isLate =
|
||||
!!debugInfo.unansweredSince &&
|
||||
now.getTime() - debugInfo.unansweredSince >= 3000
|
||||
|
||||
const diagnosticProps: DiagnosticProps[] = [
|
||||
{
|
||||
icon: 'network_ping',
|
||||
label: 'Ping Count',
|
||||
text: [
|
||||
`${debugInfo.received} / ${debugInfo.sent}`,
|
||||
lastReceivedS !== null ? `Last received ${lastReceivedS}s ago` : null,
|
||||
].filter(Boolean) as string[],
|
||||
type: isLate === null ? undefined : isLate ? 'danger' : 'success',
|
||||
},
|
||||
{
|
||||
icon: 'schedule',
|
||||
label: 'Latency',
|
||||
text: [
|
||||
debugInfo.latency
|
||||
? `${debugInfo.latency} ms\nMax: ${debugInfo.maxLatency} ms`
|
||||
: '-',
|
||||
],
|
||||
type: debugInfo.latency && debugInfo.latency < 450 ? 'success' : 'danger',
|
||||
},
|
||||
{
|
||||
icon: 'difference',
|
||||
label: 'Clock Delta',
|
||||
text: [
|
||||
debugInfo.clockDelta === null
|
||||
? '-'
|
||||
: `${Math.round(debugInfo.clockDelta / 1000)}s`,
|
||||
],
|
||||
type:
|
||||
debugInfo.clockDelta !== null && Math.abs(debugInfo.clockDelta) < 1500
|
||||
? 'success'
|
||||
: 'danger',
|
||||
},
|
||||
{
|
||||
icon: 'signal_cellular_alt',
|
||||
label: 'Online',
|
||||
text: [debugInfo.onLine?.toString() ?? '-'],
|
||||
type: debugInfo.onLine ? 'success' : 'danger',
|
||||
},
|
||||
{
|
||||
icon: 'schedule',
|
||||
label: 'Current time',
|
||||
text: [now.toUTCString()],
|
||||
},
|
||||
{
|
||||
icon: 'hourglass',
|
||||
label: 'Connection time',
|
||||
text: [
|
||||
debugInfo.client?.connectedAt
|
||||
? `${new Date(debugInfo.client.connectedAt).toUTCString()} (${Math.round(
|
||||
(Date.now() - debugInfo.client.connectedAt) / 1000
|
||||
)}s)`
|
||||
: '-',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'local_shipping',
|
||||
label: 'Transport',
|
||||
text: [socket?.socket.transport?.name ?? '-'],
|
||||
},
|
||||
{
|
||||
icon: 'badge',
|
||||
label: 'Client Public ID',
|
||||
text: [debugInfo.client?.publicId ?? '-'],
|
||||
},
|
||||
{
|
||||
icon: 'pin',
|
||||
label: 'IP Address',
|
||||
text: [debugInfo.client?.remoteIp ?? '-'],
|
||||
},
|
||||
{
|
||||
icon: 'web',
|
||||
label: 'User agent',
|
||||
text: [debugInfo.client?.userAgent ?? '-'],
|
||||
},
|
||||
{
|
||||
icon: 'directions_boat',
|
||||
label: 'Navigator info',
|
||||
text: navigatorInfo(),
|
||||
},
|
||||
]
|
||||
|
||||
const diagnosticItems = diagnosticProps.map(item => (
|
||||
<DiagnosticItem
|
||||
key={item.label}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
value={item.text.map((t, i) => (
|
||||
<div key={i}>{t}</div>
|
||||
))}
|
||||
type={item.type}
|
||||
/>
|
||||
))
|
||||
const cutAtIndex = 7
|
||||
const leftItems = diagnosticItems.slice(0, cutAtIndex)
|
||||
const rightItems = diagnosticItems.slice(cutAtIndex)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h1>Socket Diagnostics</h1>
|
||||
<ConnectionBadge state={getConnectionState()} />
|
||||
<div className="d-flex flex-wrap gap-4 mt-3 align-items-center">
|
||||
<ActionButton
|
||||
label="Reconnect"
|
||||
icon="refresh"
|
||||
onClick={forceReconnect}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Disconnect"
|
||||
icon="close"
|
||||
onClick={disconnectSocket}
|
||||
disabled={!socketState.connected}
|
||||
/>
|
||||
<OLFormCheckbox
|
||||
label="Auto ping"
|
||||
id="autoping"
|
||||
checked={autoping}
|
||||
onChange={e => setAutoping(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{socketState.lastError && <ErrorAlert message={socketState.lastError} />}
|
||||
|
||||
<div className="card p-4 mt-3">
|
||||
<div className="d-flex flex-wrap gap-4 row-gap-1 justify-content-between align-items-center">
|
||||
<h3 className="text-lg">
|
||||
<MaterialIcon type="speed" /> Connection Stats
|
||||
</h3>
|
||||
<div className="ms-auto">
|
||||
<CopyToClipboard
|
||||
content={diagnosticProps
|
||||
.map(item => [`${item.label}:`, ...item.text].join('\n'))
|
||||
.join('\n\n')}
|
||||
tooltipId="copy-debug-info"
|
||||
kind="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Row>
|
||||
<Col md={6}>{leftItems}</Col>
|
||||
<Col md={6}>{rightItems}</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
unansweredSince: number | 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,170 @@
|
||||
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 [autoping, setAutoping] = useState<boolean>(false)
|
||||
|
||||
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,
|
||||
unansweredSince: 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])
|
||||
|
||||
const sendPing = useCallback(() => {
|
||||
if (socket?.socket.connected) {
|
||||
const time = Date.now()
|
||||
setDebugInfo(prev => ({
|
||||
...prev,
|
||||
sent: prev.sent + 1,
|
||||
unansweredSince: prev.unansweredSince ?? time,
|
||||
}))
|
||||
socket.emit('debug', { time }, (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,
|
||||
unansweredSince: null,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}, [socket])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !autoping) return
|
||||
|
||||
const statsInterval = setInterval(sendPing, 2000)
|
||||
|
||||
return () => {
|
||||
clearInterval(statsInterval)
|
||||
}
|
||||
}, [socket, autoping, sendPing])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return
|
||||
|
||||
socket.on('connect', () => {
|
||||
setSocketState(prev => ({
|
||||
...prev,
|
||||
connected: true,
|
||||
connecting: false,
|
||||
lastSuccess: Date.now(),
|
||||
lastError: '',
|
||||
}))
|
||||
sendPing()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [sendPing, socket])
|
||||
|
||||
useEffect(() => {
|
||||
const updateNetworkInfo = () => {
|
||||
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,
|
||||
autoping,
|
||||
setAutoping,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user