diff --git a/package.json b/package.json index 6c88579..1065f91 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@timephy/rnnoise-wasm": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cytoscape": "^3.33.1", "mermaid": "^11.12.3", "ngx-remark": "^0.2.2", "prismjs": "^1.30.0", diff --git a/src/app/app.html b/src/app/app.html index cb1e78c..8a5b372 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -18,3 +18,6 @@ + + + diff --git a/src/app/app.ts b/src/app/app.ts index 4c62122..585e195 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -22,6 +22,7 @@ import { ServersRailComponent } from './features/servers/servers-rail.component' import { TitleBarComponent } from './features/shell/title-bar.component'; import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; +import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { UsersActions } from './store/users/users.actions'; import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; @@ -39,7 +40,8 @@ import { ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent, - SettingsModalComponent + SettingsModalComponent, + DebugConsoleComponent ], templateUrl: './app.html', styleUrl: './app.scss' diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts index 22b36ff..2a72df2 100644 --- a/src/app/core/constants.ts +++ b/src/app/core/constants.ts @@ -2,9 +2,11 @@ export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId'; export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute'; export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; +export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const ROOM_URL_PATTERN = /\/room\/([^/]+)/; export const STORE_DEVTOOLS_MAX_AGE = 25; +export const DEBUG_LOG_MAX_ENTRIES = 500; export const DEFAULT_MAX_USERS = 50; export const DEFAULT_AUDIO_BITRATE_KBPS = 96; export const DEFAULT_VOLUME = 100; diff --git a/src/app/core/helpers/debugging-helpers.ts b/src/app/core/helpers/debugging-helpers.ts new file mode 100644 index 0000000..7cb47af --- /dev/null +++ b/src/app/core/helpers/debugging-helpers.ts @@ -0,0 +1,26 @@ +import type { DebuggingService } from '../services/debugging.service'; + +export function reportDebuggingError( + debugging: DebuggingService, + source: string, + message: string, + payload: Record, + error: unknown +): void { + debugging.error(source, message, { + ...payload, + error + }); +} + +export function trackDebuggingTaskFailure( + task: Promise | unknown, + debugging: DebuggingService, + source: string, + message: string, + payload: Record +): void { + Promise.resolve(task).catch((error) => { + reportDebuggingError(debugging, source, message, payload, error); + }); +} diff --git a/src/app/core/models/debugging.models.ts b/src/app/core/models/debugging.models.ts new file mode 100644 index 0000000..bbe9d74 --- /dev/null +++ b/src/app/core/models/debugging.models.ts @@ -0,0 +1,193 @@ +import type { Room, User } from './index'; + +export type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug'; +export type DebugNetworkNodeKind = 'local-client' | 'remote-client' | 'signaling-server' | 'app-server'; +export type DebugNetworkEdgeKind = 'peer' | 'signaling' | 'membership'; +export type DebugNetworkMessageDirection = 'inbound' | 'outbound'; +export type DebugNetworkMessageScope = 'data-channel' | 'signaling'; + +export interface DebugLogEntry { + id: number; + timestamp: number; + timeLabel: string; + dateTimeLabel: string; + level: DebugLogLevel; + source: string; + message: string; + payload: unknown | null; + payloadText: string | null; + count: number; +} + +export interface DebugNetworkMessageGroup { + id: string; + scope: DebugNetworkMessageScope; + direction: DebugNetworkMessageDirection; + type: string; + count: number; + lastSeen: number; +} + +export interface DebugNetworkHandshakeStats { + answersReceived: number; + answersSent: number; + iceReceived: number; + iceSent: number; + offersReceived: number; + offersSent: number; +} + +export interface DebugNetworkTextStats { + received: number; + sent: number; +} + +export interface DebugNetworkStreamStats { + audio: number; + video: number; +} + +export interface DebugNetworkDownloadStats { + audioMbps: number | null; + fileMbps: number | null; + videoMbps: number | null; +} + +export interface DebugNetworkNode { + id: string; + identity: string | null; + userId: string | null; + kind: DebugNetworkNodeKind; + label: string; + secondaryLabel: string; + title: string; + statuses: string[]; + isActive: boolean; + isVoiceConnected: boolean; + isTyping: boolean; + isSpeaking: boolean; + isMuted: boolean; + isDeafened: boolean; + isStreaming: boolean; + connectionDrops: number; + downloads: DebugNetworkDownloadStats; + handshake: DebugNetworkHandshakeStats; + pingMs: number | null; + streams: DebugNetworkStreamStats; + textMessages: DebugNetworkTextStats; + lastSeen: number; +} + +export interface DebugNetworkEdge { + id: string; + kind: DebugNetworkEdgeKind; + sourceId: string; + targetId: string; + sourceLabel: string; + targetLabel: string; + label: string; + stateLabel: string; + isActive: boolean; + pingMs: number | null; + lastSeen: number; + messageTotal: number; + messageGroups: DebugNetworkMessageGroup[]; +} + +export interface DebugNetworkSummary { + clientCount: number; + serverCount: number; + signalingServerCount: number; + peerConnectionCount: number; + membershipCount: number; + messageCount: number; + typingCount: number; + speakingCount: number; + streamingCount: number; +} + +export interface DebugNetworkSnapshot { + nodes: DebugNetworkNode[]; + edges: DebugNetworkEdge[]; + summary: DebugNetworkSummary; + generatedAt: number; +} + +export interface DebuggingSettingsState { + enabled?: boolean; +} + +export interface PendingDebugEntry { + level: DebugLogLevel; + source: string; + message: string; + payload?: unknown; + payloadText?: string | null; + timestamp?: number; +} + +export type ConsoleMethodName = 'debug' | 'log' | 'info' | 'warn' | 'error'; +export type ConsoleMethod = (...args: unknown[]) => void; + +export interface MutableDebugNetworkMessageGroup { + id: string; + scope: DebugNetworkMessageScope; + direction: DebugNetworkMessageDirection; + type: string; + count: number; + lastSeen: number; +} + +export interface MutableDebugNetworkNode { + id: string; + identity: string | null; + userId: string | null; + kind: DebugNetworkNodeKind; + label: string; + secondaryLabel: string; + title: string; + lastSeen: number; + isActive: boolean; + isVoiceConnected: boolean; + typingExpiresAt: number | null; + isSpeaking: boolean; + isMuted: boolean; + isDeafened: boolean; + isStreaming: boolean; + connectionDrops: number; + downloads: DebugNetworkDownloadStats; + downloadsUpdatedAt: number | null; + fileTransferSamples: DebugNetworkTransferSample[]; + handshake: DebugNetworkHandshakeStats; + pingMs: number | null; + streams: DebugNetworkStreamStats; + textMessages: DebugNetworkTextStats; +} + +export interface DebugNetworkTransferSample { + bytes: number; + timestamp: number; +} + +export interface MutableDebugNetworkEdge { + id: string; + kind: DebugNetworkEdgeKind; + sourceId: string; + targetId: string; + stateLabel: string; + isActive: boolean; + pingMs: number | null; + lastSeen: number; + messageTotal: number; + messageGroups: Map; +} + +export interface DebugNetworkBuildState { + currentRoom: Room | null; + currentUser: User | null; + edges: Map; + localIds: Set; + nodes: Map; + users: readonly User[]; + userLookup: Map; +} diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index bf8dd15..8ac99a0 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -11,6 +11,7 @@ import { WebRTCService } from './webrtc.service'; import { Store } from '@ngrx/store'; import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; import { DatabaseService } from './database.service'; +import { recordDebugNetworkFileChunk } from './debug-network-metrics.service'; /** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB @@ -400,7 +401,7 @@ export class AttachmentService { * assembled into a Blob and an object URL is created. */ handleFileChunk(payload: any): void { - const { messageId, fileId, index, total, data } = payload; + const { messageId, fileId, fromPeerId, index, total, data } = payload; if ( !messageId || !fileId || @@ -444,6 +445,9 @@ export class AttachmentService { attachment.receivedBytes = previousReceived + decodedBytes.byteLength; + if (fromPeerId) + recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now); + if (!attachment.startedAtMs) attachment.startedAtMs = now; diff --git a/src/app/core/services/debug-network-metrics.service.ts b/src/app/core/services/debug-network-metrics.service.ts new file mode 100644 index 0000000..e329c3d --- /dev/null +++ b/src/app/core/services/debug-network-metrics.service.ts @@ -0,0 +1,386 @@ +type DebugNetworkMetricDirection = 'inbound' | 'outbound'; +type DebugNetworkHandshakeType = 'answer' | 'ice_candidate' | 'offer'; + +const FILE_RATE_WINDOW_MS = 6_000; + +export interface DebugNetworkMetricHandshakeCounts { + answersReceived: number; + answersSent: number; + iceReceived: number; + iceSent: number; + offersReceived: number; + offersSent: number; +} + +export interface DebugNetworkMetricTextCounts { + received: number; + sent: number; +} + +export interface DebugNetworkMetricStreamCounts { + audio: number; + video: number; +} + +export interface DebugNetworkMetricDownloadRates { + audioMbps: number | null; + fileMbps: number | null; + updatedAt: number | null; + videoMbps: number | null; +} + +export interface DebugNetworkMetricSnapshot { + connectionDrops: number; + downloads: DebugNetworkMetricDownloadRates; + handshake: DebugNetworkMetricHandshakeCounts; + lastConnectionState: string | null; + pingMs: number | null; + streams: DebugNetworkMetricStreamCounts; + textMessages: DebugNetworkMetricTextCounts; +} + +interface DebugNetworkFileSample { + bytes: number; + timestamp: number; +} + +interface InternalDebugNetworkMetricState extends DebugNetworkMetricSnapshot { + fileSamples: DebugNetworkFileSample[]; +} + +function createHandshakeCounts(): DebugNetworkMetricHandshakeCounts { + return { + answersReceived: 0, + answersSent: 0, + iceReceived: 0, + iceSent: 0, + offersReceived: 0, + offersSent: 0 + }; +} + +function createTextCounts(): DebugNetworkMetricTextCounts { + return { + received: 0, + sent: 0 + }; +} + +function createStreamCounts(): DebugNetworkMetricStreamCounts { + return { + audio: 0, + video: 0 + }; +} + +function createDownloadRates(): DebugNetworkMetricDownloadRates { + return { + audioMbps: null, + fileMbps: null, + updatedAt: null, + videoMbps: null + }; +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return null; + + return value as Record; +} + +function getString(record: Record, key: string): string | null { + const value = record[key]; + + return typeof value === 'string' ? value : null; +} + +function isTrackedTextMessageType(type: string | null): boolean { + return type === 'chat-message' || type === 'message'; +} + +class DebugNetworkMetricsStore { + private readonly metrics = new Map(); + + recordConnectionState(peerId: string, state: string): void { + if (!peerId || !state) + return; + + const metric = this.ensure(peerId); + + if ((state === 'disconnected' || state === 'failed') && metric.lastConnectionState !== state) + metric.connectionDrops += 1; + + metric.lastConnectionState = state; + + if (state === 'closed' || state === 'failed' || state === 'disconnected') { + metric.streams.audio = 0; + metric.streams.video = 0; + } + } + + recordPing(peerId: string, pingMs: number): void { + if (!peerId || !Number.isFinite(pingMs)) + return; + + this.ensure(peerId).pingMs = Math.max(0, Math.round(pingMs)); + } + + recordDataChannelPayload( + peerId: string, + payload: Record, + direction: DebugNetworkMetricDirection + ): void { + if (!peerId) + return; + + const type = getString(payload, 'type'); + + if (isTrackedTextMessageType(type)) + this.incrementText(peerId, direction); + } + + recordSignalingPayload(payload: unknown, direction: DebugNetworkMetricDirection): void { + const record = getRecord(payload); + + if (!record) + return; + + const type = getString(record, 'type'); + + if (type !== 'offer' && type !== 'answer' && type !== 'ice_candidate') + return; + + const peerId = direction === 'outbound' + ? getString(record, 'targetUserId') + : getString(record, 'fromUserId'); + + if (!peerId) + return; + + this.incrementHandshake(peerId, type, direction); + } + + recordStreams(peerId: string, streams: Partial): void { + if (!peerId) + return; + + const metric = this.ensure(peerId); + + if (typeof streams.audio === 'number' && Number.isFinite(streams.audio)) + metric.streams.audio = Math.max(0, Math.round(streams.audio)); + + if (typeof streams.video === 'number' && Number.isFinite(streams.video)) + metric.streams.video = Math.max(0, Math.round(streams.video)); + } + + recordDownloadRates( + peerId: string, + rates: { audioMbps?: number | null; videoMbps?: number | null }, + timestamp: number = Date.now() + ): void { + if (!peerId) + return; + + const metric = this.ensure(peerId); + + if (rates.audioMbps !== undefined) + metric.downloads.audioMbps = this.sanitizeRate(rates.audioMbps); + + if (rates.videoMbps !== undefined) + metric.downloads.videoMbps = this.sanitizeRate(rates.videoMbps); + + metric.downloads.updatedAt = timestamp; + } + + recordFileChunk(peerId: string, bytes: number, timestamp: number = Date.now()): void { + if (!peerId || !Number.isFinite(bytes) || bytes <= 0) + return; + + const metric = this.ensure(peerId); + + metric.fileSamples.push({ + bytes, + timestamp + }); + + metric.fileSamples = metric.fileSamples.filter( + (sample) => timestamp - sample.timestamp <= FILE_RATE_WINDOW_MS + ); + + metric.downloads.fileMbps = this.calculateFileMbps(metric.fileSamples, timestamp); + } + + getSnapshot(peerId: string): DebugNetworkMetricSnapshot | null { + const metric = this.metrics.get(peerId); + + if (!metric) + return null; + + const now = Date.now(); + + metric.fileSamples = metric.fileSamples.filter( + (sample) => now - sample.timestamp <= FILE_RATE_WINDOW_MS + ); + + metric.downloads.fileMbps = this.calculateFileMbps(metric.fileSamples, now); + + return { + connectionDrops: metric.connectionDrops, + downloads: { ...metric.downloads }, + handshake: { ...metric.handshake }, + lastConnectionState: metric.lastConnectionState, + pingMs: metric.pingMs, + streams: { ...metric.streams }, + textMessages: { ...metric.textMessages } + }; + } + + private ensure(peerId: string): InternalDebugNetworkMetricState { + const existing = this.metrics.get(peerId); + + if (existing) + return existing; + + const created: InternalDebugNetworkMetricState = { + connectionDrops: 0, + downloads: createDownloadRates(), + fileSamples: [], + handshake: createHandshakeCounts(), + lastConnectionState: null, + pingMs: null, + streams: createStreamCounts(), + textMessages: createTextCounts() + }; + + this.metrics.set(peerId, created); + + return created; + } + + private incrementHandshake( + peerId: string, + type: DebugNetworkHandshakeType, + direction: DebugNetworkMetricDirection, + count = 1 + ): void { + if (!peerId || count <= 0) + return; + + const metric = this.ensure(peerId); + + switch (type) { + case 'offer': + if (direction === 'outbound') + metric.handshake.offersSent += count; + else + metric.handshake.offersReceived += count; + + return; + + case 'answer': + if (direction === 'outbound') + metric.handshake.answersSent += count; + else + metric.handshake.answersReceived += count; + + return; + + case 'ice_candidate': + if (direction === 'outbound') + metric.handshake.iceSent += count; + else + metric.handshake.iceReceived += count; + + return; + } + } + + private incrementText( + peerId: string, + direction: DebugNetworkMetricDirection, + count = 1 + ): void { + if (!peerId || count <= 0) + return; + + const metric = this.ensure(peerId); + + if (direction === 'outbound') { + metric.textMessages.sent += count; + return; + } + + metric.textMessages.received += count; + } + + private calculateFileMbps(samples: DebugNetworkFileSample[], now: number): number | null { + if (samples.length === 0) + return null; + + const totalBytes = samples.reduce((sum, sample) => sum + sample.bytes, 0); + const earliestTimestamp = samples[0]?.timestamp ?? now; + const durationMs = Math.max(1_000, now - earliestTimestamp); + + return this.sanitizeRate(totalBytes * 8 / durationMs / 1000); + } + + private sanitizeRate(value: number | null | undefined): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + return null; + + return Math.round(value * 1000) / 1000; + } +} + +const debugNetworkMetricsStore = new DebugNetworkMetricsStore(); + +export function recordDebugNetworkConnectionState(peerId: string, state: string): void { + debugNetworkMetricsStore.recordConnectionState(peerId, state); +} + +export function recordDebugNetworkPing(peerId: string, pingMs: number): void { + debugNetworkMetricsStore.recordPing(peerId, pingMs); +} + +export function recordDebugNetworkDataChannelPayload( + peerId: string, + payload: Record, + direction: DebugNetworkMetricDirection +): void { + debugNetworkMetricsStore.recordDataChannelPayload(peerId, payload, direction); +} + +export function recordDebugNetworkSignalingPayload( + payload: unknown, + direction: DebugNetworkMetricDirection +): void { + debugNetworkMetricsStore.recordSignalingPayload(payload, direction); +} + +export function recordDebugNetworkStreams( + peerId: string, + streams: Partial +): void { + debugNetworkMetricsStore.recordStreams(peerId, streams); +} + +export function recordDebugNetworkDownloadRates( + peerId: string, + rates: { audioMbps?: number | null; videoMbps?: number | null }, + timestamp?: number +): void { + debugNetworkMetricsStore.recordDownloadRates(peerId, rates, timestamp); +} + +export function recordDebugNetworkFileChunk( + peerId: string, + bytes: number, + timestamp?: number +): void { + debugNetworkMetricsStore.recordFileChunk(peerId, bytes, timestamp); +} + +export function getDebugNetworkMetricSnapshot(peerId: string): DebugNetworkMetricSnapshot | null { + return debugNetworkMetricsStore.getSnapshot(peerId); +} diff --git a/src/app/core/services/debugging.service.ts b/src/app/core/services/debugging.service.ts new file mode 100644 index 0000000..2e72c8f --- /dev/null +++ b/src/app/core/services/debugging.service.ts @@ -0,0 +1,2 @@ +export * from '../models/debugging.models'; +export * from './debugging/debugging.service'; diff --git a/src/app/core/services/debugging/debugging-network-snapshot.builder.ts b/src/app/core/services/debugging/debugging-network-snapshot.builder.ts new file mode 100644 index 0000000..36bb288 --- /dev/null +++ b/src/app/core/services/debugging/debugging-network-snapshot.builder.ts @@ -0,0 +1,1345 @@ +/* eslint-disable complexity, padding-line-between-statements */ +import { getDebugNetworkMetricSnapshot } from '../debug-network-metrics.service'; +import type { Room, User } from '../../models/index'; +import { + LOCAL_NETWORK_NODE_ID, + NETWORK_DOWNLOAD_STAT_TTL_MS, + NETWORK_FILE_TRANSFER_RATE_WINDOW_MS, + NETWORK_IGNORED_MESSAGE_TYPES, + NETWORK_MESSAGE_GROUP_LIMIT, + NETWORK_RECENT_EDGE_TTL_MS, + NETWORK_TYPING_TTL_MS, + createEmptyDownloadStats, + createEmptyHandshakeStats, + createEmptyStreamStats, + createEmptyTextStats +} from './debugging.constants'; +import type { + DebugLogEntry, + DebugNetworkBuildState, + DebugNetworkDownloadStats, + DebugNetworkEdge, + DebugNetworkEdgeKind, + DebugNetworkMessageDirection, + DebugNetworkMessageScope, + DebugNetworkNode, + DebugNetworkNodeKind, + DebugNetworkSnapshot, + DebugNetworkTransferSample, + MutableDebugNetworkEdge, + MutableDebugNetworkNode +} from '../../models/debugging.models'; + +export function buildDebugNetworkSnapshot( + entries: readonly DebugLogEntry[], + currentUser: User | null, + allUsers: readonly User[], + currentRoom: Room | null +): DebugNetworkSnapshot { + return new DebugNetworkSnapshotBuilder(currentUser, allUsers, currentRoom).build(entries); +} + +class DebugNetworkSnapshotBuilder { + constructor( + private readonly currentUser: User | null, + private readonly allUsers: readonly User[], + private readonly currentRoom: Room | null + ) {} + + build(entries: readonly DebugLogEntry[]): DebugNetworkSnapshot { + const now = Date.now(); + const state = this.createNetworkBuildState(now); + + for (const entry of entries) { + this.applyEntryToNetworkState(state, entry); + } + + this.applyLiveUserNetworkState(state, now); + + const visibleEdges = Array.from(state.edges.values()) + .filter((edge) => this.shouldIncludeNetworkEdge(edge, now)) + .sort((edgeA, edgeB) => this.compareNetworkEdges(edgeA, edgeB)); + const visibleNodeIds = new Set([LOCAL_NETWORK_NODE_ID]); + const activeNodeIds = new Set([LOCAL_NETWORK_NODE_ID]); + + for (const edge of visibleEdges) { + visibleNodeIds.add(edge.sourceId); + visibleNodeIds.add(edge.targetId); + + if (edge.isActive) { + activeNodeIds.add(edge.sourceId); + activeNodeIds.add(edge.targetId); + } + } + + const visibleNodes = Array.from(state.nodes.values()) + .filter((node) => visibleNodeIds.has(node.id) || node.kind === 'local-client') + .map((node) => this.finalizeNetworkNode(node, state, now, activeNodeIds.has(node.id))) + .sort((nodeA, nodeB) => this.compareFinalNetworkNodes(nodeA, nodeB)); + const nodeLookup = new Map(visibleNodes.map((node) => [node.id, node])); + const finalizedEdges = visibleEdges + .map((edge) => this.finalizeNetworkEdge(edge, nodeLookup)) + .filter((edge): edge is DebugNetworkEdge => edge !== null); + const summary = { + clientCount: visibleNodes.filter((node) => node.kind === 'local-client' || node.kind === 'remote-client').length, + serverCount: visibleNodes.filter((node) => node.kind === 'app-server').length, + signalingServerCount: visibleNodes.filter((node) => node.kind === 'signaling-server').length, + peerConnectionCount: finalizedEdges.filter((edge) => edge.kind === 'peer' && edge.isActive).length, + membershipCount: finalizedEdges.filter((edge) => edge.kind === 'membership' && edge.isActive).length, + messageCount: finalizedEdges.reduce((sum, edge) => sum + edge.messageTotal, 0), + typingCount: visibleNodes.filter((node) => node.isTyping).length, + speakingCount: visibleNodes.filter((node) => node.isSpeaking).length, + streamingCount: visibleNodes.filter((node) => node.isStreaming).length + }; + + return { + nodes: visibleNodes, + edges: finalizedEdges, + summary, + generatedAt: now + }; + } + + private createNetworkBuildState(now: number): DebugNetworkBuildState { + const localIds = new Set(); + + if (this.currentUser?.id) + localIds.add(this.currentUser.id); + + if (this.currentUser?.oderId) + localIds.add(this.currentUser.oderId); + + const state: DebugNetworkBuildState = { + currentRoom: this.currentRoom, + currentUser: this.currentUser, + edges: new Map(), + localIds, + nodes: new Map(), + users: this.allUsers, + userLookup: this.buildNetworkUserLookup(this.allUsers) + }; + + this.ensureLocalNetworkNode(state, now); + + return state; + } + + private buildNetworkUserLookup(allUsers: readonly User[]): Map { + const lookup = new Map(); + + for (const user of allUsers) { + lookup.set(user.id, user); + + if (user.oderId) + lookup.set(user.oderId, user); + } + + return lookup; + } + + private applyEntryToNetworkState(state: DebugNetworkBuildState, entry: DebugLogEntry): void { + switch (entry.source) { + case 'webrtc:signaling': + this.applySignalingNetworkEntry(state, entry); + return; + case 'webrtc:data-channel': + this.applyDataChannelNetworkEntry(state, entry); + return; + case 'webrtc:voice-activity': + this.applyVoiceActivityNetworkEntry(state, entry); + return; + case 'webrtc': + this.applyGenericWebRTCNetworkEntry(state, entry); + return; + } + } + + private applySignalingNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { + const payload = this.getEntryPayloadRecord(entry.payload); + const timestamp = entry.timestamp; + + if ( + entry.message === 'Connecting to signaling server' + || entry.message === 'Connected to signaling server' + || entry.message === 'Attempting reconnect' + || entry.message === 'Disconnected from signaling server' + || entry.message === 'Signaling socket error' + || entry.message === 'Failed to initialize signaling socket' + ) { + const url = this.getPayloadString(payload, 'serverUrl') ?? this.getPayloadString(payload, 'url'); + + if (!url) + return; + + const serverNode = this.ensureSignalingServerNode(state, url, timestamp); + const edge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp); + + edge.stateLabel = this.getSignalingEdgeStateLabel(entry.message); + edge.isActive = edge.stateLabel === 'connected' || edge.stateLabel === 'connecting' || edge.stateLabel === 'active'; + + return; + } + + if (entry.message !== 'inbound' && entry.message !== 'outbound') + return; + + const direction = entry.message as DebugNetworkMessageDirection; + const type = this.getPayloadString(payload, 'type') ?? 'unknown'; + const url = this.getPayloadString(payload, 'url'); + + if (url) { + const signalingNode = this.ensureSignalingServerNode(state, url, timestamp); + const signalingEdge = this.ensureNetworkEdge(state, 'signaling', LOCAL_NETWORK_NODE_ID, signalingNode.id, timestamp); + + signalingEdge.isActive = true; + + if (!signalingEdge.stateLabel || signalingEdge.stateLabel === 'disconnected') + signalingEdge.stateLabel = 'active'; + + this.recordNetworkMessage(signalingEdge, type, direction, 'signaling', timestamp, entry.count); + } + + switch (type) { + case 'identify': { + const oderId = this.getPayloadString(payload, 'oderId'); + const displayName = this.getPayloadString(payload, 'displayName'); + + if (direction === 'outbound') + this.ensureLocalNetworkNode(state, timestamp, oderId, displayName); + + break; + } + + case 'connected': { + const oderId = this.getPayloadString(payload, 'oderId'); + + if (oderId) + this.ensureLocalNetworkNode(state, timestamp, oderId); + + break; + } + + case 'join_server': + case 'view_server': + case 'leave_server': { + const serverId = this.getPayloadString(payload, 'serverId'); + + if (!serverId) + return; + + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', LOCAL_NETWORK_NODE_ID, serverNode.id, timestamp); + + membershipEdge.isActive = type !== 'leave_server'; + membershipEdge.stateLabel = type === 'view_server' + ? 'viewing' + : (type === 'join_server' ? 'joined' : 'left'); + + break; + } + + case 'server_users': { + const serverId = this.getPayloadString(payload, 'serverId'); + const users = this.getPayloadArray(payload, 'users'); + + if (!serverId) + return; + + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + + for (const userValue of users) { + const userRecord = this.asRecord(userValue); + const userId = this.getStringProperty(userRecord, 'oderId'); + + if (!userId) + continue; + + const clientNode = this.ensureClientNetworkNode( + state, + userId, + timestamp, + this.getStringProperty(userRecord, 'displayName') + ); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp); + + membershipEdge.isActive = true; + membershipEdge.stateLabel = 'joined'; + } + + break; + } + + case 'user_joined': + case 'user_left': + case 'user_typing': { + const oderId = this.getPayloadString(payload, 'oderId'); + const displayName = this.getPayloadString(payload, 'displayName'); + const serverId = this.getPayloadString(payload, 'serverId'); + + if (!oderId) + return; + + const clientNode = this.ensureClientNetworkNode(state, oderId, timestamp, displayName); + + if (type === 'user_typing') + clientNode.typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS; + + if (serverId) { + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', clientNode.id, serverNode.id, timestamp); + + membershipEdge.isActive = type !== 'user_left'; + membershipEdge.stateLabel = type === 'user_joined' ? 'joined' : (type === 'user_left' ? 'left' : 'active'); + } + + break; + } + + case 'typing': + this.ensureLocalNetworkNode(state, timestamp).typingExpiresAt = timestamp + NETWORK_TYPING_TTL_MS; + break; + + case 'offer': + case 'answer': + case 'ice_candidate': { + const peerId = this.getPayloadString(payload, 'targetPeerId') ?? this.getPayloadString(payload, 'fromUserId'); + const displayName = this.getPayloadString(payload, 'displayName'); + + if (!peerId) + return; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, displayName); + const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + + this.incrementHandshakeStats(peerNode, type, direction, entry.count); + this.recordNetworkMessage(peerEdge, type, direction, 'signaling', timestamp, entry.count); + peerEdge.isActive = true; + + if (peerEdge.stateLabel !== 'connected') + peerEdge.stateLabel = 'negotiating'; + + break; + } + } + } + + private applyDataChannelNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { + const payload = this.getEntryPayloadRecord(entry.payload); + const timestamp = entry.timestamp; + + if (entry.message === 'Peer latency updated') { + const peerId = this.getPayloadString(payload, 'peerId'); + const latencyMs = this.getPayloadNumber(payload, 'latencyMs'); + + if (!peerId || latencyMs === null) + return; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); + const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + + edge.isActive = true; + edge.pingMs = latencyMs; + edge.stateLabel = 'connected'; + peerNode.pingMs = latencyMs; + + return; + } + + if (entry.message === 'Data channel open' || entry.message === 'Data channel closed' || entry.message === 'Data channel error') { + const peerId = this.getPayloadString(payload, 'peerId'); + + if (!peerId) + return; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); + const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + + if (entry.message === 'Data channel open') { + edge.isActive = true; + edge.stateLabel = 'connected'; + } else if (entry.message === 'Data channel closed') { + edge.isActive = false; + edge.stateLabel = 'closed'; + peerNode.isSpeaking = false; + } else { + edge.isActive = false; + edge.stateLabel = 'error'; + } + + return; + } + + if (entry.message !== 'inbound' && entry.message !== 'outbound') + return; + + const direction = entry.message as DebugNetworkMessageDirection; + const peerId = this.getPayloadString(payload, 'peerId'); + + if (!peerId) + return; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp, this.getPayloadString(payload, 'displayName')); + const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + const type = this.getPayloadString(payload, 'type') ?? 'unknown'; + + edge.isActive = true; + edge.stateLabel = 'connected'; + this.recordNetworkMessage(edge, type, direction, 'data-channel', timestamp, entry.count); + + if (type === 'chat-message' || type === 'message') + this.incrementTextMessageCount(peerNode, direction, entry.count); + + if (type === 'file-chunk' && direction === 'inbound') { + const bytes = this.getPayloadNumber(payload, 'bytes'); + + if (bytes !== null) + this.recordFileTransferSample(peerNode, timestamp, bytes, entry.count); + } + + if (direction === 'outbound') { + const outboundIdentity = this.getPayloadString(payload, 'oderId'); + + if (outboundIdentity) { + this.ensureLocalNetworkNode( + state, + timestamp, + outboundIdentity, + this.getPayloadString(payload, 'displayName') + ); + } + } + + if (type === 'voice-state') { + const voiceState = this.getPayloadRecord(payload, 'voiceState'); + const subjectNode = direction === 'outbound' + ? this.ensureLocalNetworkNode( + state, + timestamp, + this.getPayloadString(payload, 'oderId'), + this.getPayloadString(payload, 'displayName') + ) + : peerNode; + + this.applyVoiceStateToNetworkNode(subjectNode, voiceState); + + const serverId = this.getStringProperty(voiceState, 'serverId') ?? this.getStringProperty(voiceState, 'roomId'); + + if (serverId) { + const serverNode = this.ensureAppServerNode(state, serverId, timestamp); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', subjectNode.id, serverNode.id, timestamp); + + membershipEdge.isActive = true; + membershipEdge.stateLabel = 'joined'; + } + } + + if (type === 'screen-state') { + const subjectNode = direction === 'outbound' + ? this.ensureLocalNetworkNode( + state, + timestamp, + this.getPayloadString(payload, 'oderId'), + this.getPayloadString(payload, 'displayName') + ) + : peerNode; + const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing'); + + if (isScreenSharing !== null) { + subjectNode.isStreaming = isScreenSharing; + + if (!isScreenSharing) + subjectNode.streams.video = 0; + } + } + } + + private applyGenericWebRTCNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { + const payload = this.getEntryPayloadRecord(entry.payload); + const timestamp = entry.timestamp; + const peerId = this.getStringProperty(payload, 'remotePeerId') ?? this.getStringProperty(payload, 'peerId'); + + if (!peerId) + return; + + const peerNode = this.ensureClientNetworkNode(state, peerId, timestamp); + const edge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, peerNode.id, timestamp); + + switch (entry.message) { + case 'Creating peer connection': + case 'Received data channel': + edge.isActive = true; + + if (edge.stateLabel !== 'connected') + edge.stateLabel = 'connecting'; + + return; + + case 'connectionstatechange': { + const connectionState = this.getStringProperty(payload, 'state'); + + if (!connectionState) + return; + + const previousState = edge.stateLabel; + + edge.stateLabel = connectionState; + edge.isActive = connectionState === 'connected' || connectionState === 'connecting'; + + if (!edge.isActive) { + peerNode.isSpeaking = false; + + if (connectionState === 'disconnected' || connectionState === 'failed') { + if (previousState !== connectionState) + peerNode.connectionDrops += 1; + + peerNode.streams.audio = 0; + peerNode.streams.video = 0; + } + } + + return; + } + + case 'Remote stream updated': { + const audioTrackCount = this.getNumberProperty(payload, 'audioTrackCount'); + const videoTrackCount = this.getNumberProperty(payload, 'videoTrackCount'); + + edge.isActive = true; + + if (!edge.stateLabel || edge.stateLabel === 'negotiating') + edge.stateLabel = 'connected'; + + if (audioTrackCount !== null) + peerNode.streams.audio = Math.max(0, Math.round(audioTrackCount)); + + if (videoTrackCount !== null) + peerNode.streams.video = Math.max(0, Math.round(videoTrackCount)); + + return; + } + + case 'Peer transport stats': { + edge.isActive = true; + + if (!edge.stateLabel || edge.stateLabel === 'negotiating') + edge.stateLabel = 'connected'; + + this.updateNodeDownloadStats(peerNode, { + audioMbps: this.getNumberProperty(payload, 'audioDownloadMbps'), + videoMbps: this.getNumberProperty(payload, 'videoDownloadMbps') + }, timestamp); + + return; + } + } + } + + private applyVoiceActivityNetworkEntry(state: DebugNetworkBuildState, entry: DebugLogEntry): void { + const payload = this.getEntryPayloadRecord(entry.payload); + const peerId = this.getStringProperty(payload, 'peerId'); + const isSpeaking = this.getBooleanProperty(payload, 'isSpeaking'); + + if (!peerId || isSpeaking === null) + return; + + const node = this.ensureClientNetworkNode(state, peerId, entry.timestamp); + + node.isSpeaking = isSpeaking; + node.lastSeen = Math.max(node.lastSeen, entry.timestamp); + } + + private applyLiveUserNetworkState(state: DebugNetworkBuildState, now: number): void { + const localUser = state.currentUser; + const localIdentity = localUser ? this.getUserNetworkIdentity(localUser) : null; + const localVoiceServerId = localUser ? this.getUserVoiceServerId(localUser) : null; + const localVoiceConnected = localUser?.voiceState?.isConnected === true; + + if (localUser && localIdentity) { + const localNode = this.ensureLocalNetworkNode(state, now, localIdentity, localUser.displayName); + + this.applyUserStateToNetworkNode(localNode, localUser, now); + + if (localVoiceServerId) { + const serverNode = this.ensureAppServerNode(state, localVoiceServerId, now); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', localNode.id, serverNode.id, now); + + membershipEdge.isActive = true; + membershipEdge.stateLabel = localVoiceConnected ? 'joined' : 'viewing'; + membershipEdge.lastSeen = now; + } + } + + for (const user of state.users) { + const identity = this.getUserNetworkIdentity(user); + + if (!identity) + continue; + + const node = this.isLocalIdentity(state, identity) + ? this.ensureLocalNetworkNode(state, now, identity, user.displayName) + : this.ensureClientNetworkNode(state, identity, now, user.displayName); + + this.applyUserStateToNetworkNode(node, user, now); + + const voiceServerId = this.getUserVoiceServerId(user); + + if (voiceServerId) { + const serverNode = this.ensureAppServerNode(state, voiceServerId, now); + const membershipEdge = this.ensureNetworkEdge(state, 'membership', node.id, serverNode.id, now); + + membershipEdge.isActive = user.voiceState?.isConnected === true; + membershipEdge.stateLabel = membershipEdge.isActive ? 'joined' : 'inactive'; + + if (membershipEdge.isActive) + membershipEdge.lastSeen = now; + } + + if ( + !this.isLocalIdentity(state, identity) + && localVoiceConnected + && user.voiceState?.isConnected === true + && voiceServerId + && localVoiceServerId + && voiceServerId === localVoiceServerId + ) { + const peerEdge = this.ensureNetworkEdge(state, 'peer', LOCAL_NETWORK_NODE_ID, node.id, now); + + peerEdge.isActive = true; + peerEdge.stateLabel = 'connected'; + peerEdge.lastSeen = now; + + if (node.pingMs !== null) + peerEdge.pingMs = node.pingMs; + } + } + } + + private applyUserStateToNetworkNode(node: MutableDebugNetworkNode, user: User, now: number): void { + node.label = user.displayName || user.username || node.label; + node.secondaryLabel = user.username ? `@${user.username}` : node.secondaryLabel; + node.title = user.displayName || user.username || node.title; + node.userId = user.id || node.userId; + node.isVoiceConnected = user.voiceState?.isConnected === true; + node.isMuted = user.voiceState?.isMuted === true; + node.isDeafened = user.voiceState?.isDeafened === true; + node.isSpeaking = user.voiceState?.isSpeaking === true || node.isSpeaking; + node.isStreaming = user.screenShareState?.isSharing === true || node.isStreaming; + + if (user.voiceState?.isConnected !== true) + node.streams.audio = 0; + + if (user.screenShareState?.isSharing !== true) + node.streams.video = 0; + + node.lastSeen = Math.max(node.lastSeen, now); + } + + private getUserNetworkIdentity(user: User): string | null { + return user.oderId || user.id || null; + } + + private getUserVoiceServerId(user: User): string | null { + return user.voiceState?.serverId || user.voiceState?.roomId || null; + } + + private ensureLocalNetworkNode( + state: DebugNetworkBuildState, + timestamp: number, + identity?: string | null, + displayName?: string | null + ): MutableDebugNetworkNode { + const node = this.ensureNetworkNode(state, LOCAL_NETWORK_NODE_ID, 'local-client', identity ?? null, timestamp); + const currentUser = state.currentUser; + + if (identity) + state.localIds.add(identity); + + node.isActive = true; + node.userId = currentUser?.id || node.userId; + node.label = currentUser?.displayName || currentUser?.username || displayName || node.label || 'You'; + node.secondaryLabel = currentUser?.username ? `@${currentUser.username}` : 'You'; + node.title = currentUser?.displayName || displayName || 'You'; + + return node; + } + + private ensureClientNetworkNode( + state: DebugNetworkBuildState, + identity: string, + timestamp: number, + displayName?: string | null + ): MutableDebugNetworkNode { + if (this.isLocalIdentity(state, identity)) + return this.ensureLocalNetworkNode(state, timestamp, identity, displayName); + + const node = this.ensureNetworkNode(state, `client:${identity}`, 'remote-client', identity, timestamp); + + this.applyUserMetadataToNetworkNode(state, node, identity, displayName); + + return node; + } + + private ensureSignalingServerNode(state: DebugNetworkBuildState, url: string, timestamp: number): MutableDebugNetworkNode { + const node = this.ensureNetworkNode(state, `signal:${url}`, 'signaling-server', url, timestamp); + + node.label = this.getSignalingHostLabel(url); + node.secondaryLabel = this.getSignalingSecondaryLabel(url); + node.title = url; + + return node; + } + + private ensureAppServerNode(state: DebugNetworkBuildState, serverId: string, timestamp: number): MutableDebugNetworkNode { + const node = this.ensureNetworkNode(state, `server:${serverId}`, 'app-server', serverId, timestamp); + const currentRoom = state.currentRoom; + + node.label = currentRoom?.id === serverId + ? currentRoom.name + : (node.label || `Server ${this.shortenIdentifier(serverId)}`); + node.secondaryLabel = serverId; + node.title = node.label; + + return node; + } + + private ensureNetworkNode( + state: DebugNetworkBuildState, + nodeId: string, + kind: DebugNetworkNodeKind, + identity: string | null, + timestamp: number + ): MutableDebugNetworkNode { + const existing = state.nodes.get(nodeId); + + if (existing) { + existing.lastSeen = Math.max(existing.lastSeen, timestamp); + + if (identity && !existing.identity) + existing.identity = identity; + + return existing; + } + + const node: MutableDebugNetworkNode = { + id: nodeId, + identity, + userId: null, + kind, + label: kind === 'local-client' + ? 'You' + : (kind === 'signaling-server' ? 'Signaling' : `Node ${identity ? this.shortenIdentifier(identity) : 'unknown'}`), + secondaryLabel: identity || kind, + title: identity || kind, + lastSeen: timestamp, + isActive: kind === 'local-client', + isVoiceConnected: false, + typingExpiresAt: null, + isSpeaking: false, + isMuted: false, + isDeafened: false, + isStreaming: false, + connectionDrops: 0, + downloads: createEmptyDownloadStats(), + downloadsUpdatedAt: null, + fileTransferSamples: [], + handshake: createEmptyHandshakeStats(), + pingMs: null, + streams: createEmptyStreamStats(), + textMessages: createEmptyTextStats() + }; + + state.nodes.set(nodeId, node); + + return node; + } + + private applyUserMetadataToNetworkNode( + state: DebugNetworkBuildState, + node: MutableDebugNetworkNode, + identity: string, + displayName?: string | null + ): void { + const user = state.userLookup.get(identity); + const label = user?.displayName || displayName || user?.username || node.label; + + node.identity = identity; + node.userId = user?.id || node.userId; + node.label = label; + node.secondaryLabel = user?.username ? `@${user.username}` : this.shortenIdentifier(identity); + node.title = user?.displayName || displayName || identity; + } + + private ensureNetworkEdge( + state: DebugNetworkBuildState, + kind: DebugNetworkEdgeKind, + sourceId: string, + targetId: string, + timestamp: number + ): MutableDebugNetworkEdge { + const edgeId = `${kind}:${sourceId}->${targetId}`; + const existing = state.edges.get(edgeId); + + if (existing) { + existing.lastSeen = Math.max(existing.lastSeen, timestamp); + return existing; + } + + const edge: MutableDebugNetworkEdge = { + id: edgeId, + kind, + sourceId, + targetId, + stateLabel: kind === 'membership' ? 'joined' : '', + isActive: kind === 'membership', + pingMs: null, + lastSeen: timestamp, + messageTotal: 0, + messageGroups: new Map() + }; + + state.edges.set(edgeId, edge); + + return edge; + } + + private recordNetworkMessage( + edge: MutableDebugNetworkEdge, + type: string, + direction: DebugNetworkMessageDirection, + scope: DebugNetworkMessageScope, + timestamp: number, + count: number + ): void { + if (NETWORK_IGNORED_MESSAGE_TYPES.has(type)) + return; + + const groupId = `${scope}:${direction}:${type}`; + const existing = edge.messageGroups.get(groupId); + + edge.lastSeen = Math.max(edge.lastSeen, timestamp); + edge.messageTotal += count; + + if (existing) { + existing.count += count; + existing.lastSeen = Math.max(existing.lastSeen, timestamp); + return; + } + + edge.messageGroups.set(groupId, { + id: groupId, + scope, + direction, + type, + count, + lastSeen: timestamp + }); + } + + private incrementHandshakeStats( + node: MutableDebugNetworkNode, + type: string, + direction: DebugNetworkMessageDirection, + count: number + ): void { + switch (type) { + case 'offer': + if (direction === 'outbound') + node.handshake.offersSent += count; + else + node.handshake.offersReceived += count; + + return; + + case 'answer': + if (direction === 'outbound') + node.handshake.answersSent += count; + else + node.handshake.answersReceived += count; + + return; + + case 'ice_candidate': + if (direction === 'outbound') + node.handshake.iceSent += count; + else + node.handshake.iceReceived += count; + + return; + } + } + + private incrementTextMessageCount( + node: MutableDebugNetworkNode, + direction: DebugNetworkMessageDirection, + count: number + ): void { + if (direction === 'outbound') { + node.textMessages.sent += count; + return; + } + + node.textMessages.received += count; + } + + private recordFileTransferSample( + node: MutableDebugNetworkNode, + timestamp: number, + bytes: number, + count: number + ): void { + if (bytes <= 0 || count <= 0) + return; + + node.fileTransferSamples.push({ + bytes: bytes * count, + timestamp + }); + node.fileTransferSamples = node.fileTransferSamples.filter( + (sample) => timestamp - sample.timestamp <= NETWORK_FILE_TRANSFER_RATE_WINDOW_MS + ); + } + + private updateNodeDownloadStats( + node: MutableDebugNetworkNode, + stats: { audioMbps?: number | null; videoMbps?: number | null }, + timestamp: number + ): void { + if (stats.audioMbps !== undefined) + node.downloads.audioMbps = this.sanitizeRate(stats.audioMbps); + + if (stats.videoMbps !== undefined) + node.downloads.videoMbps = this.sanitizeRate(stats.videoMbps); + + node.downloadsUpdatedAt = timestamp; + } + + private sanitizeRate(value: number | null | undefined): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + return null; + + return Math.round(value * 1000) / 1000; + } + + private applyVoiceStateToNetworkNode(node: MutableDebugNetworkNode, voiceState: Record | null): void { + if (!voiceState) + return; + + const isConnected = this.getBooleanProperty(voiceState, 'isConnected'); + const isMuted = this.getBooleanProperty(voiceState, 'isMuted'); + const isDeafened = this.getBooleanProperty(voiceState, 'isDeafened'); + const isSpeaking = this.getBooleanProperty(voiceState, 'isSpeaking'); + + if (isConnected !== null) + node.isVoiceConnected = isConnected; + + if (isConnected === false) + node.streams.audio = 0; + + if (isMuted !== null) + node.isMuted = isMuted; + + if (isDeafened !== null) + node.isDeafened = isDeafened; + + if (isSpeaking !== null) + node.isSpeaking = isSpeaking; + } + + private isLocalIdentity(state: DebugNetworkBuildState, identity: string): boolean { + return state.localIds.has(identity); + } + + private shouldIncludeNetworkEdge(edge: MutableDebugNetworkEdge, now: number): boolean { + if (edge.kind === 'membership') + return edge.isActive; + + if (edge.isActive) + return true; + + return now - edge.lastSeen <= NETWORK_RECENT_EDGE_TTL_MS; + } + + private finalizeNetworkNode( + node: MutableDebugNetworkNode, + state: DebugNetworkBuildState, + now: number, + hasActiveEdge: boolean + ): DebugNetworkNode { + if (node.kind === 'remote-client' && node.identity) + this.applyLivePeerMetricsToNetworkNode(node, node.identity, now); + + const isTyping = node.typingExpiresAt !== null && node.typingExpiresAt > now; + const user = node.identity ? state.userLookup.get(node.identity) : null; + const userId = node.userId || user?.id || null; + const label = node.kind === 'local-client' + ? (state.currentUser?.displayName || state.currentUser?.username || node.label || 'You') + : (user?.displayName || user?.username || node.label); + const secondaryLabel = node.kind === 'local-client' + ? (state.currentUser?.username ? `@${state.currentUser.username}` : 'You') + : (user?.username ? `@${user.username}` : node.secondaryLabel); + const title = node.kind === 'local-client' + ? 'Current client' + : node.title; + const streams = { + audio: node.streams.audio > 0 ? node.streams.audio : (node.isVoiceConnected ? 1 : 0), + video: node.streams.video > 0 ? node.streams.video : (node.isStreaming ? 1 : 0) + }; + const downloads = this.getFreshDownloadStats(node, now); + const statuses: string[] = []; + + if (node.kind === 'local-client' || node.kind === 'remote-client') { + if (isTyping) + statuses.push('typing'); + + if (node.isSpeaking) + statuses.push('speaking'); + + if (node.isVoiceConnected) + statuses.push(node.isMuted ? 'mic muted' : 'mic on'); + + if (node.isDeafened) + statuses.push('deafened'); + + if (node.isStreaming) + statuses.push('streaming'); + + if (node.pingMs !== null) + statuses.push(`${node.pingMs} ms`); + + if (streams.audio > 0) + statuses.push(this.buildStreamStatus(streams.audio, 'voice stream')); + + if (streams.video > 0) + statuses.push(this.buildStreamStatus(streams.video, 'video stream')); + + if (node.connectionDrops > 0) + statuses.push(this.buildStreamStatus(node.connectionDrops, 'drop')); + } else if (node.kind === 'signaling-server') { + statuses.push(hasActiveEdge ? 'connected' : 'idle'); + } else if (hasActiveEdge) { + statuses.push('active'); + } + + return { + id: node.id, + identity: node.identity, + userId, + kind: node.kind, + label, + secondaryLabel, + title, + statuses, + isActive: node.kind === 'local-client' || hasActiveEdge || node.isActive, + isVoiceConnected: node.isVoiceConnected, + isTyping, + isSpeaking: node.isSpeaking, + isMuted: node.isMuted, + isDeafened: node.isDeafened, + isStreaming: node.isStreaming, + connectionDrops: node.connectionDrops, + downloads, + handshake: { ...node.handshake }, + pingMs: node.pingMs, + streams, + textMessages: { ...node.textMessages }, + lastSeen: node.lastSeen + }; + } + + private applyLivePeerMetricsToNetworkNode( + node: MutableDebugNetworkNode, + identity: string, + now: number + ): void { + const metrics = getDebugNetworkMetricSnapshot(identity); + + if (!metrics) + return; + + if (metrics.pingMs !== null) + node.pingMs = metrics.pingMs; + + node.connectionDrops = Math.max(node.connectionDrops, metrics.connectionDrops); + node.handshake.answersReceived = Math.max(node.handshake.answersReceived, metrics.handshake.answersReceived); + node.handshake.answersSent = Math.max(node.handshake.answersSent, metrics.handshake.answersSent); + node.handshake.iceReceived = Math.max(node.handshake.iceReceived, metrics.handshake.iceReceived); + node.handshake.iceSent = Math.max(node.handshake.iceSent, metrics.handshake.iceSent); + node.handshake.offersReceived = Math.max(node.handshake.offersReceived, metrics.handshake.offersReceived); + node.handshake.offersSent = Math.max(node.handshake.offersSent, metrics.handshake.offersSent); + node.streams.audio = Math.max(node.streams.audio, metrics.streams.audio); + node.streams.video = Math.max(node.streams.video, metrics.streams.video); + node.textMessages.received = Math.max(node.textMessages.received, metrics.textMessages.received); + node.textMessages.sent = Math.max(node.textMessages.sent, metrics.textMessages.sent); + + if (metrics.streams.audio > 0) + node.isVoiceConnected = true; + + if (metrics.streams.video > 0) + node.isStreaming = true; + + if (metrics.downloads.audioMbps !== null) + node.downloads.audioMbps = metrics.downloads.audioMbps; + + if (metrics.downloads.videoMbps !== null) + node.downloads.videoMbps = metrics.downloads.videoMbps; + + if (metrics.downloads.fileMbps !== null) + node.downloads.fileMbps = metrics.downloads.fileMbps; + + if (metrics.downloads.updatedAt !== null) + node.downloadsUpdatedAt = Math.max(node.downloadsUpdatedAt ?? 0, metrics.downloads.updatedAt); + + node.lastSeen = Math.max(node.lastSeen, now); + } + + private getFreshDownloadStats(node: MutableDebugNetworkNode, now: number): DebugNetworkDownloadStats { + const hasFreshRtcStats = node.downloadsUpdatedAt !== null && now - node.downloadsUpdatedAt <= NETWORK_DOWNLOAD_STAT_TTL_MS; + const fileDownloadMbps = this.calculateFileDownloadMbps(node.fileTransferSamples, now) ?? node.downloads.fileMbps; + + return { + audioMbps: hasFreshRtcStats ? node.downloads.audioMbps : null, + fileMbps: fileDownloadMbps, + videoMbps: hasFreshRtcStats ? node.downloads.videoMbps : null + }; + } + + private calculateFileDownloadMbps(samples: DebugNetworkTransferSample[], now: number): number | null { + const recentSamples = samples.filter( + (sample) => now - sample.timestamp <= NETWORK_FILE_TRANSFER_RATE_WINDOW_MS + ); + + if (recentSamples.length === 0) + return null; + + const totalBytes = recentSamples.reduce((sum, sample) => sum + sample.bytes, 0); + const earliestTimestamp = recentSamples[0]?.timestamp ?? now; + const durationMs = Math.max(1_000, now - earliestTimestamp); + + return this.sanitizeRate(totalBytes * 8 / durationMs / 1000); + } + + private buildStreamStatus(count: number, label: string): string { + return `${count} ${label}${count === 1 ? '' : 's'}`; + } + + private finalizeNetworkEdge( + edge: MutableDebugNetworkEdge, + nodeLookup: Map + ): DebugNetworkEdge | null { + const sourceNode = nodeLookup.get(edge.sourceId); + const targetNode = nodeLookup.get(edge.targetId); + + if (!sourceNode || !targetNode) + return null; + + const messageGroups = Array.from(edge.messageGroups.values()) + .sort((groupA, groupB) => { + if (groupA.lastSeen !== groupB.lastSeen) + return groupB.lastSeen - groupA.lastSeen; + + return groupB.count - groupA.count; + }) + .slice(0, NETWORK_MESSAGE_GROUP_LIMIT) + .map((group) => ({ + id: group.id, + scope: group.scope, + direction: group.direction, + type: group.type, + count: group.count, + lastSeen: group.lastSeen + })); + + return { + id: edge.id, + kind: edge.kind, + sourceId: edge.sourceId, + targetId: edge.targetId, + sourceLabel: sourceNode.label, + targetLabel: targetNode.label, + label: this.buildNetworkEdgeLabel(edge), + stateLabel: edge.stateLabel || (edge.isActive ? 'active' : 'idle'), + isActive: edge.isActive, + pingMs: edge.pingMs, + lastSeen: edge.lastSeen, + messageTotal: edge.messageTotal, + messageGroups + }; + } + + private buildNetworkEdgeLabel(edge: MutableDebugNetworkEdge): string { + if (edge.kind === 'peer') { + if (edge.pingMs !== null) + return `${edge.pingMs} ms`; + + return edge.stateLabel || (edge.isActive ? 'p2p' : 'idle'); + } + + if (edge.kind === 'signaling') + return edge.stateLabel || (edge.isActive ? 'connected' : 'idle'); + + return edge.isActive ? (edge.stateLabel || 'joined') : (edge.stateLabel || 'inactive'); + } + + private compareNetworkEdges(edgeA: MutableDebugNetworkEdge, edgeB: MutableDebugNetworkEdge): number { + if (edgeA.isActive !== edgeB.isActive) + return edgeA.isActive ? -1 : 1; + + if (edgeA.kind !== edgeB.kind) + return this.getNetworkEdgeOrder(edgeA.kind) - this.getNetworkEdgeOrder(edgeB.kind); + + return edgeB.lastSeen - edgeA.lastSeen; + } + + private getNetworkEdgeOrder(kind: DebugNetworkEdgeKind): number { + switch (kind) { + case 'signaling': + return 0; + case 'peer': + return 1; + case 'membership': + return 2; + } + } + + private compareFinalNetworkNodes(nodeA: DebugNetworkNode, nodeB: DebugNetworkNode): number { + if (nodeA.kind !== nodeB.kind) + return this.getNetworkNodeOrder(nodeA.kind) - this.getNetworkNodeOrder(nodeB.kind); + + return nodeA.label.localeCompare(nodeB.label); + } + + private getNetworkNodeOrder(kind: DebugNetworkNodeKind): number { + switch (kind) { + case 'local-client': + return 0; + case 'signaling-server': + return 1; + case 'app-server': + return 2; + case 'remote-client': + return 3; + } + } + + private getSignalingEdgeStateLabel(message: string): string { + switch (message) { + case 'Connected to signaling server': + return 'connected'; + case 'Connecting to signaling server': + case 'Attempting reconnect': + return 'connecting'; + case 'Disconnected from signaling server': + return 'disconnected'; + case 'Signaling socket error': + case 'Failed to initialize signaling socket': + return 'error'; + default: + return 'active'; + } + } + + private getSignalingHostLabel(url: string): string { + try { + return new URL(url).hostname || 'signaling'; + } catch { + return 'signaling'; + } + } + + private getSignalingSecondaryLabel(url: string): string { + try { + const parsed = new URL(url); + + return `${parsed.protocol}//${parsed.host}`; + } catch { + return url; + } + } + + private shortenIdentifier(value: string): string { + if (value.length <= 12) + return value; + + return `${value.slice(0, 6)}…${value.slice(-4)}`; + } + + private getEntryPayloadRecord(payload: unknown): Record | null { + if (Array.isArray(payload)) { + for (const value of payload) { + const record = this.asRecord(value); + + if (record) + return record; + } + + return null; + } + + return this.asRecord(payload); + } + + private getPayloadField(payload: Record | null, key: string): unknown { + if (!payload) + return undefined; + + if (key in payload) + return payload[key]; + + const nestedPreview = this.asRecord(payload['payloadPreview']); + + return nestedPreview?.[key]; + } + + private getPayloadString(payload: Record | null, key: string): string | null { + const value = this.getPayloadField(payload, key); + + return typeof value === 'string' ? value : null; + } + + private getPayloadNumber(payload: Record | null, key: string): number | null { + const value = this.getPayloadField(payload, key); + + return typeof value === 'number' ? value : null; + } + + private getPayloadBoolean(payload: Record | null, key: string): boolean | null { + const value = this.getPayloadField(payload, key); + + return typeof value === 'boolean' ? value : null; + } + + private getPayloadRecord(payload: Record | null, key: string): Record | null { + return this.asRecord(this.getPayloadField(payload, key)); + } + + private getPayloadArray(payload: Record | null, key: string): unknown[] { + const value = this.getPayloadField(payload, key); + + return Array.isArray(value) ? value : []; + } + + private getStringProperty(record: Record | null, key: string): string | null { + const value = record?.[key]; + + return typeof value === 'string' ? value : null; + } + + private getBooleanProperty(record: Record | null, key: string): boolean | null { + const value = record?.[key]; + + return typeof value === 'boolean' ? value : null; + } + + private getNumberProperty(record: Record | null, key: string): number | null { + const value = record?.[key]; + + return typeof value === 'number' ? value : null; + } + + private asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return null; + + return value as Record; + } +} diff --git a/src/app/core/services/debugging/debugging.constants.ts b/src/app/core/services/debugging/debugging.constants.ts new file mode 100644 index 0000000..f307757 --- /dev/null +++ b/src/app/core/services/debugging/debugging.constants.ts @@ -0,0 +1,52 @@ +import type { + DebugNetworkDownloadStats, + DebugNetworkHandshakeStats, + DebugNetworkTextStats, + DebugNetworkStreamStats +} from '../../models/debugging.models'; + +export const LOCAL_NETWORK_NODE_ID = 'client:local'; +export const NETWORK_TYPING_TTL_MS = 4_500; +export const NETWORK_RECENT_EDGE_TTL_MS = 30_000; +export const NETWORK_MESSAGE_GROUP_LIMIT = 12; +export const NETWORK_FILE_TRANSFER_RATE_WINDOW_MS = 6_000; +export const NETWORK_DOWNLOAD_STAT_TTL_MS = 8_000; +export const NETWORK_IGNORED_MESSAGE_TYPES = new Set([ + 'ping', + 'pong', + 'state-request', + 'voice-state-request' +]); + +export function createEmptyHandshakeStats(): DebugNetworkHandshakeStats { + return { + answersReceived: 0, + answersSent: 0, + iceReceived: 0, + iceSent: 0, + offersReceived: 0, + offersSent: 0 + }; +} + +export function createEmptyTextStats(): DebugNetworkTextStats { + return { + received: 0, + sent: 0 + }; +} + +export function createEmptyStreamStats(): DebugNetworkStreamStats { + return { + audio: 0, + video: 0 + }; +} + +export function createEmptyDownloadStats(): DebugNetworkDownloadStats { + return { + audioMbps: null, + fileMbps: null, + videoMbps: null + }; +} diff --git a/src/app/core/services/debugging/debugging.service.ts b/src/app/core/services/debugging/debugging.service.ts new file mode 100644 index 0000000..c67c073 --- /dev/null +++ b/src/app/core/services/debugging/debugging.service.ts @@ -0,0 +1,804 @@ +import { + Injectable, + computed, + inject, + signal +} from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + Event as RouterNavigationEvent, + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router +} from '@angular/router'; + +import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; +import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors'; +import { DEBUG_LOG_MAX_ENTRIES, STORAGE_KEY_DEBUGGING_SETTINGS } from '../../constants'; +import { buildDebugNetworkSnapshot } from './debugging-network-snapshot.builder'; +import type { + ConsoleMethod, + ConsoleMethodName, + DebugLogEntry, + DebugLogLevel, + DebugNetworkSnapshot, + DebuggingSettingsState, + PendingDebugEntry +} from '../../models/debugging.models'; + +@Injectable({ providedIn: 'root' }) +export class DebuggingService { + readonly enabled = signal(false); + readonly entries = signal([]); + readonly isConsoleOpen = signal(false); + readonly networkSnapshot = computed(() => + buildDebugNetworkSnapshot( + this.entries(), + this.currentUser() ?? null, + this.allUsers() ?? [], + this.currentRoom() ?? null + ) + ); + + private readonly router = inject(Router); + private readonly store = inject(Store); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + private readonly allUsers = this.store.selectSignal(selectAllUsers); + private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); + private readonly timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + hour12: false + }); + private readonly dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + hour12: false + }); + private readonly originalConsoleMethods: Record = { + debug: console.debug.bind(console), + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console) + }; + + private initialized = false; + private nextEntryId = 1; + private pendingEntries: PendingDebugEntry[] = []; + private flushQueued = false; + + constructor() { + this.loadSettings(); + this.init(); + } + + init(): void { + if (this.initialized) + return; + + this.initialized = true; + this.patchConsole(); + this.trackRouterEvents(); + this.trackGlobalEvents(); + } + + setEnabled(enabled: boolean): void { + if (enabled === this.enabled()) + return; + + if (enabled) { + this.enabled.set(true); + this.persistSettings(); + this.info('debugging', 'Debugging service enabled'); + return; + } + + this.info('debugging', 'Debugging service disabled'); + this.enabled.set(false); + this.isConsoleOpen.set(false); + this.persistSettings(); + } + + toggleConsole(): void { + if (!this.enabled()) + return; + + this.isConsoleOpen.update((open) => !open); + } + + openConsole(): void { + if (!this.enabled()) + return; + + this.isConsoleOpen.set(true); + } + + closeConsole(): void { + this.isConsoleOpen.set(false); + } + + clear(): void { + this.pendingEntries = []; + this.entries.set([]); + } + + recordEvent(source: string, message: string, payload?: unknown): void { + if (!this.enabled()) + return; + + this.pushEntry({ level: 'event', + source, + message, + payload }); + } + + info(source: string, message: string, payload?: unknown): void { + if (!this.enabled()) + return; + + this.pushEntry({ level: 'info', + source, + message, + payload }); + } + + warn(source: string, message: string, payload?: unknown): void { + if (!this.enabled()) + return; + + this.pushEntry({ level: 'warn', + source, + message, + payload }); + } + + error(source: string, message: string, payload?: unknown): void { + if (!this.enabled()) + return; + + this.pushEntry({ level: 'error', + source, + message, + payload }); + } + + private loadSettings(): void { + try { + const raw = localStorage.getItem(STORAGE_KEY_DEBUGGING_SETTINGS); + + if (!raw) + return; + + const parsed = JSON.parse(raw) as DebuggingSettingsState; + + this.enabled.set(parsed.enabled === true); + } catch {} + } + + private persistSettings(): void { + try { + localStorage.setItem( + STORAGE_KEY_DEBUGGING_SETTINGS, + JSON.stringify({ enabled: this.enabled() }) + ); + } catch {} + } + + private patchConsole(): void { + this.patchConsoleMethod('debug', 'debug'); + this.patchConsoleMethod('log', 'info'); + this.patchConsoleMethod('info', 'info'); + this.patchConsoleMethod('warn', 'warn'); + this.patchConsoleMethod('error', 'error'); + } + + private patchConsoleMethod(methodName: ConsoleMethodName, level: DebugLogLevel): void { + const originalMethod = this.originalConsoleMethods[methodName]; + const consoleRef = console as unknown as Record; + + consoleRef[methodName] = (...args: unknown[]) => { + originalMethod(...args); + this.captureConsoleMessage(level, args); + }; + } + + private captureConsoleMessage(level: DebugLogLevel, args: readonly unknown[]): void { + if (!this.enabled()) + return; + + const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ') + .trim() || '(empty console call)'; + const consoleMetadata = this.extractConsoleMetadata(rawMessage); + const payload = this.extractConsolePayload(args); + const payloadText = payload === undefined + ? null + : this.stringifyPayload(payload); + + this.pushEntry({ level, + source: consoleMetadata.source, + message: consoleMetadata.message, + payload, + payloadText }); + } + + private extractConsoleMetadata(rawMessage: string): { source: string; message: string } { + const labels: string[] = []; + + let remainder = rawMessage.trim(); + + while (remainder.startsWith('[')) { + const closingIndex = remainder.indexOf(']'); + + if (closingIndex <= 1) + break; + + labels.push(remainder.slice(1, closingIndex)); + remainder = remainder.slice(closingIndex + 1).trim(); + } + + if (labels.length === 0) { + return { + source: 'console', + message: rawMessage + }; + } + + const normalizedLabels = labels.map((label) => this.normalizeConsoleLabel(label)); + + if (normalizedLabels[0] === 'webrtc') { + return { + source: normalizedLabels[1] ? `webrtc:${normalizedLabels[1]}` : 'webrtc', + message: remainder || rawMessage + }; + } + + return { + source: normalizedLabels[0] || 'console', + message: remainder || rawMessage + }; + } + + private normalizeConsoleLabel(label: string): string { + return label.trim().toLowerCase() + .replace(/\s+/g, '-'); + } + + private extractConsolePayload(args: readonly unknown[]): unknown { + const structuredArgs = args.filter((arg) => typeof arg !== 'string'); + + if (structuredArgs.length === 0) + return undefined; + + if (structuredArgs.length === 1) + return structuredArgs[0]; + + return structuredArgs; + } + + private trackRouterEvents(): void { + this.router.events.subscribe((event) => { + if (!this.enabled()) + return; + + this.captureRouterEvent(event); + }); + } + + private captureRouterEvent(event: RouterNavigationEvent): void { + if (event instanceof NavigationStart) { + this.pushEntry({ level: 'event', + source: 'router', + message: `Navigation started: ${event.url}`, + payload: { + id: event.id, + url: event.url, + navigationTrigger: event.navigationTrigger, + restoredState: event.restoredState ?? null + } + }); + + return; + } + + if (event instanceof NavigationEnd) { + this.pushEntry({ level: 'event', + source: 'router', + message: `Navigation completed: ${event.urlAfterRedirects}`, + payload: { + id: event.id, + url: event.url, + urlAfterRedirects: event.urlAfterRedirects + } + }); + + return; + } + + if (event instanceof NavigationCancel) { + this.pushEntry({ level: 'warn', + source: 'router', + message: `Navigation cancelled: ${event.url}`, + payload: { + id: event.id, + url: event.url, + reason: event.reason, + code: event.code + } + }); + + return; + } + + if (event instanceof NavigationError) { + this.pushEntry({ level: 'error', + source: 'router', + message: `Navigation failed: ${event.url}`, + payload: { + id: event.id, + url: event.url, + error: event.error + } + }); + } + } + + private trackGlobalEvents(): void { + window.addEventListener('error', (event) => { + if (!this.enabled()) + return; + + this.pushEntry({ level: 'error', + source: 'window', + message: event.message || 'Unhandled runtime error', + payload: { + filename: event.filename || null, + line: event.lineno || null, + column: event.colno || null, + error: event.error + } + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + if (!this.enabled()) + return; + + this.pushEntry({ level: 'error', + source: 'window', + message: 'Unhandled promise rejection', + payload: event.reason + }); + }); + + window.addEventListener('online', () => { + this.recordEvent('window', 'Browser connection restored'); + }); + + window.addEventListener('offline', () => { + this.warn('window', 'Browser went offline'); + }); + + document.addEventListener('visibilitychange', () => { + this.recordEvent('document', `Visibility changed: ${document.visibilityState}`, { + visibilityState: document.visibilityState + }); + }); + + document.addEventListener('click', (event) => this.captureDocumentEvent(event), true); + document.addEventListener('change', (event) => this.captureDocumentEvent(event), true); + document.addEventListener('submit', (event) => this.captureDocumentEvent(event), true); + document.addEventListener('keydown', (event) => this.captureDocumentEvent(event), true); + } + + private captureDocumentEvent(event: Event): void { + if (!this.enabled()) + return; + + if (this.isIgnoredTarget(event.target)) + return; + + if (event instanceof KeyboardEvent && !this.shouldTrackKeyboardEvent(event)) + return; + + const payload: Record = { + type: event.type, + target: this.describeElement(event.target) + }; + const controlMetadata = this.describeControl(event.target); + + if (controlMetadata) + payload['control'] = controlMetadata; + + if (event instanceof KeyboardEvent) { + payload['key'] = event.key; + payload['code'] = event.code; + payload['altKey'] = event.altKey; + payload['ctrlKey'] = event.ctrlKey; + payload['metaKey'] = event.metaKey; + payload['shiftKey'] = event.shiftKey; + } + + this.pushEntry({ level: 'event', + source: 'ui', + message: this.describeInteraction(event), + payload }); + } + + private shouldTrackKeyboardEvent(event: KeyboardEvent): boolean { + if (event.ctrlKey || event.metaKey || event.altKey) + return true; + + return event.key === 'Enter' || event.key === 'Escape' || event.key === 'Tab'; + } + + private isIgnoredTarget(target: EventTarget | null): boolean { + const element = this.asElement(target); + + if (!element) + return false; + + return !!element.closest('[data-debug-console-root="true"]'); + } + + private describeInteraction(event: Event): string { + const element = this.asElement(event.target); + const targetLabel = element ? this.buildElementLabel(element) : 'unknown target'; + + if (event instanceof KeyboardEvent) + return `keydown (${event.key}) on ${targetLabel}`; + + return `${event.type} on ${targetLabel}`; + } + + private buildElementLabel(element: Element): string { + const tagName = element.tagName.toLowerCase(); + const parts = [tagName]; + const id = element.id.trim(); + const type = element.getAttribute('type'); + const name = element.getAttribute('name'); + const ariaLabel = element.getAttribute('aria-label'); + const placeholder = element.getAttribute('placeholder'); + const interactiveText = this.getInteractiveText(element); + + if (id) + parts.push(`#${id}`); + + if (type) + parts.push(`type=${type}`); + + if (name) { + parts.push(`name=${name}`); + } else if (ariaLabel) { + parts.push(`label=${ariaLabel}`); + } else if (placeholder) { + parts.push(`placeholder=${placeholder}`); + } else if (interactiveText) { + parts.push(`text=${interactiveText}`); + } + + return parts.join(' '); + } + + private getInteractiveText(element: Element): string | null { + const tagName = element.tagName.toLowerCase(); + + if (tagName !== 'button' && tagName !== 'a') + return null; + + const text = element.textContent?.trim().replace(/\s+/g, ' ') || ''; + + if (!text) + return null; + + return text.slice(0, 60); + } + + private describeElement(target: EventTarget | null): Record | null { + const element = this.asElement(target); + + if (!element) + return null; + + const payload: Record = { + tag: element.tagName.toLowerCase() + }; + const id = element.id.trim(); + const type = element.getAttribute('type'); + const role = element.getAttribute('role'); + const name = element.getAttribute('name'); + const ariaLabel = element.getAttribute('aria-label'); + const placeholder = element.getAttribute('placeholder'); + const interactiveText = this.getInteractiveText(element); + + if (id) + payload['id'] = id; + + if (type) + payload['type'] = type; + + if (role) + payload['role'] = role; + + if (name) + payload['name'] = name; + + if (ariaLabel) + payload['ariaLabel'] = ariaLabel; + + if (placeholder) + payload['placeholder'] = placeholder; + + if (interactiveText) + payload['text'] = interactiveText; + + return payload; + } + + private describeControl(target: EventTarget | null): Record | null { + if (target instanceof HTMLInputElement) { + if (target.type === 'checkbox' || target.type === 'radio') { + return { + type: target.type, + checked: target.checked + }; + } + + if (target.type === 'file') { + return { + type: target.type, + filesCount: target.files?.length ?? 0 + }; + } + + return { + type: target.type || 'text', + hasValue: target.value.length > 0, + valueLength: target.value.length, + masked: target.type === 'password' + }; + } + + if (target instanceof HTMLTextAreaElement) { + return { + type: 'textarea', + hasValue: target.value.length > 0, + valueLength: target.value.length + }; + } + + if (target instanceof HTMLSelectElement) { + return { + type: 'select', + hasValue: target.value.length > 0, + selectedIndex: target.selectedIndex + }; + } + + return null; + } + + private asElement(target: EventTarget | null): Element | null { + if (target instanceof Element) + return target; + + if (target instanceof Node) + return target.parentElement; + + return null; + } + + private pushEntry(entry: PendingDebugEntry): void { + this.pendingEntries.push(entry); + this.schedulePendingEntryFlush(); + } + + private schedulePendingEntryFlush(): void { + if (this.flushQueued) + return; + + this.flushQueued = true; + + Promise.resolve().then(() => { + this.flushQueued = false; + this.flushPendingEntries(); + + if (this.pendingEntries.length > 0) + this.schedulePendingEntryFlush(); + }); + } + + private flushPendingEntries(): void { + if (this.pendingEntries.length === 0) + return; + + const pendingEntries = this.pendingEntries; + + this.pendingEntries = []; + + let nextEntries = this.entries(); + + for (const entry of pendingEntries) { + const nextEntry = this.createEntry(entry); + const lastEntry = nextEntries[nextEntries.length - 1]; + + if (lastEntry && this.canCollapseEntries(lastEntry, nextEntry)) { + nextEntries = [ + ...nextEntries.slice(0, -1), + { + ...lastEntry, + count: lastEntry.count + 1, + timestamp: nextEntry.timestamp, + timeLabel: nextEntry.timeLabel, + dateTimeLabel: nextEntry.dateTimeLabel + } + ]; + + continue; + } + + nextEntries = [...nextEntries, nextEntry].slice(-DEBUG_LOG_MAX_ENTRIES); + } + + this.entries.set(nextEntries); + } + + private createEntry(entry: PendingDebugEntry): DebugLogEntry { + const timestamp = entry.timestamp ?? Date.now(); + const hasPayload = entry.payload !== undefined; + const normalizedPayload = hasPayload + ? this.normalizePayload(entry.payload) + : null; + const payloadText = entry.payloadText === undefined + ? (hasPayload ? this.stringifyNormalizedPayload(normalizedPayload) : null) + : entry.payloadText; + const nextEntry: DebugLogEntry = { + id: this.nextEntryId++, + timestamp, + timeLabel: this.timeFormatter.format(timestamp), + dateTimeLabel: this.dateTimeFormatter.format(timestamp), + level: entry.level, + source: entry.source, + message: entry.message, + payload: normalizedPayload, + payloadText, + count: 1 + }; + + return nextEntry; + } + + private canCollapseEntries(previousEntry: DebugLogEntry, nextEntry: DebugLogEntry): boolean { + const withinWindow = nextEntry.timestamp - previousEntry.timestamp <= 1000; + + return withinWindow + && previousEntry.level === nextEntry.level + && previousEntry.source === nextEntry.source + && previousEntry.message === nextEntry.message + && previousEntry.payloadText === nextEntry.payloadText; + } + + private stringifyPreview(value: unknown): string { + if (typeof value === 'string') + return value; + + if (value instanceof Error) + return `${value.name}: ${value.message}`; + + const payloadText = this.stringifyPayload(value); + + if (!payloadText) + return String(value); + + return payloadText.replace(/\s+/g, ' ').slice(0, 200); + } + + private stringifyPayload(value: unknown): string | null { + if (value === undefined) + return null; + + return this.stringifyNormalizedPayload(this.normalizePayload(value)); + } + + private normalizePayload(value: unknown): unknown { + try { + return this.normalizeValue(value, 0, new WeakSet()); + } catch { + try { + return String(value); + } catch { + return '[unserializable value]'; + } + } + } + + private stringifyNormalizedPayload(value: unknown): string | null { + try { + const json = JSON.stringify(value, null, 2); + + if (typeof json === 'string') + return json; + + return value === undefined ? null : String(value); + } catch { + try { + return String(value); + } catch { + return '[unserializable value]'; + } + } + } + + private normalizeValue(value: unknown, depth: number, seen: WeakSet): unknown { + if ( + value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + ) { + return value; + } + + if (typeof value === 'bigint') + return value.toString(); + + if (typeof value === 'function') + return `[Function ${value.name || 'anonymous'}]`; + + if (value instanceof Date) + return value.toISOString(); + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack + }; + } + + if (value instanceof Event) { + return { + type: value.type, + target: this.describeElement(value.target), + currentTarget: this.describeElement(value.currentTarget), + timeStamp: value.timeStamp + }; + } + + if (value instanceof HTMLElement) + return this.describeElement(value); + + if (typeof value !== 'object') + return String(value); + + if (seen.has(value)) + return '[Circular]'; + + seen.add(value); + + if (depth >= 3) + return `[${value.constructor?.name || 'Object'}]`; + + if (Array.isArray(value)) { + return value.slice(0, 20).map((item) => this.normalizeValue(item, depth + 1, seen)); + } + + const normalizedObject: Record = {}; + const objectValue = value as Record; + + for (const key of Object.keys(objectValue).slice(0, 20)) { + normalizedObject[key] = this.normalizeValue(objectValue[key], depth + 1, seen); + } + + return normalizedObject; + } +} diff --git a/src/app/core/services/debugging/index.ts b/src/app/core/services/debugging/index.ts new file mode 100644 index 0000000..5fc554a --- /dev/null +++ b/src/app/core/services/debugging/index.ts @@ -0,0 +1,2 @@ +export * from '../../models/debugging.models'; +export * from './debugging.service'; diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 3595898..4a2260c 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -3,6 +3,8 @@ export * from './platform.service'; export * from './browser-database.service'; export * from './electron-database.service'; export * from './database.service'; +export * from '../models/debugging.models'; +export * from './debugging/debugging.service'; export * from './webrtc.service'; export * from './server-directory.service'; export * from './klipy.service'; diff --git a/src/app/core/services/settings-modal.service.ts b/src/app/core/services/settings-modal.service.ts index 9921726..d33b228 100644 --- a/src/app/core/services/settings-modal.service.ts +++ b/src/app/core/services/settings-modal.service.ts @@ -1,5 +1,5 @@ import { Injectable, signal } from '@angular/core'; -export type SettingsPage = 'network' | 'voice' | 'server' | 'members' | 'bans' | 'permissions'; +export type SettingsPage = 'network' | 'voice' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions'; @Injectable({ providedIn: 'root' }) export class SettingsModalService { diff --git a/src/app/core/services/voice-activity.service.ts b/src/app/core/services/voice-activity.service.ts index 8a8cf31..7c836eb 100644 --- a/src/app/core/services/voice-activity.service.ts +++ b/src/app/core/services/voice-activity.service.ts @@ -25,6 +25,7 @@ import { Signal } from '@angular/core'; import { Subscription } from 'rxjs'; +import { DebuggingService } from './debugging.service'; import { WebRTCService } from './webrtc.service'; /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */ @@ -46,6 +47,7 @@ interface TrackedStream { @Injectable({ providedIn: 'root' }) export class VoiceActivityService implements OnDestroy { private readonly webrtc = inject(WebRTCService); + private readonly debugging = inject(DebuggingService); private readonly tracked = new Map(); private animFrameId: number | null = null; @@ -134,6 +136,10 @@ export class VoiceActivityService implements OnDestroy { if (!entry) return; + if (entry.speakingSignal()) { + this.reportSpeakingState(id, false, 0); + } + this.disposeEntry(entry); this.tracked.delete(id); this.publishSpeakingMap(); @@ -159,7 +165,7 @@ export class VoiceActivityService implements OnDestroy { private poll = (): void => { let mapDirty = false; - this.tracked.forEach((entry) => { + this.tracked.forEach((entry, id) => { const { analyser, dataArray, volumeSignal, speakingSignal } = entry; analyser.getByteTimeDomainData(dataArray); @@ -183,6 +189,7 @@ export class VoiceActivityService implements OnDestroy { if (!wasSpeaking) { speakingSignal.set(true); + this.reportSpeakingState(id, true, rms); mapDirty = true; } } else { @@ -190,6 +197,7 @@ export class VoiceActivityService implements OnDestroy { if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) { speakingSignal.set(false); + this.reportSpeakingState(id, false, rms); mapDirty = true; } } @@ -211,6 +219,14 @@ export class VoiceActivityService implements OnDestroy { this._speakingMap.set(map); } + private reportSpeakingState(peerId: string, isSpeaking: boolean, volume: number): void { + this.debugging.recordEvent('webrtc:voice-activity', 'Speaking state changed', { + peerId, + isSpeaking, + volume: Number(volume.toFixed(3)) + }); + } + private disposeEntry(entry: TrackedStream): void { try { entry.source.disconnect(); } catch { /* already disconnected */ } diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index c26f252..5c40755 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -23,6 +23,7 @@ import { Observable, Subject } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { SignalingMessage, ChatEvent } from '../models/index'; import { TimeSyncService } from './time-sync.service'; +import { DebuggingService } from './debugging.service'; import { SignalingManager, @@ -55,8 +56,9 @@ import { }) export class WebRTCService implements OnDestroy { private readonly timeSync = inject(TimeSyncService); + private readonly debugging = inject(DebuggingService); - private readonly logger = new WebRTCLogger(/* debugEnabled */ true); + private readonly logger = new WebRTCLogger(() => this.debugging.enabled()); private lastIdentifyCredentials: IdentifyCredentials | null = null; private lastJoinedServer: JoinedServerInfo | null = null; diff --git a/src/app/core/services/webrtc/media.manager.ts b/src/app/core/services/webrtc/media.manager.ts index 9984374..55ff5d6 100644 --- a/src/app/core/services/webrtc/media.manager.ts +++ b/src/app/core/services/webrtc/media.manager.ts @@ -631,10 +631,14 @@ export class MediaManager { try { this.inputGainSourceNode?.disconnect(); this.inputGainNode?.disconnect(); - } catch { /* already disconnected */ } + } catch (error) { + this.logger.warn('Input gain nodes were already disconnected during teardown', error as any); + } if (this.inputGainCtx && this.inputGainCtx.state !== 'closed') { - this.inputGainCtx.close().catch(() => {}); + this.inputGainCtx.close().catch((error) => { + this.logger.warn('Failed to close input gain audio context', error as any); + }); } this.inputGainCtx = null; diff --git a/src/app/core/services/webrtc/noise-reduction.manager.ts b/src/app/core/services/webrtc/noise-reduction.manager.ts index d11596b..0b71eb9 100644 --- a/src/app/core/services/webrtc/noise-reduction.manager.ts +++ b/src/app/core/services/webrtc/noise-reduction.manager.ts @@ -175,20 +175,20 @@ export class NoiseReductionManager { private teardownGraph(): void { try { this.sourceNode?.disconnect(); - } catch { - /* already disconnected */ + } catch (error) { + this.logger.warn('Noise reduction source node already disconnected', error); } try { this.workletNode?.disconnect(); - } catch { - /* already disconnected */ + } catch (error) { + this.logger.warn('Noise reduction worklet node already disconnected', error); } try { this.destinationNode?.disconnect(); - } catch { - /* already disconnected */ + } catch (error) { + this.logger.warn('Noise reduction destination node already disconnected', error); } this.sourceNode = null; @@ -197,8 +197,8 @@ export class NoiseReductionManager { // Close the context to free hardware resources if (this.audioContext && this.audioContext.state !== 'closed') { - this.audioContext.close().catch(() => { - /* best-effort */ + this.audioContext.close().catch((error) => { + this.logger.warn('Failed to close RNNoise audio context', error); }); } diff --git a/src/app/core/services/webrtc/peer-connection-manager/connection/create-peer-connection.ts b/src/app/core/services/webrtc/peer-connection-manager/connection/create-peer-connection.ts index 32d73e1..073c45f 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/connection/create-peer-connection.ts +++ b/src/app/core/services/webrtc/peer-connection-manager/connection/create-peer-connection.ts @@ -11,6 +11,7 @@ import { TRANSCEIVER_RECV_ONLY, TRANSCEIVER_SEND_RECV } from '../../webrtc.constants'; +import { recordDebugNetworkConnectionState } from '../../../debug-network-metrics.service'; import { PeerData } from '../../webrtc.types'; import { ConnectionLifecycleHandlers, PeerConnectionManagerContext } from '../shared'; @@ -52,6 +53,8 @@ export function createPeerConnection( state: connection.connectionState }); + recordDebugNetworkConnectionState(remotePeerId, connection.connectionState); + switch (connection.connectionState) { case CONNECTION_STATE_CONNECTED: handlers.clearPeerDisconnectGraceTimer(remotePeerId); diff --git a/src/app/core/services/webrtc/peer-connection-manager/connection/negotiation.ts b/src/app/core/services/webrtc/peer-connection-manager/connection/negotiation.ts index b0de18f..c84de1f 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/connection/negotiation.ts +++ b/src/app/core/services/webrtc/peer-connection-manager/connection/negotiation.ts @@ -52,7 +52,11 @@ export async function doCreateAndSendOffer( payload: { sdp: offer } }); } catch (error) { - logger.error('Failed to create offer', error); + logger.error('Failed to create offer', error, { + localDescriptionType: peerData.connection.localDescription?.type ?? null, + remotePeerId, + signalingState: peerData.connection.signalingState + }); } } @@ -150,7 +154,12 @@ export async function doHandleOffer( payload: { sdp: answer } }); } catch (error) { - logger.error('Failed to handle offer', error); + logger.error('Failed to handle offer', error, { + fromUserId, + pendingIceCandidates: peerData.pendingIceCandidates.length, + sdpLength: sdp.sdp?.length, + signalingState: peerData.connection.signalingState + }); } } @@ -185,7 +194,12 @@ export async function doHandleAnswer( }); } } catch (error) { - logger.error('Failed to handle answer', error); + logger.error('Failed to handle answer', error, { + fromUserId, + pendingIceCandidates: peerData.pendingIceCandidates.length, + sdpLength: sdp.sdp?.length, + signalingState: peerData.connection.signalingState + }); } } @@ -212,7 +226,13 @@ export async function doHandleIceCandidate( peerData.pendingIceCandidates.push(candidate); } } catch (error) { - logger.error('Failed to add ICE candidate', error); + logger.error('Failed to add ICE candidate', error, { + candidateMid: candidate.sdpMid ?? null, + candidateMLineIndex: candidate.sdpMLineIndex ?? null, + fromUserId, + hasRemoteDescription: !!peerData.connection.remoteDescription, + pendingIceCandidates: peerData.pendingIceCandidates.length + }); } } @@ -242,6 +262,9 @@ export async function doRenegotiate( payload: { sdp: offer } }); } catch (error) { - logger.error('Failed to renegotiate', error); + logger.error('Failed to renegotiate', error, { + peerId, + signalingState: peerData.connection.signalingState + }); } } diff --git a/src/app/core/services/webrtc/peer-connection-manager/messaging/data-channel.ts b/src/app/core/services/webrtc/peer-connection-manager/messaging/data-channel.ts index 7c1a4d5..92fe4ef 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/messaging/data-channel.ts +++ b/src/app/core/services/webrtc/peer-connection-manager/messaging/data-channel.ts @@ -11,6 +11,7 @@ import { P2P_TYPE_VOICE_STATE, P2P_TYPE_VOICE_STATE_REQUEST } from '../../webrtc.constants'; +import { recordDebugNetworkDataChannelPayload, recordDebugNetworkPing } from '../../../debug-network-metrics.service'; import { PeerConnectionManagerContext } from '../shared'; import { startPingInterval } from './ping'; @@ -30,33 +31,71 @@ export function setupDataChannel( const { logger } = context; channel.onopen = () => { - logger.info('Data channel open', { remotePeerId }); + logger.info('[data-channel] Data channel open', { + channelLabel: channel.label, + negotiated: channel.negotiated, + ordered: channel.ordered, + peerId: remotePeerId, + protocol: channel.protocol || null + }); + sendCurrentStatesToChannel(context, channel, remotePeerId); try { - channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); - } catch { - /* ignore */ + const stateRequest = { type: P2P_TYPE_STATE_REQUEST }; + const rawPayload = JSON.stringify(stateRequest); + + channel.send(rawPayload); + logDataChannelTraffic(context, channel, remotePeerId, 'outbound', rawPayload, stateRequest); + } catch (error) { + logger.error('[data-channel] Failed to request peer state on open', error, { + bufferedAmount: channel.bufferedAmount, + channelLabel: channel.label, + peerId: remotePeerId, + readyState: channel.readyState, + type: P2P_TYPE_STATE_REQUEST + }); } - startPingInterval(context.state, remotePeerId); + startPingInterval(context.state, logger, remotePeerId); }; channel.onclose = () => { - logger.info('Data channel closed', { remotePeerId }); + logger.info('[data-channel] Data channel closed', { + bufferedAmount: channel.bufferedAmount, + channelLabel: channel.label, + peerId: remotePeerId, + readyState: channel.readyState + }); }; channel.onerror = (error) => { - logger.error('Data channel error', error, { remotePeerId }); + logger.error('[data-channel] Data channel error', error, { + bufferedAmount: channel.bufferedAmount, + channelLabel: channel.label, + peerId: remotePeerId, + readyState: channel.readyState + }); }; channel.onmessage = (event) => { + const rawPayload = typeof event.data === 'string' + ? event.data + : String(event.data ?? ''); + try { - const message = JSON.parse(event.data) as PeerMessage; + const message = JSON.parse(rawPayload) as PeerMessage; + + logDataChannelTraffic(context, channel, remotePeerId, 'inbound', rawPayload, message); handlePeerMessage(context, remotePeerId, message); } catch (error) { - logger.error('Failed to parse peer message', error); + logger.error('[data-channel] Failed to parse peer message', error, { + bytes: measurePayloadBytes(rawPayload), + channelLabel: channel.label, + peerId: remotePeerId, + rawPreview: getRawPreview(rawPayload) + }); } }; } @@ -71,10 +110,8 @@ export function handlePeerMessage( ): void { const { logger, state } = context; - logger.info('Received P2P message', { - peerId, - type: message.type - }); + logger.info('[data-channel] Received P2P message', summarizePeerMessage(message, { peerId })); + recordDebugNetworkDataChannelPayload(peerId, message, 'inbound'); if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) { sendCurrentStatesToPeer(context, peerId); @@ -98,6 +135,8 @@ export function handlePeerMessage( state.peerLatencies.set(peerId, latencyMs); state.peerLatencyChanged$.next({ peerId, latencyMs }); + recordDebugNetworkPing(peerId, latencyMs); + logger.info('[data-channel] Peer latency updated', { latencyMs, peerId }); } state.pendingPings.delete(peerId); @@ -118,16 +157,35 @@ export function broadcastMessage( event: object ): void { const { logger, state } = context; - const data = JSON.stringify(event); + + let data = ''; + + try { + data = JSON.stringify(event); + } catch (error) { + logger.error('[data-channel] Failed to serialize broadcast payload', error, { + payloadPreview: summarizePeerMessage(event as PeerMessage) + }); + + return; + } state.activePeerConnections.forEach((peerData, peerId) => { try { if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { peerData.dataChannel.send(data); - logger.info('Sent message via P2P', { peerId }); + recordDebugNetworkDataChannelPayload(peerId, event as PeerMessage, 'outbound'); + + logDataChannelTraffic(context, peerData.dataChannel, peerId, 'outbound', data, event as PeerMessage); } } catch (error) { - logger.error('Failed to send to peer', error, { peerId }); + logger.error('[data-channel] Failed to broadcast message to peer', error, { + bufferedAmount: peerData.dataChannel?.bufferedAmount, + channelLabel: peerData.dataChannel?.label, + payloadPreview: summarizePeerMessage(event as PeerMessage), + peerId, + readyState: peerData.dataChannel?.readyState ?? null + }); } }); } @@ -149,9 +207,20 @@ export function sendToPeer( } try { - peerData.dataChannel.send(JSON.stringify(event)); + const rawPayload = JSON.stringify(event); + + peerData.dataChannel.send(rawPayload); + recordDebugNetworkDataChannelPayload(peerId, event as PeerMessage, 'outbound'); + + logDataChannelTraffic(context, peerData.dataChannel, peerId, 'outbound', rawPayload, event as PeerMessage); } catch (error) { - logger.error('Failed to send to peer', error, { peerId }); + logger.error('[data-channel] Failed to send message to peer', error, { + bufferedAmount: peerData.dataChannel.bufferedAmount, + channelLabel: peerData.dataChannel.label, + payloadPreview: summarizePeerMessage(event as PeerMessage), + peerId, + readyState: peerData.dataChannel.readyState + }); } } @@ -179,6 +248,14 @@ export async function sendToPeerBuffered( } if (channel.bufferedAmount > DATA_CHANNEL_HIGH_WATER_BYTES) { + logger.warn('[data-channel] Waiting for buffered amount to drain', { + bufferedAmount: channel.bufferedAmount, + channelLabel: channel.label, + highWaterMark: DATA_CHANNEL_HIGH_WATER_BYTES, + lowWaterMark: DATA_CHANNEL_LOW_WATER_BYTES, + peerId + }); + await new Promise((resolve) => { const handleBufferedAmountLow = () => { if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES) { @@ -193,8 +270,17 @@ export async function sendToPeerBuffered( try { channel.send(data); + recordDebugNetworkDataChannelPayload(peerId, event as PeerMessage, 'outbound'); + + logDataChannelTraffic(context, channel, peerId, 'outbound', data, event as PeerMessage); } catch (error) { - logger.error('Failed to send buffered message', error, { peerId }); + logger.error('[data-channel] Failed to send buffered message', error, { + bufferedAmount: channel.bufferedAmount, + channelLabel: channel.label, + payloadPreview: summarizePeerMessage(event as PeerMessage), + peerId, + readyState: channel.readyState + }); } } @@ -248,27 +334,35 @@ export function sendCurrentStatesToChannel( const voiceState = callbacks.getVoiceStateSnapshot(); try { - channel.send( - JSON.stringify({ - type: P2P_TYPE_VOICE_STATE, - oderId, - displayName, - voiceState - }) - ); + const voiceStatePayload = { + type: P2P_TYPE_VOICE_STATE, + oderId, + displayName, + voiceState + }; + const screenStatePayload = { + type: P2P_TYPE_SCREEN_STATE, + oderId, + displayName, + isScreenSharing: callbacks.isScreenSharingActive() + }; + const voiceStateRaw = JSON.stringify(voiceStatePayload); + const screenStateRaw = JSON.stringify(screenStatePayload); - channel.send( - JSON.stringify({ - type: P2P_TYPE_SCREEN_STATE, - oderId, - displayName, - isScreenSharing: callbacks.isScreenSharingActive() - }) - ); + channel.send(voiceStateRaw); + logDataChannelTraffic(context, channel, remotePeerId, 'outbound', voiceStateRaw, voiceStatePayload); + channel.send(screenStateRaw); + logDataChannelTraffic(context, channel, remotePeerId, 'outbound', screenStateRaw, screenStatePayload); - logger.info('Sent initial states to channel', { remotePeerId, voiceState }); + logger.info('[data-channel] Sent initial states to channel', { remotePeerId, voiceState }); } catch (error) { - logger.error('Failed to send initial states to channel', error); + logger.error('[data-channel] Failed to send initial states to channel', error, { + bufferedAmount: channel.bufferedAmount, + channelLabel: channel.label, + peerId: remotePeerId, + readyState: channel.readyState, + voiceState + }); } } @@ -294,3 +388,100 @@ export function broadcastCurrentStates(context: PeerConnectionManagerContext): v isScreenSharing: callbacks.isScreenSharingActive() }); } + +function logDataChannelTraffic( + context: PeerConnectionManagerContext, + channel: RTCDataChannel, + peerId: string, + direction: 'inbound' | 'outbound', + rawPayload: string, + payload: PeerMessage +): void { + context.logger.traffic('data-channel', direction, { + ...summarizePeerMessage(payload, { peerId }), + bufferedAmount: channel.bufferedAmount, + bytes: measurePayloadBytes(rawPayload), + channelLabel: channel.label, + readyState: channel.readyState + }); +} + +function summarizePeerMessage(payload: PeerMessage, base?: Record): Record { + const summary: Record = { + ...base, + keys: Object.keys(payload).slice(0, 10), + type: typeof payload.type === 'string' ? payload.type : 'unknown' + }; + const payloadMessage = asObject(payload['message']); + const voiceState = asObject(payload['voiceState']); + + if (typeof payload['oderId'] === 'string') + summary['oderId'] = payload['oderId']; + + if (typeof payload['displayName'] === 'string') + summary['displayName'] = payload['displayName']; + + if (typeof payload['roomId'] === 'string') + summary['roomId'] = payload['roomId']; + + if (typeof payload['serverId'] === 'string') + summary['serverId'] = payload['serverId']; + + if (typeof payload['messageId'] === 'string') + summary['messageId'] = payload['messageId']; + + if (typeof payload['isScreenSharing'] === 'boolean') + summary['isScreenSharing'] = payload['isScreenSharing']; + + if (typeof payload['content'] === 'string') + summary['contentLength'] = payload['content'].length; + + if (Array.isArray(payload['ids'])) + summary['idsCount'] = payload['ids'].length; + + if (Array.isArray(payload['items'])) + summary['itemsCount'] = payload['items'].length; + + if (Array.isArray(payload['messages'])) + summary['messagesCount'] = payload['messages'].length; + + if (payloadMessage) { + if (typeof payloadMessage['id'] === 'string') + summary['messageId'] = payloadMessage['id']; + + if (typeof payloadMessage['roomId'] === 'string') + summary['roomId'] = payloadMessage['roomId']; + + if (typeof payloadMessage['content'] === 'string') + summary['contentLength'] = payloadMessage['content'].length; + } + + if (voiceState) { + summary['voiceState'] = { + isConnected: voiceState['isConnected'] === true, + isMuted: voiceState['isMuted'] === true, + isDeafened: voiceState['isDeafened'] === true, + isSpeaking: voiceState['isSpeaking'] === true, + roomId: typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined, + serverId: typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined, + volume: typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined + }; + } + + return summary; +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return null; + + return value as Record; +} + +function measurePayloadBytes(payload: string): number { + return new TextEncoder().encode(payload).length; +} + +function getRawPreview(payload: string): string { + return payload.replace(/\s+/g, ' ').slice(0, 240); +} diff --git a/src/app/core/services/webrtc/peer-connection-manager/messaging/ping.ts b/src/app/core/services/webrtc/peer-connection-manager/messaging/ping.ts index c4153f1..a5d9a9c 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/messaging/ping.ts +++ b/src/app/core/services/webrtc/peer-connection-manager/messaging/ping.ts @@ -3,14 +3,15 @@ import { P2P_TYPE_PING, PEER_PING_INTERVAL_MS } from '../../webrtc.constants'; +import { WebRTCLogger } from '../../webrtc-logger'; import { PeerConnectionManagerState } from '../shared'; /** Start periodic pings to a peer to measure round-trip latency. */ -export function startPingInterval(state: PeerConnectionManagerState, peerId: string): void { +export function startPingInterval(state: PeerConnectionManagerState, logger: WebRTCLogger, peerId: string): void { stopPingInterval(state, peerId); - sendPing(state, peerId); + sendPing(state, logger, peerId); - const timer = setInterval(() => sendPing(state, peerId), PEER_PING_INTERVAL_MS); + const timer = setInterval(() => sendPing(state, logger, peerId), PEER_PING_INTERVAL_MS); state.peerPingTimers.set(peerId, timer); } @@ -32,7 +33,7 @@ export function clearAllPingTimers(state: PeerConnectionManagerState): void { } /** Send a single ping to a peer. */ -export function sendPing(state: PeerConnectionManagerState, peerId: string): void { +export function sendPing(state: PeerConnectionManagerState, logger: WebRTCLogger, peerId: string): void { const peerData = state.activePeerConnections.get(peerId); if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) @@ -43,13 +44,28 @@ export function sendPing(state: PeerConnectionManagerState, peerId: string): voi state.pendingPings.set(peerId, timestamp); try { - peerData.dataChannel.send( - JSON.stringify({ - type: P2P_TYPE_PING, - ts: timestamp - }) - ); - } catch { - /* ignore */ + const payload = JSON.stringify({ + type: P2P_TYPE_PING, + ts: timestamp + }); + + peerData.dataChannel.send(payload); + logger.traffic('data-channel', 'outbound', { + bufferedAmount: peerData.dataChannel.bufferedAmount, + bytes: new TextEncoder().encode(payload).length, + channelLabel: peerData.dataChannel.label, + peerId, + readyState: peerData.dataChannel.readyState, + type: P2P_TYPE_PING + }); + } catch (error) { + logger.error('[data-channel] Failed to send ping', error, { + bufferedAmount: peerData.dataChannel.bufferedAmount, + channelLabel: peerData.dataChannel.label, + peerId, + readyState: peerData.dataChannel.readyState, + ts: timestamp, + type: P2P_TYPE_PING + }); } } diff --git a/src/app/core/services/webrtc/peer-connection-manager/peer-connection.manager.ts b/src/app/core/services/webrtc/peer-connection-manager/peer-connection.manager.ts index 67a2335..3ff8911 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/peer-connection.manager.ts +++ b/src/app/core/services/webrtc/peer-connection-manager/peer-connection.manager.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { ChatEvent } from '../../../models'; +import { recordDebugNetworkDownloadRates } from '../../debug-network-metrics.service'; import { WebRTCLogger } from '../webrtc-logger'; import { PeerData } from '../webrtc.types'; import { createPeerConnection as createManagedPeerConnection } from './connection/create-peer-connection'; @@ -44,12 +45,24 @@ import { RemovePeerOptions } from './shared'; +const PEER_STATS_POLL_INTERVAL_MS = 2_000; +const PEER_STATS_SAMPLE_MIN_INTERVAL_MS = 500; + +interface PeerInboundByteSnapshot { + audioBytesReceived: number; + collectedAt: number; + videoBytesReceived: number; +} + /** * Creates and manages RTCPeerConnections, data channels, * offer/answer negotiation, ICE candidates, and P2P reconnection. */ export class PeerConnectionManager { private readonly state = createPeerConnectionManagerState(); + private readonly lastInboundByteSnapshots = new Map(); + private statsPollTimer: ReturnType | null = null; + private transportStatsPollInFlight = false; /** Active peer connections keyed by remote peer ID. */ readonly activePeerConnections = this.state.activePeerConnections; @@ -74,7 +87,9 @@ export class PeerConnectionManager { constructor( private readonly logger: WebRTCLogger, private callbacks: PeerConnectionCallbacks - ) {} + ) { + this.startTransportStatsPolling(); + } /** * Replace the callback set at runtime. @@ -183,11 +198,13 @@ export class PeerConnectionManager { */ removePeer(peerId: string, options?: RemovePeerOptions): void { removeManagedPeer(this.context, peerId, options); + this.clearPeerTransportStats(peerId); } /** Close every active peer connection and clear internal state. */ closeAllPeers(): void { closeManagedPeers(this.state); + this.lastInboundByteSnapshots.clear(); } /** Cancel all pending peer reconnect timers and clear the tracker. */ @@ -207,6 +224,8 @@ export class PeerConnectionManager { /** Clean up all resources. */ destroy(): void { + this.stopTransportStatsPolling(); + this.lastInboundByteSnapshots.clear(); this.closeAllPeers(); this.peerConnected$.complete(); this.peerDisconnected$.complete(); @@ -293,4 +312,141 @@ export class PeerConnectionManager { private addToConnectedPeers(peerId: string): void { addToConnectedPeers(this.state, peerId); } + + private startTransportStatsPolling(): void { + if (this.statsPollTimer) + return; + + this.statsPollTimer = setInterval(() => { + void this.pollTransportStats(); + }, PEER_STATS_POLL_INTERVAL_MS); + } + + private stopTransportStatsPolling(): void { + if (!this.statsPollTimer) + return; + + clearInterval(this.statsPollTimer); + this.statsPollTimer = null; + } + + private clearPeerTransportStats(peerId: string): void { + this.lastInboundByteSnapshots.delete(peerId); + } + + private async pollTransportStats(): Promise { + if (this.transportStatsPollInFlight || this.state.activePeerConnections.size === 0) + return; + + this.transportStatsPollInFlight = true; + + try { + for (const [peerId, peerData] of Array.from(this.state.activePeerConnections.entries())) { + await this.pollPeerTransportStats(peerId, peerData); + } + } finally { + this.transportStatsPollInFlight = false; + } + } + + private async pollPeerTransportStats(peerId: string, peerData: PeerData): Promise { + const connectionState = peerData.connection.connectionState; + + if (connectionState === 'closed' || connectionState === 'failed') { + this.clearPeerTransportStats(peerId); + return; + } + + try { + const stats = await peerData.connection.getStats(); + + let audioBytesReceived = 0; + let videoBytesReceived = 0; + + stats.forEach((report) => { + const summary = this.getInboundRtpSummary(report); + + if (!summary) + return; + + if (summary.kind === 'audio') + audioBytesReceived += summary.bytesReceived; + + if (summary.kind === 'video') + videoBytesReceived += summary.bytesReceived; + }); + + const collectedAt = Date.now(); + const previous = this.lastInboundByteSnapshots.get(peerId); + + this.lastInboundByteSnapshots.set(peerId, { + audioBytesReceived, + collectedAt, + videoBytesReceived + }); + + if (!previous) + return; + + const elapsedMs = collectedAt - previous.collectedAt; + + if (elapsedMs < PEER_STATS_SAMPLE_MIN_INTERVAL_MS) + return; + + const audioDownloadMbps = this.calculateMbps(audioBytesReceived - previous.audioBytesReceived, elapsedMs); + const videoDownloadMbps = this.calculateMbps(videoBytesReceived - previous.videoBytesReceived, elapsedMs); + + recordDebugNetworkDownloadRates(peerId, { + audioMbps: this.roundMetric(audioDownloadMbps), + videoMbps: this.roundMetric(videoDownloadMbps) + }, collectedAt); + + this.logger.info('Peer transport stats', { + audioDownloadMbps: this.roundMetric(audioDownloadMbps), + connectionState, + remotePeerId: peerId, + totalDownloadMbps: this.roundMetric(audioDownloadMbps + videoDownloadMbps), + videoDownloadMbps: this.roundMetric(videoDownloadMbps) + }); + } catch (error) { + this.logger.warn('Failed to collect peer transport stats', { + connectionState, + error: (error as Error)?.message ?? String(error), + peerId + }); + } + } + + private getInboundRtpSummary(report: RTCStats): { bytesReceived: number; kind: 'audio' | 'video' } | null { + const summary = report as unknown as Record; + + if (summary['type'] !== 'inbound-rtp' || summary['isRemote'] === true) + return null; + + const bytesReceived = typeof summary['bytesReceived'] === 'number' + ? summary['bytesReceived'] + : null; + const mediaKind = typeof summary['kind'] === 'string' + ? summary['kind'] + : (typeof summary['mediaType'] === 'string' ? summary['mediaType'] : null); + + if (bytesReceived === null || (mediaKind !== 'audio' && mediaKind !== 'video')) + return null; + + return { + bytesReceived, + kind: mediaKind + }; + } + + private calculateMbps(deltaBytes: number, elapsedMs: number): number { + if (elapsedMs <= 0) + return 0; + + return Math.max(0, deltaBytes) * 8 / elapsedMs / 1000; + } + + private roundMetric(value: number): number { + return Math.round(value * 1000) / 1000; + } } diff --git a/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts b/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts index 20a6eeb..3d38e5e 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts +++ b/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts @@ -1,4 +1,5 @@ import { TRACK_KIND_VIDEO } from '../../webrtc.constants'; +import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service'; import { PeerConnectionManagerContext } from '../shared'; export function handleRemoteTrack( @@ -34,13 +35,25 @@ export function handleRemoteTrack( const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track); - track.addEventListener('ended', () => removeRemoteTrack(state, remotePeerId, track.id)); + track.addEventListener('ended', () => removeRemoteTrack(context, remotePeerId, track.id)); state.remotePeerStreams.set(remotePeerId, compositeStream); state.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream }); + + recordDebugNetworkStreams(remotePeerId, { + audio: compositeStream.getAudioTracks().length, + video: compositeStream.getVideoTracks().length + }); + + logger.info('Remote stream updated', { + audioTrackCount: compositeStream.getAudioTracks().length, + remotePeerId, + trackCount: compositeStream.getTracks().length, + videoTrackCount: compositeStream.getVideoTracks().length + }); } function buildCompositeRemoteStream( @@ -63,10 +76,11 @@ function buildCompositeRemoteStream( } function removeRemoteTrack( - state: PeerConnectionManagerContext['state'], + context: PeerConnectionManagerContext, remotePeerId: string, trackId: string ): void { + const { logger, state } = context; const currentStream = state.remotePeerStreams.get(remotePeerId); if (!currentStream) @@ -81,6 +95,16 @@ function removeRemoteTrack( if (remainingTracks.length === 0) { state.remotePeerStreams.delete(remotePeerId); + recordDebugNetworkStreams(remotePeerId, { audio: 0, + video: 0 }); + + logger.info('Remote stream updated', { + audioTrackCount: 0, + remotePeerId, + trackCount: 0, + videoTrackCount: 0 + }); + return; } @@ -91,4 +115,16 @@ function removeRemoteTrack( peerId: remotePeerId, stream: nextStream }); + + recordDebugNetworkStreams(remotePeerId, { + audio: nextStream.getAudioTracks().length, + video: nextStream.getVideoTracks().length + }); + + logger.info('Remote stream updated', { + audioTrackCount: nextStream.getAudioTracks().length, + remotePeerId, + trackCount: nextStream.getTracks().length, + videoTrackCount: nextStream.getVideoTracks().length + }); } diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/core/services/webrtc/screen-share.manager.ts index 190150e..9b458b6 100644 --- a/src/app/core/services/webrtc/screen-share.manager.ts +++ b/src/app/core/services/webrtc/screen-share.manager.ts @@ -168,7 +168,11 @@ export class ScreenShareManager { // Clean up mixed audio if (this.combinedAudioStream) { - try { this.combinedAudioStream.getTracks().forEach(track => track.stop()); } catch { /* ignore */ } + try { + this.combinedAudioStream.getTracks().forEach((track) => track.stop()); + } catch (error) { + this.logger.warn('Failed to stop combined screen-share audio tracks', error as any); + } this.combinedAudioStream = null; } @@ -179,7 +183,9 @@ export class ScreenShareManager { const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender); if (videoTransceiver) { - videoTransceiver.sender.replaceTrack(null).catch(() => {}); + videoTransceiver.sender.replaceTrack(null).catch((error) => { + this.logger.error('Failed to clear screen video sender track', error, { peerId }); + }); if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) { videoTransceiver.direction = TRANSCEIVER_RECV_ONLY; @@ -340,7 +346,11 @@ export class ScreenShareManager { this.stopScreenShare(); if (this.audioMixingContext) { - try { this.audioMixingContext.close(); } catch { /* ignore */ } + try { + this.audioMixingContext.close(); + } catch (error) { + this.logger.warn('Failed to close audio mixing context during destroy', error as any); + } this.audioMixingContext = null; } diff --git a/src/app/core/services/webrtc/signaling.manager.ts b/src/app/core/services/webrtc/signaling.manager.ts index 297e888..e7b5c27 100644 --- a/src/app/core/services/webrtc/signaling.manager.ts +++ b/src/app/core/services/webrtc/signaling.manager.ts @@ -5,6 +5,7 @@ */ import { Observable, Subject } from 'rxjs'; import { SignalingMessage } from '../../models'; +import { recordDebugNetworkSignalingPayload } from '../debug-network-metrics.service'; import { WebRTCLogger } from './webrtc-logger'; import { IdentifyCredentials, JoinedServerInfo } from './webrtc.types'; import { @@ -45,6 +46,8 @@ export class SignalingManager { this.lastSignalingUrl = serverUrl; return new Observable((observer) => { try { + this.logger.info('[signaling] Connecting to signaling server', { serverUrl }); + if (this.signalingWebSocket) { this.signalingWebSocket.close(); } @@ -53,7 +56,11 @@ export class SignalingManager { this.signalingWebSocket = new WebSocket(serverUrl); this.signalingWebSocket.onopen = () => { - this.logger.info('Connected to signaling server'); + this.logger.info('[signaling] Connected to signaling server', { + serverUrl, + readyState: this.getSocketReadyStateLabel() + }); + this.clearReconnect(); this.startHeartbeat(); this.connectionStatus$.next({ connected: true }); @@ -62,25 +69,56 @@ export class SignalingManager { }; this.signalingWebSocket.onmessage = (event) => { + const rawPayload = this.stringifySocketPayload(event.data); + const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null; + try { - const message = JSON.parse(event.data); + const message = JSON.parse(rawPayload) as SignalingMessage & Record; + const payloadPreview = this.buildPayloadPreview(message); + + recordDebugNetworkSignalingPayload(message, 'inbound'); + + this.logger.traffic('signaling', 'inbound', { + ...payloadPreview, + bytes: payloadBytes ?? undefined, + payloadPreview, + readyState: this.getSocketReadyStateLabel(), + type: typeof message.type === 'string' ? message.type : 'unknown', + url: serverUrl + }); this.messageReceived$.next(message); } catch (error) { - this.logger.error('Failed to parse signaling message', error); + this.logger.error('[signaling] Failed to parse signaling message', error, { + bytes: payloadBytes ?? undefined, + rawPreview: this.getPayloadPreview(rawPayload), + readyState: this.getSocketReadyStateLabel(), + url: serverUrl + }); } }; this.signalingWebSocket.onerror = (error) => { - this.logger.error('Signaling socket error', error); + this.logger.error('[signaling] Signaling socket error', error, { + readyState: this.getSocketReadyStateLabel(), + url: serverUrl + }); + this.connectionStatus$.next({ connected: false, errorMessage: 'Connection to signaling server failed' }); observer.error(error); }; - this.signalingWebSocket.onclose = () => { - this.logger.info('Disconnected from signaling server'); + this.signalingWebSocket.onclose = (event) => { + this.logger.warn('[signaling] Disconnected from signaling server', { + attempts: this.signalingReconnectAttempts, + code: event.code, + reason: event.reason || null, + url: serverUrl, + wasClean: event.wasClean + }); + this.stopHeartbeat(); this.connectionStatus$.next({ connected: false, errorMessage: 'Disconnected from signaling server' }); @@ -88,6 +126,11 @@ export class SignalingManager { this.scheduleReconnect(); }; } catch (error) { + this.logger.error('[signaling] Failed to initialize signaling socket', error, { + readyState: this.getSocketReadyStateLabel(), + url: serverUrl + }); + observer.error(error); } }); @@ -118,7 +161,12 @@ export class SignalingManager { /** Send a signaling message (with `from` / `timestamp` populated). */ sendSignalingMessage(message: Omit, localPeerId: string): void { if (!this.isSocketOpen()) { - this.logger.error('Signaling socket not connected', new Error('Socket not open')); + this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), { + readyState: this.getSocketReadyStateLabel(), + type: message.type, + url: this.lastSignalingUrl + }); + return; } @@ -126,17 +174,30 @@ export class SignalingManager { from: localPeerId, timestamp: Date.now() }; - this.signalingWebSocket!.send(JSON.stringify(fullMessage)); + this.sendSerializedPayload(fullMessage, { + targetPeerId: message.to, + type: message.type, + url: this.lastSignalingUrl + }); } /** Send a raw JSON payload (for identify, join_server, etc.). */ sendRawMessage(message: Record): void { if (!this.isSocketOpen()) { - this.logger.error('Signaling socket not connected', new Error('Socket not open')); + this.logger.error('[signaling] Signaling socket not connected', new Error('Socket not open'), { + readyState: this.getSocketReadyStateLabel(), + type: typeof message['type'] === 'string' ? message['type'] : 'unknown', + url: this.lastSignalingUrl + }); + return; } - this.signalingWebSocket!.send(JSON.stringify(message)); + this.sendSerializedPayload(message, { + targetPeerId: typeof message['targetUserId'] === 'string' ? message['targetUserId'] : undefined, + type: typeof message['type'] === 'string' ? message['type'] : 'unknown', + url: this.lastSignalingUrl + }); } /** Gracefully close the WebSocket. */ @@ -212,7 +273,12 @@ export class SignalingManager { this.signalingReconnectTimer = setTimeout(() => { this.signalingReconnectTimer = null; this.signalingReconnectAttempts++; - this.logger.info('Attempting to reconnect to signaling...'); + this.logger.info('[signaling] Attempting reconnect', { + attempt: this.signalingReconnectAttempts, + delay, + url: this.lastSignalingUrl + }); + this.connect(this.lastSignalingUrl!).subscribe({ next: () => { this.signalingReconnectAttempts = 0; }, error: () => { this.scheduleReconnect(); } @@ -251,4 +317,152 @@ export class SignalingManager { this.messageReceived$.complete(); this.connectionStatus$.complete(); } + + private sendSerializedPayload( + message: SignalingMessage | Record, + details: { targetPeerId?: string; type?: string; url?: string | null } + ): void { + let rawPayload = ''; + + const payloadPreview = this.buildPayloadPreview(message); + + recordDebugNetworkSignalingPayload(message, 'outbound'); + + try { + rawPayload = JSON.stringify(message); + } catch (error) { + this.logger.error('[signaling] Failed to serialize signaling payload', error, { + payloadPreview, + type: details.type, + url: details.url + }); + + throw error; + } + + try { + this.signalingWebSocket!.send(rawPayload); + this.logger.traffic('signaling', 'outbound', { + ...payloadPreview, + bytes: this.measurePayloadBytes(rawPayload), + payloadPreview, + readyState: this.getSocketReadyStateLabel(), + targetPeerId: details.targetPeerId, + type: details.type, + url: details.url + }); + } catch (error) { + this.logger.error('[signaling] Failed to send signaling payload', error, { + bytes: this.measurePayloadBytes(rawPayload), + payloadPreview, + readyState: this.getSocketReadyStateLabel(), + targetPeerId: details.targetPeerId, + type: details.type, + url: details.url + }); + + throw error; + } + } + + private getSocketReadyStateLabel(): string { + const readyState = this.signalingWebSocket?.readyState; + + switch (readyState) { + case WebSocket.CONNECTING: + return 'connecting'; + case WebSocket.OPEN: + return 'open'; + case WebSocket.CLOSING: + return 'closing'; + case WebSocket.CLOSED: + return 'closed'; + default: + return 'unavailable'; + } + } + + private stringifySocketPayload(payload: unknown): string { + if (typeof payload === 'string') + return payload; + + if (payload instanceof ArrayBuffer) + return new TextDecoder().decode(payload); + + return String(payload ?? ''); + } + + private measurePayloadBytes(payload: string): number { + return new TextEncoder().encode(payload).length; + } + + private getPayloadPreview(payload: string): string { + return payload.replace(/\s+/g, ' ').slice(0, 240); + } + + private buildPayloadPreview(payload: SignalingMessage | Record): Record { + const record = payload as Record; + const voiceState = this.summarizeVoiceState(record['voiceState']); + const users = this.summarizeUsers(record['users']); + + return { + displayName: typeof record['displayName'] === 'string' ? record['displayName'] : undefined, + fromUserId: typeof record['fromUserId'] === 'string' ? record['fromUserId'] : undefined, + isScreenSharing: typeof record['isScreenSharing'] === 'boolean' ? record['isScreenSharing'] : undefined, + keys: Object.keys(record).slice(0, 10), + oderId: typeof record['oderId'] === 'string' ? record['oderId'] : undefined, + roomId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined, + serverId: typeof record['serverId'] === 'string' ? record['serverId'] : undefined, + targetPeerId: typeof record['targetUserId'] === 'string' ? record['targetUserId'] : undefined, + type: typeof record['type'] === 'string' ? record['type'] : 'unknown', + userCount: Array.isArray(record['users']) ? record['users'].length : undefined, + users, + voiceState + }; + } + + private summarizeVoiceState(value: unknown): Record | undefined { + const voiceState = this.asRecord(value); + + if (!voiceState) + return undefined; + + return { + isConnected: voiceState['isConnected'] === true, + isMuted: voiceState['isMuted'] === true, + isDeafened: voiceState['isDeafened'] === true, + isSpeaking: voiceState['isSpeaking'] === true, + roomId: typeof voiceState['roomId'] === 'string' ? voiceState['roomId'] : undefined, + serverId: typeof voiceState['serverId'] === 'string' ? voiceState['serverId'] : undefined, + volume: typeof voiceState['volume'] === 'number' ? voiceState['volume'] : undefined + }; + } + + private summarizeUsers(value: unknown): Record[] | undefined { + if (!Array.isArray(value)) + return undefined; + + const users: Record[] = []; + + for (const userValue of value.slice(0, 20)) { + const user = this.asRecord(userValue); + + if (!user) + continue; + + users.push({ + displayName: typeof user['displayName'] === 'string' ? user['displayName'] : undefined, + oderId: typeof user['oderId'] === 'string' ? user['oderId'] : undefined + }); + } + + return users; + } + + private asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return null; + + return value as Record; + } } diff --git a/src/app/core/services/webrtc/webrtc-logger.ts b/src/app/core/services/webrtc/webrtc-logger.ts index ba78c3d..ca0654e 100644 --- a/src/app/core/services/webrtc/webrtc-logger.ts +++ b/src/app/core/services/webrtc/webrtc-logger.ts @@ -3,12 +3,33 @@ * Lightweight logging utility for the WebRTC subsystem. * All log lines are prefixed with `[WebRTC]`. */ +export interface WebRTCTrafficDetails { + bytes?: number; + bufferedAmount?: number; + channelLabel?: string; + messageId?: string; + payloadPreview?: unknown; + peerId?: string; + readyState?: number | string | null; + roomId?: string; + targetPeerId?: string; + type?: string; + url?: string | null; + [key: string]: unknown; +} + export class WebRTCLogger { - constructor(private readonly isEnabled = true) {} + constructor(private readonly isEnabled: boolean | (() => boolean) = true) {} + + private get debugEnabled(): boolean { + return typeof this.isEnabled === 'function' + ? this.isEnabled() + : this.isEnabled; + } /** Informational log (only when debug is enabled). */ info(prefix: string, ...args: unknown[]): void { - if (!this.isEnabled) + if (!this.debugEnabled) return; try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } @@ -16,12 +37,17 @@ export class WebRTCLogger { /** Warning log (only when debug is enabled). */ warn(prefix: string, ...args: unknown[]): void { - if (!this.isEnabled) + if (!this.debugEnabled) return; try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } } + /** Structured network-traffic log for signaling and data-channel activity. */ + traffic(scope: 'data-channel' | 'signaling', direction: 'inbound' | 'outbound', details: WebRTCTrafficDetails): void { + this.info(`[${scope}] ${direction}`, details); + } + /** Error log (always emitted regardless of debug flag). */ error(prefix: string, err: unknown, extra?: Record): void { const payload = { diff --git a/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html b/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html new file mode 100644 index 0000000..1c8f5e3 --- /dev/null +++ b/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.html @@ -0,0 +1,106 @@ +
+
+
+
+
+ +
+ +
+

App-wide debugging

+

+ Capture UI events, navigation activity, console output, and global runtime errors in a live debug console. +

+
+
+ + +
+
+ +
+
+
+ + Captured events +
+

{{ entryCount() }}

+

Last update: {{ lastUpdatedLabel() }}

+
+ +
+
+ + Errors +
+

{{ errorCount() }}

+

Unhandled runtime failures and rejected promises.

+
+ +
+
+ + Warnings +
+

{{ warningCount() }}

+

Navigation cancellations, offline events, and other warnings.

+
+
+ +
+
+
+
Floating debug console
+

+ When debugging is enabled, a bug icon appears in the app so you can open the docked console without blocking the rest of the UI. +

+
+ +
+ + + +
+
+
+
diff --git a/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts b/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts new file mode 100644 index 0000000..f5b98d4 --- /dev/null +++ b/src/app/features/settings/settings-modal/debugging-settings/debugging-settings.component.ts @@ -0,0 +1,70 @@ +import { + Component, + computed, + inject +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + lucideBug, + lucideCircleAlert, + lucideClock3, + lucideTrash2, + lucideTriangleAlert +} from '@ng-icons/lucide'; + +import { DebuggingService } from '../../../../core/services/debugging.service'; + +@Component({ + selector: 'app-debugging-settings', + standalone: true, + imports: [CommonModule, NgIcon], + viewProviders: [ + provideIcons({ + lucideBug, + lucideCircleAlert, + lucideClock3, + lucideTrash2, + lucideTriangleAlert + }) + ], + templateUrl: './debugging-settings.component.html' +}) +export class DebuggingSettingsComponent { + readonly debugging = inject(DebuggingService); + + readonly enabled = this.debugging.enabled; + readonly isConsoleOpen = this.debugging.isConsoleOpen; + readonly entryCount = computed(() => { + return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0); + }); + readonly errorCount = computed(() => { + return this.debugging.entries().reduce((sum, entry) => { + return sum + (entry.level === 'error' ? entry.count : 0); + }, 0); + }); + readonly warningCount = computed(() => { + return this.debugging.entries().reduce((sum, entry) => { + return sum + (entry.level === 'warn' ? entry.count : 0); + }, 0); + }); + readonly lastUpdatedLabel = computed(() => { + const lastEntry = this.debugging.entries().at(-1); + + return lastEntry ? lastEntry.timeLabel : 'No logs yet'; + }); + + onEnabledChange(event: Event): void { + const input = event.target as HTMLInputElement; + + this.debugging.setEnabled(input.checked); + } + + openConsole(): void { + this.debugging.openConsole(); + } + + clearLogs(): void { + this.debugging.clear(); + } +} diff --git a/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts b/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts index 5a344b7..c3cd72c 100644 --- a/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts +++ b/src/app/features/settings/settings-modal/server-settings/server-settings.component.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { Component, + effect, inject, input, - signal, - computed + signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; @@ -60,18 +60,21 @@ export class ServerSettingsComponent { private saveTimeout: ReturnType | null = null; /** Reload form fields whenever the server input changes. */ - serverData = computed(() => { - const room = this.server(); + readonly serverData = this.server; + + constructor() { + effect(() => { + const room = this.server(); + + if (!room) + return; - if (room) { this.roomName = room.name; this.roomDescription = room.description || ''; this.isPrivate.set(room.isPrivate); this.maxUsers = room.maxUsers || 0; - } - - return room; - }); + }); + } togglePrivate(): void { this.isPrivate.update((currentValue) => !currentValue); diff --git a/src/app/features/settings/settings-modal/settings-modal.component.html b/src/app/features/settings/settings-modal/settings-modal.component.html index a2dee38..9eaf456 100644 --- a/src/app/features/settings/settings-modal/settings-modal.component.html +++ b/src/app/features/settings/settings-modal/settings-modal.component.html @@ -122,6 +122,9 @@ @case ('voice') { Voice & Audio } + @case ('debugging') { + Debugging + } @case ('server') { Server Settings } @@ -157,6 +160,9 @@ @case ('voice') { } + @case ('debugging') { + + } @case ('server') { + + + + diff --git a/src/app/features/voice/voice-controls/voice-controls.component.ts b/src/app/features/voice/voice-controls/voice-controls.component.ts index d2f5812..492f784 100644 --- a/src/app/features/voice/voice-controls/voice-controls.component.ts +++ b/src/app/features/voice/voice-controls/voice-controls.component.ts @@ -31,7 +31,10 @@ import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants'; import { SettingsModalService } from '../../../core/services/settings-modal.service'; -import { UserAvatarComponent } from '../../../shared'; +import { + DebugConsoleComponent, + UserAvatarComponent +} from '../../../shared'; import { PlaybackOptions, VoicePlaybackService } from './services/voice-playback.service'; interface AudioDevice { @@ -45,6 +48,7 @@ interface AudioDevice { imports: [ CommonModule, NgIcon, + DebugConsoleComponent, UserAvatarComponent ], viewProviders: [ diff --git a/src/app/shared/components/debug-console/debug-console-entry-list/debug-console-entry-list.component.html b/src/app/shared/components/debug-console/debug-console-entry-list/debug-console-entry-list.component.html new file mode 100644 index 0000000..5792e19 --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console-entry-list/debug-console-entry-list.component.html @@ -0,0 +1,75 @@ +
+ @if (entries().length === 0) { +
+
+

No logs match the current filters.

+

Generate activity in the app or loosen the filters to see captured events.

+
+
+ } @else { +
+ @for (entry of entries(); track entry.id) { +
+ + + @if (entry.payloadText && isExpanded(entry.id)) { +
{{
+              entry.payloadText
+            }}
+ } +
+ } +
+ } +
diff --git a/src/app/shared/components/debug-console/debug-console-entry-list/debug-console-entry-list.component.ts b/src/app/shared/components/debug-console/debug-console-entry-list/debug-console-entry-list.component.ts new file mode 100644 index 0000000..f0ca0b0 --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console-entry-list/debug-console-entry-list.component.ts @@ -0,0 +1,108 @@ +import { + Component, + ElementRef, + effect, + input, + signal, + viewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideChevronDown, lucideChevronRight } from '@ng-icons/lucide'; + +import { type DebugLogEntry, type DebugLogLevel } from '../../../../core/services/debugging.service'; + +@Component({ + selector: 'app-debug-console-entry-list', + standalone: true, + imports: [CommonModule, NgIcon], + viewProviders: [ + provideIcons({ + lucideChevronDown, + lucideChevronRight + }) + ], + templateUrl: './debug-console-entry-list.component.html', + host: { + style: 'display: flex; min-height: 0; overflow: hidden;' + } +}) +export class DebugConsoleEntryListComponent { + readonly entries = input.required(); + readonly autoScroll = input.required(); + readonly expandedEntryIds = signal([]); + + private readonly viewportRef = viewChild>('viewport'); + + constructor() { + effect(() => { + this.entries(); + + if (!this.autoScroll()) + return; + + requestAnimationFrame(() => this.scrollToBottom()); + }); + } + + toggleExpanded(entryId: number): void { + const nextExpandedIds = new Set(this.expandedEntryIds()); + + if (nextExpandedIds.has(entryId)) { + nextExpandedIds.delete(entryId); + } else { + nextExpandedIds.add(entryId); + } + + this.expandedEntryIds.set(Array.from(nextExpandedIds)); + } + + isExpanded(entryId: number): boolean { + return this.expandedEntryIds().includes(entryId); + } + + getRowClass(level: DebugLogLevel): string { + switch (level) { + case 'event': + return 'bg-transparent hover:bg-secondary/20'; + case 'info': + return 'bg-sky-500/[0.04] hover:bg-sky-500/[0.08]'; + case 'warn': + return 'bg-yellow-500/[0.05] hover:bg-yellow-500/[0.08]'; + case 'error': + return 'bg-destructive/[0.05] hover:bg-destructive/[0.08]'; + case 'debug': + return 'bg-fuchsia-500/[0.05] hover:bg-fuchsia-500/[0.08]'; + } + } + + getBadgeClass(level: DebugLogLevel): string { + const base = 'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide'; + + switch (level) { + case 'event': + return base + ' bg-primary/10 text-primary'; + case 'info': + return base + ' bg-sky-500/10 text-sky-300'; + case 'warn': + return base + ' bg-yellow-500/10 text-yellow-300'; + case 'error': + return base + ' bg-destructive/10 text-destructive'; + case 'debug': + return base + ' bg-fuchsia-500/10 text-fuchsia-300'; + } + } + + getLevelLabel(level: DebugLogLevel): string { + return level.toUpperCase(); + } + + private scrollToBottom(): void { + const viewport = this.viewportRef()?.nativeElement; + + if (!viewport) + return; + + viewport.scrollTop = viewport.scrollHeight; + } +} diff --git a/src/app/shared/components/debug-console/debug-console-network-map/debug-console-network-map.component.html b/src/app/shared/components/debug-console/debug-console-network-map/debug-console-network-map.component.html new file mode 100644 index 0000000..3937198 --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console-network-map/debug-console-network-map.component.html @@ -0,0 +1,247 @@ +
+
+
+ +
+
+ {{ snapshot().summary.clientCount }} clients + {{ snapshot().summary.serverCount }} servers + {{ snapshot().summary.peerConnectionCount }} peer links + {{ snapshot().summary.messageCount }} grouped messages +
+ +
+ Local client + Remote client + Signaling + Server +
+
+ + @if (snapshot().edges.length === 0) { +
+
+

No network activity captured yet.

+

+ Enable debugging before connecting to signaling, joining a server, or opening peer channels to populate the live map. +

+
+
+ } +
+ + +
diff --git a/src/app/shared/components/debug-console/debug-console-network-map/debug-console-network-map.component.ts b/src/app/shared/components/debug-console/debug-console-network-map/debug-console-network-map.component.ts new file mode 100644 index 0000000..5fb8cca --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console-network-map/debug-console-network-map.component.ts @@ -0,0 +1,651 @@ +/* eslint-disable id-denylist, id-length, padding-line-between-statements */ +import { + Component, + ElementRef, + OnDestroy, + computed, + effect, + input, + viewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import cytoscape, { type Core, type ElementDefinition } from 'cytoscape'; + +import { + type DebugNetworkEdge, + type DebugNetworkMessageGroup, + type DebugNetworkNode, + type DebugNetworkSnapshot +} from '../../../../core/services/debugging.service'; + +@Component({ + selector: 'app-debug-console-network-map', + standalone: true, + imports: [CommonModule], + templateUrl: './debug-console-network-map.component.html', + host: { + style: 'display: flex; min-height: 0; overflow: hidden;' + } +}) +export class DebugConsoleNetworkMapComponent implements OnDestroy { + readonly snapshot = input.required(); + readonly graphRef = viewChild>('graph'); + readonly statusNodes = computed(() => { + const clientNodes = this.snapshot().nodes + .filter((node) => node.kind === 'local-client' || node.kind === 'remote-client'); + const remoteNodes = clientNodes.filter((node) => node.kind === 'remote-client'); + const visibleNodes = remoteNodes.length > 0 + ? remoteNodes + : clientNodes; + + return visibleNodes + .sort((nodeA, nodeB) => { + if (nodeA.isActive !== nodeB.isActive) + return nodeA.isActive ? -1 : 1; + + return nodeA.label.localeCompare(nodeB.label); + }); + }); + readonly connectionEdges = computed(() => { + return [...this.snapshot().edges].sort((edgeA, edgeB) => { + if (edgeA.isActive !== edgeB.isActive) + return edgeA.isActive ? -1 : 1; + + if (edgeA.kind !== edgeB.kind) + return this.getEdgeOrder(edgeA.kind) - this.getEdgeOrder(edgeB.kind); + + return edgeB.lastSeen - edgeA.lastSeen; + }); + }); + + private cytoscapeInstance: Core | null = null; + private resizeObserver: ResizeObserver | null = null; + private lastStructureKey = ''; + + constructor() { + effect(() => { + const container = this.graphRef()?.nativeElement; + const snapshot = this.snapshot(); + + if (!container) + return; + + this.ensureGraph(container); + requestAnimationFrame(() => this.renderGraph(snapshot)); + }); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.cytoscapeInstance?.destroy(); + this.cytoscapeInstance = null; + } + + formatAge(timestamp: number): string { + const deltaMs = Math.max(0, Date.now() - timestamp); + + if (deltaMs < 1_000) + return 'just now'; + + const seconds = Math.floor(deltaMs / 1_000); + + if (seconds < 60) + return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + + if (minutes < 60) + return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + + return `${hours}h ago`; + } + + formatEdgeHeading(edge: DebugNetworkEdge): string { + return `${edge.sourceLabel} → ${edge.targetLabel}`; + } + + formatMessageGroup(group: DebugNetworkMessageGroup): string { + const direction = group.direction === 'outbound' ? '↑' : '↓'; + + return `${direction} ${group.type} ×${group.count}`; + } + + formatHandshakeSummary(node: DebugNetworkNode): string { + const offerSummary = `${node.handshake.offersSent}/${node.handshake.offersReceived}`; + const answerSummary = `${node.handshake.answersSent}/${node.handshake.answersReceived}`; + const iceSummary = `${node.handshake.iceSent}/${node.handshake.iceReceived}`; + + return `O ${offerSummary} · A ${answerSummary} · ICE ${iceSummary}`; + } + + formatTextSummary(node: DebugNetworkNode): string { + return `Text ↑${node.textMessages.sent} ↓${node.textMessages.received}`; + } + + formatStreamSummary(node: DebugNetworkNode): string { + return `Streams A${node.streams.audio} V${node.streams.video}`; + } + + formatDownloadSummary(node: DebugNetworkNode): string { + const metrics = [ + `F ${this.formatMbps(node.downloads.fileMbps)}`, + `A ${this.formatMbps(node.downloads.audioMbps)}`, + `V ${this.formatMbps(node.downloads.videoMbps)}` + ]; + + return `↓ ${metrics.join(' · ')}`; + } + + formatClientId(node: DebugNetworkNode): string { + return node.userId ?? node.identity ?? 'Unavailable'; + } + + formatPeerIdentity(node: DebugNetworkNode): string | null { + if (!node.identity || node.identity === node.userId) + return null; + + return node.identity; + } + + hasDownloadMetrics(node: DebugNetworkNode): boolean { + return node.downloads.fileMbps !== null || node.downloads.audioMbps !== null || node.downloads.videoMbps !== null; + } + + formatMbps(value: number | null): string { + if (value === null) + return '-'; + + return value >= 10 ? value.toFixed(1) : value.toFixed(2); + } + + getConnectionBadgeClass(edge: DebugNetworkEdge): string { + const base = 'rounded-full border px-2 py-0.5 text-[10px] font-medium'; + + if (!edge.isActive) + return base + ' border-border text-muted-foreground'; + + switch (edge.kind) { + case 'signaling': + return base + ' border-orange-400/40 bg-orange-500/10 text-orange-300'; + case 'peer': + return base + ' border-emerald-400/40 bg-emerald-500/10 text-emerald-300'; + case 'membership': + return base + ' border-violet-400/40 bg-violet-500/10 text-violet-300'; + } + } + + getMessageBadgeClass(group: DebugNetworkMessageGroup): string { + const base = 'rounded-full border px-2 py-0.5 text-[10px] font-medium'; + + if (group.scope === 'signaling') + return base + ' border-orange-400/30 bg-orange-500/10 text-orange-200'; + + if (group.direction === 'outbound') + return base + ' border-sky-400/30 bg-sky-500/10 text-sky-200'; + + return base + ' border-cyan-400/30 bg-cyan-500/10 text-cyan-200'; + } + + getStatusBadgeClass(node: DebugNetworkNode): string { + const base = 'rounded-full border px-2 py-0.5 text-[10px] font-medium'; + + if (node.isSpeaking) + return base + ' border-emerald-400/40 bg-emerald-500/10 text-emerald-300'; + + if (node.isTyping) + return base + ' border-amber-400/40 bg-amber-500/10 text-amber-300'; + + if (node.isStreaming) + return base + ' border-fuchsia-400/40 bg-fuchsia-500/10 text-fuchsia-300'; + + if (node.isMuted) + return base + ' border-rose-400/40 bg-rose-500/10 text-rose-300'; + + return base + ' border-border text-muted-foreground'; + } + + getNodeActivityLabel(node: DebugNetworkNode): string { + if (node.isSpeaking) + return 'Speaking'; + + if (node.isTyping) + return 'Typing'; + + if (node.isStreaming) + return 'Streaming'; + + if (node.isMuted) + return 'Muted'; + + return 'Active'; + } + + getEdgeKindLabel(edge: DebugNetworkEdge): string { + switch (edge.kind) { + case 'membership': + return 'Membership'; + case 'signaling': + return 'Signaling'; + case 'peer': + return 'Peer'; + } + } + + getVisibleMessageGroups(edge: DebugNetworkEdge): DebugNetworkMessageGroup[] { + return edge.messageGroups.slice(0, 8); + } + + getHiddenMessageGroupCount(edge: DebugNetworkEdge): number { + return Math.max(0, edge.messageGroups.length - 8); + } + + private ensureGraph(container: HTMLDivElement): void { + if (this.cytoscapeInstance) + return; + + this.cytoscapeInstance = cytoscape({ + container, + boxSelectionEnabled: false, + minZoom: 0.45, + maxZoom: 1.8, + wheelSensitivity: 0.2, + autoungrabify: true, + style: this.buildGraphStyles() as never + }); + this.resizeObserver = new ResizeObserver(() => { + this.cytoscapeInstance?.resize(); + }); + this.resizeObserver.observe(container); + } + + private renderGraph(snapshot: DebugNetworkSnapshot): void { + if (!this.cytoscapeInstance) + return; + + const structureKey = this.buildStructureKey(snapshot); + const elements = this.buildGraphElements(snapshot); + + this.cytoscapeInstance.elements().remove(); + this.cytoscapeInstance.add(elements); + this.cytoscapeInstance.resize(); + + if (structureKey !== this.lastStructureKey) { + this.cytoscapeInstance.fit(undefined, 48); + this.lastStructureKey = structureKey; + } + } + + private buildStructureKey(snapshot: DebugNetworkSnapshot): string { + return JSON.stringify({ + edgeIds: snapshot.edges.map((edge) => edge.id), + nodeIds: snapshot.nodes.map((node) => node.id) + }); + } + + private buildGraphElements(snapshot: DebugNetworkSnapshot): ElementDefinition[] { + const positions = this.buildNodePositions(snapshot.nodes); + + return [ + ...snapshot.nodes.map((node) => ({ + data: { + id: node.id, + label: this.buildNodeLabel(node) + }, + position: positions.get(node.id) ?? { x: 0, + y: 0 }, + classes: this.buildNodeClasses(node) + })), + ...snapshot.edges.map((edge) => { + const label = this.buildEdgeLabel(edge); + + return { + data: { + id: edge.id, + source: edge.sourceId, + target: edge.targetId, + label + }, + classes: this.buildEdgeClasses(edge, label) + }; + }) + ]; + } + + private buildNodePositions(nodes: DebugNetworkNode[]): Map { + const positions = new Map(); + const localNodes = nodes.filter((node) => node.kind === 'local-client'); + const signalingNodes = nodes.filter((node) => node.kind === 'signaling-server'); + const serverNodes = nodes.filter((node) => node.kind === 'app-server'); + const remoteNodes = nodes.filter((node) => node.kind === 'remote-client'); + + for (const node of localNodes) { + positions.set(node.id, { + x: 140, + y: 320 + }); + } + + this.applyColumnPositions(positions, signalingNodes, 470, 170, 112); + this.applyColumnPositions(positions, serverNodes, 470, 470, 112); + this.applyColumnPositions(positions, remoteNodes, 820, 320, 186); + + return positions; + } + + private applyColumnPositions( + positions: Map, + nodes: DebugNetworkNode[], + x: number, + centerY: number, + spacing: number + ): void { + if (nodes.length === 0) + return; + + const totalHeight = (nodes.length - 1) * spacing; + const startY = centerY - totalHeight / 2; + + nodes.forEach((node, index) => { + positions.set(node.id, { + x, + y: startY + index * spacing + }); + }); + } + + private buildNodeClasses(node: DebugNetworkNode): string { + const classes: string[] = [node.kind]; + + if (!node.isActive) + classes.push('inactive'); + + if (node.isTyping) + classes.push('typing'); + + if (node.isSpeaking) + classes.push('speaking'); + + if (node.isStreaming) + classes.push('streaming'); + + if (node.isMuted) + classes.push('muted'); + + if (node.isDeafened) + classes.push('deafened'); + + return classes.join(' '); + } + + private buildEdgeClasses(edge: DebugNetworkEdge, label: string): string { + const classes: string[] = [edge.kind]; + + if (!edge.isActive) + classes.push('inactive'); + + if (label.trim().length > 0) + classes.push('has-label'); + + return classes.join(' '); + } + + private buildNodeLabel(node: DebugNetworkNode): string { + const lines = [node.label]; + + if (node.secondaryLabel) + lines.push(node.secondaryLabel); + + if (node.kind === 'local-client' || node.kind === 'remote-client') { + lines.push(`ID ${this.shortenIdentifier(node.userId ?? node.identity ?? 'unknown')}`); + + const statusLine = this.buildCompactStatusLine(node); + + if (statusLine) + lines.push(statusLine); + + lines.push(`A${node.streams.audio} V${node.streams.video} • ↑${node.textMessages.sent} ↓${node.textMessages.received}`); + + if (this.hasHandshakeActivity(node)) { + lines.push( + `HS O${node.handshake.offersSent}/${node.handshake.offersReceived} A${node.handshake.answersSent}/${node.handshake.answersReceived}` + ); + lines.push(`ICE ${node.handshake.iceSent}/${node.handshake.iceReceived} • Drop ${node.connectionDrops}`); + } else { + lines.push(`Drop ${node.connectionDrops}`); + } + + if (this.hasDownloadMetrics(node)) { + const downloadSummary = [ + `F${this.formatMbps(node.downloads.fileMbps)}`, + `A${this.formatMbps(node.downloads.audioMbps)}`, + `V${this.formatMbps(node.downloads.videoMbps)}` + ].join(' '); + + lines.push(`↓ ${downloadSummary}`); + } + } + + return lines.join('\n'); + } + + private buildEdgeLabel(edge: DebugNetworkEdge): string { + if (edge.kind === 'membership') + return edge.isActive ? edge.stateLabel : ''; + + return edge.label; + } + + private getEdgeOrder(kind: DebugNetworkEdge['kind']): number { + switch (kind) { + case 'signaling': + return 0; + case 'peer': + return 1; + case 'membership': + return 2; + } + } + + private buildGraphStyles() { + return [ + { + selector: 'node', + style: { + 'background-color': '#2563eb', + 'border-color': '#60a5fa', + 'border-width': 2, + color: '#f8fafc', + 'font-family': 'Inter, ui-sans-serif, system-ui, sans-serif', + 'font-size': 10, + 'font-weight': 600, + height: 152, + label: 'data(label)', + padding: 12, + shape: 'round-rectangle', + 'text-background-color': '#0f172acc', + 'text-background-opacity': 1, + 'text-background-padding': 4, + 'text-border-radius': 8, + 'text-halign': 'center', + 'text-max-width': 208, + 'text-outline-color': '#0f172a', + 'text-outline-width': 0, + 'text-valign': 'center', + 'text-wrap': 'wrap', + width: 224 + } + }, + { + selector: 'node.local-client', + style: { + 'background-color': '#2563eb', + 'border-color': '#93c5fd' + } + }, + { + selector: 'node.remote-client', + style: { + 'background-color': '#0f766e', + 'border-color': '#34d399' + } + }, + { + selector: 'node.signaling-server', + style: { + 'background-color': '#9a3412', + 'border-color': '#fdba74', + height: 82, + shape: 'round-rectangle', + width: 190 + } + }, + { + selector: 'node.app-server', + style: { + 'background-color': '#5b21b6', + 'border-color': '#c4b5fd', + height: 82, + shape: 'round-rectangle', + width: 190 + } + }, + { + selector: 'node.typing', + style: { + 'border-color': '#fbbf24', + 'border-width': 4 + } + }, + { + selector: 'node.speaking', + style: { + 'border-color': '#34d399', + 'border-width': 4, + 'overlay-color': '#34d399', + 'overlay-opacity': 0.14, + 'overlay-padding': 6 + } + }, + { + selector: 'node.streaming', + style: { + 'border-color': '#e879f9' + } + }, + { + selector: 'node.muted', + style: { + 'background-blacken': 0.28 + } + }, + { + selector: 'node.deafened', + style: { + 'border-style': 'dashed' + } + }, + { + selector: 'node.inactive', + style: { + opacity: 0.45 + } + }, + { + selector: 'edge', + style: { + color: '#e2e8f0', + 'curve-style': 'bezier', + 'font-family': 'Inter, ui-sans-serif, system-ui, sans-serif', + 'font-size': 10, + 'line-color': '#64748b', + label: 'data(label)', + opacity: 0.92, + 'target-arrow-shape': 'none', + 'text-background-color': '#0f172acc', + 'text-background-opacity': 0, + 'text-background-padding': 0, + 'text-margin-y': -10, + 'text-max-width': 120, + 'text-outline-width': 0, + 'text-wrap': 'wrap', + width: 2.5 + } + }, + { + selector: 'edge.has-label', + style: { + 'text-background-opacity': 1, + 'text-background-padding': 3 + } + }, + { + selector: 'edge.signaling', + style: { + 'line-color': '#fb923c', + 'line-style': 'dashed' + } + }, + { + selector: 'edge.peer', + style: { + 'line-color': '#22c55e' + } + }, + { + selector: 'edge.membership', + style: { + 'line-color': '#c084fc', + 'line-style': 'dotted', + width: 1.5 + } + }, + { + selector: 'edge.inactive', + style: { + opacity: 0.3 + } + } + ]; + } + + private buildCompactStatusLine(node: DebugNetworkNode): string | null { + const tokens: string[] = []; + + if (node.isSpeaking) + tokens.push('Speaking'); + else if (node.isTyping) + tokens.push('Typing'); + + if (node.isMuted) + tokens.push('Muted'); + else if (node.isVoiceConnected) + tokens.push('Mic on'); + + if (node.isStreaming) + tokens.push('Screen'); + + if (node.pingMs !== null) + tokens.push(`${node.pingMs} ms`); + + return tokens.length > 0 ? tokens.join(' • ') : null; + } + + private hasHandshakeActivity(node: DebugNetworkNode): boolean { + return node.handshake.offersSent > 0 + || node.handshake.offersReceived > 0 + || node.handshake.answersSent > 0 + || node.handshake.answersReceived > 0 + || node.handshake.iceSent > 0 + || node.handshake.iceReceived > 0; + } + + private shortenIdentifier(value: string): string { + if (value.length <= 18) + return value; + + return `${value.slice(0, 8)}…${value.slice(-6)}`; + } +} diff --git a/src/app/shared/components/debug-console/debug-console-toolbar/debug-console-toolbar.component.html b/src/app/shared/components/debug-console/debug-console-toolbar/debug-console-toolbar.component.html new file mode 100644 index 0000000..b49742e --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console-toolbar/debug-console-toolbar.component.html @@ -0,0 +1,149 @@ +
+
+
+
+ Debug Console + @if (activeTab() === 'logs') { + {{ visibleCount() }} visible + } @else { + {{ networkSummary().clientCount }} clients · {{ networkSummary().peerConnectionCount }} links + } +
+

+ {{ + activeTab() === 'logs' + ? 'Search logs, filter by level or source, and inspect timestamps inline.' + : 'Visualize signaling, peer links, typing, speaking, streaming, and grouped traffic directly from captured debug data.' + }} +

+
+ +
+ + + + + + + +
+
+ +
+ @for (tab of tabs; track tab) { + + } +
+ + @if (activeTab() === 'logs') { +
+ + + +
+ +
+
+ + Levels +
+ + @for (level of levels; track level) { + + } +
+ } @else { +
+

Traffic is grouped by edge and message type to keep signaling, voice-state, and screen-state chatter readable.

+ +
+ {{ networkSummary().typingCount }} typing + {{ networkSummary().speakingCount }} speaking + {{ networkSummary().streamingCount }} streaming + {{ networkSummary().membershipCount }} memberships +
+
+ } +
diff --git a/src/app/shared/components/debug-console/debug-console-toolbar/debug-console-toolbar.component.ts b/src/app/shared/components/debug-console/debug-console-toolbar/debug-console-toolbar.component.ts new file mode 100644 index 0000000..34c6e8e --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console-toolbar/debug-console-toolbar.component.ts @@ -0,0 +1,171 @@ +import { + Component, + input, + output +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + lucideFilter, + lucidePause, + lucidePlay, + lucideSearch, + lucideTrash2, + lucideX +} from '@ng-icons/lucide'; + +type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug'; + +interface DebugNetworkSummary { + clientCount: number; + serverCount: number; + signalingServerCount: number; + peerConnectionCount: number; + membershipCount: number; + messageCount: number; + typingCount: number; + speakingCount: number; + streamingCount: number; +} + +@Component({ + selector: 'app-debug-console-toolbar', + standalone: true, + imports: [CommonModule, NgIcon], + viewProviders: [ + provideIcons({ + lucideFilter, + lucidePause, + lucidePlay, + lucideSearch, + lucideTrash2, + lucideX + }) + ], + templateUrl: './debug-console-toolbar.component.html' +}) +export class DebugConsoleToolbarComponent { + readonly activeTab = input.required<'logs' | 'network'>(); + readonly detached = input.required(); + readonly searchTerm = input.required(); + readonly selectedSource = input.required(); + readonly sourceOptions = input.required(); + readonly levelState = input.required>(); + readonly levelCounts = input.required>(); + readonly visibleCount = input.required(); + readonly autoScroll = input.required(); + readonly networkSummary = input.required(); + + readonly activeTabChange = output<'logs' | 'network'>(); + readonly detachToggled = output(); + readonly searchTermChange = output(); + readonly selectedSourceChange = output(); + readonly levelToggled = output(); + readonly autoScrollToggled = output(); + readonly clearRequested = output(); + readonly closeRequested = output(); + + readonly levels: DebugLogLevel[] = [ + 'event', + 'info', + 'warn', + 'error', + 'debug' + ]; + + readonly tabs: ('logs' | 'network')[] = ['logs', 'network']; + + setActiveTab(tab: 'logs' | 'network'): void { + this.activeTabChange.emit(tab); + } + + toggleDetached(): void { + this.detachToggled.emit(undefined); + } + + onSearchInput(event: Event): void { + const input = event.target as HTMLInputElement; + + this.searchTermChange.emit(input.value); + } + + onSourceChange(event: Event): void { + const select = event.target as HTMLSelectElement; + + this.selectedSourceChange.emit(select.value); + } + + toggleLevel(level: DebugLogLevel): void { + this.levelToggled.emit(level); + } + + toggleAutoScroll(): void { + this.autoScrollToggled.emit(undefined); + } + + clear(): void { + this.clearRequested.emit(undefined); + } + + close(): void { + this.closeRequested.emit(undefined); + } + + getDetachLabel(): string { + return this.detached() ? 'Dock' : 'Detach'; + } + + getTabLabel(tab: 'logs' | 'network'): string { + return tab === 'logs' ? 'Logs' : 'Network'; + } + + getTabButtonClass(tab: 'logs' | 'network'): string { + const isActive = this.activeTab() === tab; + const base = 'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors'; + + if (!isActive) + return base + ' border-border bg-transparent text-muted-foreground hover:bg-secondary/60'; + + return base + ' border-primary/40 bg-primary/10 text-primary'; + } + + getLevelLabel(level: DebugLogLevel): string { + switch (level) { + case 'event': + return 'Events'; + case 'info': + return 'Info'; + case 'warn': + return 'Warn'; + case 'error': + return 'Error'; + case 'debug': + return 'Debug'; + } + + return 'Unknown'; + } + + getLevelButtonClass(level: DebugLogLevel): string { + const isActive = this.levelState()[level]; + const base = 'rounded-full border px-3 py-1 text-xs font-medium transition-colors'; + + if (!isActive) + return base + ' border-border bg-transparent text-muted-foreground hover:bg-secondary/60'; + + switch (level) { + case 'event': + return base + ' border-primary/40 bg-primary/10 text-primary'; + case 'info': + return base + ' border-sky-500/40 bg-sky-500/10 text-sky-300'; + case 'warn': + return base + ' border-yellow-500/40 bg-yellow-500/10 text-yellow-300'; + case 'error': + return base + ' border-destructive/40 bg-destructive/10 text-destructive'; + case 'debug': + return base + ' border-fuchsia-500/40 bg-fuchsia-500/10 text-fuchsia-300'; + } + + return base + ' border-border bg-transparent text-muted-foreground'; + } +} diff --git a/src/app/shared/components/debug-console/debug-console.component.html b/src/app/shared/components/debug-console/debug-console.component.html new file mode 100644 index 0000000..0c7f5a7 --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console.component.html @@ -0,0 +1,174 @@ +@if (debugging.enabled()) { + @if (showLauncher()) { + @if (launcherVariant() === 'floating') { + + } @else if (launcherVariant() === 'compact') { + + } @else { + + } + } + + @if (showPanel() && isOpen()) { +
+
+ + + + + @if (detached()) { + + } + + + + @if (activeTab() === 'logs') { + + } @else { + + } +
+
+ } +} diff --git a/src/app/shared/components/debug-console/debug-console.component.ts b/src/app/shared/components/debug-console/debug-console.component.ts new file mode 100644 index 0000000..b22c98b --- /dev/null +++ b/src/app/shared/components/debug-console/debug-console.component.ts @@ -0,0 +1,348 @@ +import { + Component, + HostListener, + computed, + effect, + inject, + input, + signal +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideBug } from '@ng-icons/lucide'; + +import { DebuggingService, type DebugLogLevel } from '../../../core/services/debugging.service'; +import { DebugConsoleEntryListComponent } from './debug-console-entry-list/debug-console-entry-list.component'; +import { DebugConsoleNetworkMapComponent } from './debug-console-network-map/debug-console-network-map.component'; +import { DebugConsoleToolbarComponent } from './debug-console-toolbar/debug-console-toolbar.component'; + +type DebugLevelState = Record; + +type DebugConsoleTab = 'logs' | 'network'; +type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact'; + +@Component({ + selector: 'app-debug-console', + standalone: true, + imports: [ + CommonModule, + NgIcon, + DebugConsoleEntryListComponent, + DebugConsoleNetworkMapComponent, + DebugConsoleToolbarComponent + ], + viewProviders: [ + provideIcons({ + lucideBug + }) + ], + templateUrl: './debug-console.component.html', + host: { + style: 'display: contents;', + 'data-debug-console-root': 'true' + } +}) +export class DebugConsoleComponent { + readonly debugging = inject(DebuggingService); + readonly entries = this.debugging.entries; + readonly isOpen = this.debugging.isConsoleOpen; + readonly networkSnapshot = this.debugging.networkSnapshot; + readonly launcherVariant = input('floating'); + readonly showLauncher = input(true); + readonly showPanel = input(true); + + readonly activeTab = signal('logs'); + readonly detached = signal(false); + readonly searchTerm = signal(''); + readonly selectedSource = signal('all'); + readonly autoScroll = signal(true); + readonly panelHeight = signal(360); + readonly panelWidth = signal(832); + readonly panelLeft = signal(0); + readonly panelTop = signal(0); + readonly levelState = signal({ + event: true, + info: true, + warn: true, + error: true, + debug: true + }); + + readonly sourceOptions = computed(() => { + return Array.from(new Set(this.entries().map((entry) => entry.source))).sort(); + }); + readonly filteredEntries = computed(() => { + const searchTerm = this.searchTerm().trim() + .toLowerCase(); + const selectedSource = this.selectedSource(); + const levelState = this.levelState(); + + return this.entries().filter((entry) => { + if (!levelState[entry.level]) + return false; + + if (selectedSource !== 'all' && entry.source !== selectedSource) + return false; + + if (!searchTerm) + return true; + + return [ + entry.message, + entry.source, + entry.level, + entry.timeLabel, + entry.dateTimeLabel, + entry.payloadText || '' + ].some((value) => value.toLowerCase().includes(searchTerm)); + }); + }); + readonly levelCounts = computed>(() => { + const counts: Record = { + event: 0, + info: 0, + warn: 0, + error: 0, + debug: 0 + }; + + for (const entry of this.entries()) { + counts[entry.level] += entry.count; + } + + return counts; + }); + readonly visibleCount = computed(() => { + return this.filteredEntries().reduce((sum, entry) => sum + entry.count, 0); + }); + readonly badgeCount = computed(() => { + const counts = this.levelCounts(); + + return counts.error > 0 ? counts.error : this.entries().reduce((sum, entry) => sum + entry.count, 0); + }); + readonly hasErrors = computed(() => this.levelCounts().error > 0); + readonly networkSummary = computed(() => this.networkSnapshot().summary); + + private dragging = false; + private resizingHeight = false; + private resizingWidth = false; + private resizeOriginY = 0; + private resizeOriginX = 0; + private resizeOriginHeight = 360; + private resizeOriginWidth = 832; + private panelOriginLeft = 0; + private panelOriginTop = 0; + + constructor() { + this.syncPanelBounds(); + + effect(() => { + const selectedSource = this.selectedSource(); + const sourceOptions = this.sourceOptions(); + + if (selectedSource !== 'all' && !sourceOptions.includes(selectedSource)) + this.selectedSource.set('all'); + }); + } + + @HostListener('window:mousemove', ['$event']) + onResizeMove(event: MouseEvent): void { + if (this.dragging) { + this.updateDetachedPosition(event); + return; + } + + if (this.resizingWidth) { + this.updatePanelWidth(event); + return; + } + + if (!this.resizingHeight) + return; + + this.updatePanelHeight(event); + } + + @HostListener('window:mouseup') + onResizeEnd(): void { + this.dragging = false; + this.resizingHeight = false; + this.resizingWidth = false; + } + + @HostListener('window:resize') + onWindowResize(): void { + this.syncPanelBounds(); + } + + toggleConsole(): void { + this.debugging.toggleConsole(); + } + + closeConsole(): void { + this.debugging.closeConsole(); + } + + updateSearchTerm(value: string): void { + this.searchTerm.set(value); + } + + updateSelectedSource(source: string): void { + this.selectedSource.set(source); + } + + setActiveTab(tab: DebugConsoleTab): void { + this.activeTab.set(tab); + } + + toggleDetached(): void { + const nextDetached = !this.detached(); + + this.detached.set(nextDetached); + this.syncPanelBounds(); + + if (nextDetached) + this.initializeDetachedPosition(); + } + + toggleLevel(level: DebugLogLevel): void { + this.levelState.update((current) => ({ + ...current, + [level]: !current[level] + })); + } + + toggleAutoScroll(): void { + this.autoScroll.update((enabled) => !enabled); + } + + clearLogs(): void { + this.debugging.clear(); + } + + startResize(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.resizingHeight = true; + this.resizeOriginY = event.clientY; + this.resizeOriginHeight = this.panelHeight(); + this.panelOriginTop = this.panelTop(); + } + + startWidthResize(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.resizingWidth = true; + this.resizeOriginX = event.clientX; + this.resizeOriginWidth = this.panelWidth(); + this.panelOriginLeft = this.panelLeft(); + } + + startDrag(event: MouseEvent): void { + if (!this.detached()) + return; + + event.preventDefault(); + event.stopPropagation(); + this.dragging = true; + this.resizeOriginX = event.clientX; + this.resizeOriginY = event.clientY; + this.panelOriginLeft = this.panelLeft(); + this.panelOriginTop = this.panelTop(); + } + + formatBadgeCount(count: number): string { + if (count > 99) + return '99+'; + + return count.toString(); + } + + private updatePanelHeight(event: MouseEvent): void { + const delta = this.resizeOriginY - event.clientY; + const nextHeight = this.clampPanelHeight(this.resizeOriginHeight + delta); + + this.panelHeight.set(nextHeight); + + if (!this.detached()) + return; + + const originBottom = this.panelOriginTop + this.resizeOriginHeight; + const maxTop = this.getMaxPanelTop(nextHeight); + + this.panelTop.set(this.clampValue(originBottom - nextHeight, 16, maxTop)); + } + + private updatePanelWidth(event: MouseEvent): void { + const delta = this.resizeOriginX - event.clientX; + const nextWidth = this.clampPanelWidth(this.resizeOriginWidth + delta); + + this.panelWidth.set(nextWidth); + + if (!this.detached()) + return; + + const originRight = this.panelOriginLeft + this.resizeOriginWidth; + const maxLeft = this.getMaxPanelLeft(nextWidth); + + this.panelLeft.set(this.clampValue(originRight - nextWidth, 16, maxLeft)); + } + + private updateDetachedPosition(event: MouseEvent): void { + const nextLeft = this.panelOriginLeft + (event.clientX - this.resizeOriginX); + const nextTop = this.panelOriginTop + (event.clientY - this.resizeOriginY); + + this.panelLeft.set(this.clampValue(nextLeft, 16, this.getMaxPanelLeft(this.panelWidth()))); + this.panelTop.set(this.clampValue(nextTop, 16, this.getMaxPanelTop(this.panelHeight()))); + } + + private initializeDetachedPosition(): void { + if (this.panelLeft() > 0 || this.panelTop() > 0) { + this.clampDetachedPosition(); + return; + } + + this.panelLeft.set(this.getMaxPanelLeft(this.panelWidth())); + this.panelTop.set(this.clampValue(window.innerHeight - this.panelHeight() - 96, 16, this.getMaxPanelTop(this.panelHeight()))); + } + + private clampPanelHeight(height: number): number { + const maxHeight = this.detached() + ? Math.max(260, window.innerHeight - 32) + : Math.floor(window.innerHeight * 0.75); + + return Math.min(Math.max(height, 260), maxHeight); + } + + private clampPanelWidth(width: number): number { + const maxWidth = Math.max(360, window.innerWidth - 32); + const minWidth = Math.min(460, maxWidth); + + return Math.min(Math.max(width, minWidth), maxWidth); + } + + private clampDetachedPosition(): void { + this.panelLeft.set(this.clampValue(this.panelLeft(), 16, this.getMaxPanelLeft(this.panelWidth()))); + this.panelTop.set(this.clampValue(this.panelTop(), 16, this.getMaxPanelTop(this.panelHeight()))); + } + + private getMaxPanelLeft(width: number): number { + return Math.max(16, window.innerWidth - width - 16); + } + + private getMaxPanelTop(height: number): number { + return Math.max(16, window.innerHeight - height - 16); + } + + private syncPanelBounds(): void { + this.panelWidth.update((width) => this.clampPanelWidth(width)); + this.panelHeight.update((height) => this.clampPanelHeight(height)); + + if (this.detached()) + this.clampDetachedPosition(); + } + + private clampValue(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } +} diff --git a/src/app/shared/index.ts b/src/app/shared/index.ts index 5d76615..965f8e6 100644 --- a/src/app/shared/index.ts +++ b/src/app/shared/index.ts @@ -7,4 +7,5 @@ export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dial export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component'; export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-audio-player.component'; export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component'; +export { DebugConsoleComponent } from './components/debug-console/debug-console.component'; export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component'; diff --git a/src/app/store/messages/messages-incoming.handlers.ts b/src/app/store/messages/messages-incoming.handlers.ts index e7d02e8..c8b75ed 100644 --- a/src/app/store/messages/messages-incoming.handlers.ts +++ b/src/app/store/messages/messages-incoming.handlers.ts @@ -18,7 +18,9 @@ import { import { mergeMap } from 'rxjs/operators'; import { Action } from '@ngrx/store'; import { Message } from '../../core/models/index'; +import type { DebuggingService } from '../../core/services'; import { DatabaseService } from '../../core/services/database.service'; +import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; import { WebRTCService } from '../../core/services/webrtc.service'; import { AttachmentService } from '../../core/services/attachment.service'; import { MessagesActions } from './messages.actions'; @@ -39,6 +41,7 @@ export interface IncomingMessageContext { db: DatabaseService; webrtc: WebRTCService; attachments: AttachmentService; + debugging: DebuggingService; currentUser: any; currentRoom: any; } @@ -256,7 +259,7 @@ function requestMissingImages( /** Saves an incoming chat message to DB and dispatches receiveMessage. */ function handleChatMessage( event: any, - { db, currentUser }: IncomingMessageContext + { db, debugging, currentUser }: IncomingMessageContext ): Observable { const msg = event.message; @@ -271,22 +274,43 @@ function handleChatMessage( if (isOwnMessage) return EMPTY; - db.saveMessage(msg); + trackBackgroundOperation( + db.saveMessage(msg), + debugging, + 'Failed to persist incoming chat message', + { + channelId: msg.channelId || 'general', + fromPeerId: event.fromPeerId ?? null, + messageId: msg.id, + roomId: msg.roomId, + senderId: msg.senderId + } + ); + return of(MessagesActions.receiveMessage({ message: msg })); } /** Applies a remote message edit to the local DB and store. */ function handleMessageEdited( event: any, - { db }: IncomingMessageContext + { db, debugging }: IncomingMessageContext ): Observable { if (!event.messageId || !event.content) return EMPTY; - db.updateMessage(event.messageId, { - content: event.content, - editedAt: event.editedAt - }); + trackBackgroundOperation( + db.updateMessage(event.messageId, { + content: event.content, + editedAt: event.editedAt + }), + debugging, + 'Failed to persist incoming message edit', + { + editedAt: event.editedAt ?? null, + fromPeerId: event.fromPeerId ?? null, + messageId: event.messageId + } + ); return of( MessagesActions.editMessageSuccess({ @@ -300,12 +324,22 @@ function handleMessageEdited( /** Applies a remote message deletion to the local DB and store. */ function handleMessageDeleted( event: any, - { db }: IncomingMessageContext + { db, debugging }: IncomingMessageContext ): Observable { if (!event.messageId) return EMPTY; - db.deleteMessage(event.messageId); + trackBackgroundOperation( + db.deleteMessage(event.messageId), + debugging, + 'Failed to persist incoming message deletion', + { + deletedBy: event.deletedBy ?? null, + fromPeerId: event.fromPeerId ?? null, + messageId: event.messageId + } + ); + return of( MessagesActions.deleteMessageSuccess({ messageId: event.messageId }) ); @@ -314,24 +348,46 @@ function handleMessageDeleted( /** Saves an incoming reaction to DB and updates the store. */ function handleReactionAdded( event: any, - { db }: IncomingMessageContext + { db, debugging }: IncomingMessageContext ): Observable { if (!event.messageId || !event.reaction) return EMPTY; - db.saveReaction(event.reaction); + trackBackgroundOperation( + db.saveReaction(event.reaction), + debugging, + 'Failed to persist incoming reaction', + { + emoji: event.reaction.emoji, + fromPeerId: event.fromPeerId ?? null, + messageId: event.messageId, + reactionId: event.reaction.id + } + ); + return of(MessagesActions.addReactionSuccess({ reaction: event.reaction })); } /** Removes a reaction from DB and updates the store. */ function handleReactionRemoved( event: any, - { db }: IncomingMessageContext + { db, debugging }: IncomingMessageContext ): Observable { if (!event.messageId || !event.oderId || !event.emoji) return EMPTY; - db.removeReaction(event.messageId, event.oderId, event.emoji); + trackBackgroundOperation( + db.removeReaction(event.messageId, event.oderId, event.emoji), + debugging, + 'Failed to persist incoming reaction removal', + { + emoji: event.emoji, + fromPeerId: event.fromPeerId ?? null, + messageId: event.messageId, + oderId: event.oderId + } + ); + return of( MessagesActions.removeReactionSuccess({ messageId: event.messageId, @@ -442,12 +498,24 @@ function handleSyncRequest( /** Merges a full message dump from a peer into the local DB and store. */ function handleSyncFull( event: any, - { db }: IncomingMessageContext + { db, debugging }: IncomingMessageContext ): Observable { if (!event.messages || !Array.isArray(event.messages)) return EMPTY; - event.messages.forEach((msg: Message) => db.saveMessage(msg)); + event.messages.forEach((msg: Message) => { + trackBackgroundOperation( + db.saveMessage(msg), + debugging, + 'Failed to persist full-sync message batch item', + { + fromPeerId: event.fromPeerId ?? null, + messageId: msg.id, + roomId: msg.roomId + } + ); + }); + return of(MessagesActions.syncMessages({ messages: event.messages })); } @@ -493,3 +561,12 @@ export function dispatchIncomingMessage( return handler ? handler(event, ctx) : EMPTY; } + +function trackBackgroundOperation( + task: Promise | unknown, + debugging: DebuggingService, + message: string, + payload: Record +): void { + trackDebuggingTaskFailure(task, debugging, 'messages', message, payload); +} diff --git a/src/app/store/messages/messages-sync.effects.ts b/src/app/store/messages/messages-sync.effects.ts index b7df0b3..bc29f07 100644 --- a/src/app/store/messages/messages-sync.effects.ts +++ b/src/app/store/messages/messages-sync.effects.ts @@ -40,6 +40,7 @@ import { RoomsActions } from '../rooms/rooms.actions'; import { selectMessagesSyncing } from './messages.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { DatabaseService } from '../../core/services/database.service'; +import { DebuggingService } from '../../core/services/debugging.service'; import { WebRTCService } from '../../core/services/webrtc.service'; import { INVENTORY_LIMIT, @@ -55,6 +56,7 @@ export class MessagesSyncEffects { private readonly actions$ = inject(Actions); private readonly store = inject(Store); private readonly db = inject(DatabaseService); + private readonly debugging = inject(DebuggingService); private readonly webrtc = inject(WebRTCService); /** Tracks whether the last sync cycle found no new messages. */ @@ -135,8 +137,12 @@ export class MessagesSyncEffects { type: 'chat-inventory-request', roomId: activeRoom.id } as any); - } catch { - /* peer may have disconnected */ + } catch (error) { + this.debugging.warn('messages', 'Failed to kick off room sync for peer', { + error, + peerId: pid, + roomId: activeRoom.id + }); } } }) @@ -181,15 +187,24 @@ export class MessagesSyncEffects { type: 'chat-inventory-request', roomId: room.id } as any); - } catch { - /* peer may have disconnected */ + } catch (error) { + this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', { + error, + peerId: pid, + roomId: room.id + }); } } return MessagesActions.startSync(); }), - catchError(() => { + catchError((error) => { this.lastSyncClean = false; + this.debugging.warn('messages', 'Periodic sync poll failed', { + error, + roomId: room.id + }); + return of(MessagesActions.syncComplete()); }) ); diff --git a/src/app/store/messages/messages.effects.ts b/src/app/store/messages/messages.effects.ts index 83d3e12..6b7b5fa 100644 --- a/src/app/store/messages/messages.effects.ts +++ b/src/app/store/messages/messages.effects.ts @@ -32,6 +32,8 @@ import { MessagesActions } from './messages.actions'; import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { DatabaseService } from '../../core/services/database.service'; +import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; +import { DebuggingService } from '../../core/services'; import { WebRTCService } from '../../core/services/webrtc.service'; import { TimeSyncService } from '../../core/services/time-sync.service'; import { AttachmentService } from '../../core/services/attachment.service'; @@ -44,6 +46,7 @@ export class MessagesEffects { private readonly actions$ = inject(Actions); private readonly store = inject(Store); private readonly db = inject(DatabaseService); + private readonly debugging = inject(DebuggingService); private readonly webrtc = inject(WebRTCService); private readonly timeSync = inject(TimeSyncService); private readonly attachments = inject(AttachmentService); @@ -97,7 +100,17 @@ export class MessagesEffects { replyToId }; - this.db.saveMessage(message); + this.trackBackgroundOperation( + this.db.saveMessage(message), + 'Failed to persist outgoing chat message', + { + channelId: message.channelId, + contentLength: message.content.length, + messageId: message.id, + roomId: message.roomId + } + ); + this.webrtc.broadcastMessage({ type: 'chat-message', message }); @@ -131,8 +144,16 @@ export class MessagesEffects { const editedAt = this.timeSync.now(); - this.db.updateMessage(messageId, { content, - editedAt }); + this.trackBackgroundOperation( + this.db.updateMessage(messageId, { content, + editedAt }), + 'Failed to persist edited chat message', + { + contentLength: content.length, + editedAt, + messageId + } + ); this.webrtc.broadcastMessage({ type: 'message-edited', messageId, @@ -171,7 +192,12 @@ export class MessagesEffects { return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' })); } - this.db.updateMessage(messageId, { isDeleted: true }); + this.trackBackgroundOperation( + this.db.updateMessage(messageId, { isDeleted: true }), + 'Failed to persist message deletion', + { messageId } + ); + this.webrtc.broadcastMessage({ type: 'message-deleted', messageId }); @@ -204,7 +230,15 @@ export class MessagesEffects { return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' })); } - this.db.updateMessage(messageId, { isDeleted: true }); + this.trackBackgroundOperation( + this.db.updateMessage(messageId, { isDeleted: true }), + 'Failed to persist admin message deletion', + { + deletedBy: currentUser.id, + messageId + } + ); + this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedBy: currentUser.id }); @@ -235,7 +269,17 @@ export class MessagesEffects { timestamp: this.timeSync.now() }; - this.db.saveReaction(reaction); + this.trackBackgroundOperation( + this.db.saveReaction(reaction), + 'Failed to persist reaction', + { + emoji, + messageId, + reactionId: reaction.id, + userId: currentUser.id + } + ); + this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction }); @@ -254,7 +298,16 @@ export class MessagesEffects { if (!currentUser) return EMPTY; - this.db.removeReaction(messageId, currentUser.id, emoji); + this.trackBackgroundOperation( + this.db.removeReaction(messageId, currentUser.id, emoji), + 'Failed to persist reaction removal', + { + emoji, + messageId, + userId: currentUser.id + } + ); + this.webrtc.broadcastMessage({ type: 'reaction-removed', messageId, @@ -286,18 +339,43 @@ export class MessagesEffects { mergeMap(([ event, currentUser, - currentRoom]: [any, any, any + currentRoom ]) => { const ctx: IncomingMessageContext = { db: this.db, webrtc: this.webrtc, attachments: this.attachments, + debugging: this.debugging, currentUser, currentRoom }; - return dispatchIncomingMessage(event, ctx); + return dispatchIncomingMessage(event, ctx).pipe( + catchError((error) => { + const eventRecord = event as unknown as Record; + const messageRecord = (eventRecord['message'] && typeof eventRecord['message'] === 'object' && !Array.isArray(eventRecord['message'])) + ? eventRecord['message'] as Record + : null; + + reportDebuggingError(this.debugging, 'messages', 'Failed to process incoming peer message', { + eventType: typeof eventRecord['type'] === 'string' ? eventRecord['type'] : 'unknown', + fromPeerId: typeof eventRecord['fromPeerId'] === 'string' ? eventRecord['fromPeerId'] : null, + messageId: typeof eventRecord['messageId'] === 'string' + ? eventRecord['messageId'] + : (typeof messageRecord?.['id'] === 'string' ? messageRecord['id'] : null), + roomId: typeof eventRecord['roomId'] === 'string' + ? eventRecord['roomId'] + : (typeof messageRecord?.['roomId'] === 'string' ? messageRecord['roomId'] : null) + }, error); + + return EMPTY; + }) + ); }) ) ); + + private trackBackgroundOperation(task: Promise | unknown, message: string, payload: Record): void { + trackDebuggingTaskFailure(task, this.debugging, 'messages', message, payload); + } }