diff --git a/src/app/core/services/debugging/debugging.service.ts b/src/app/core/services/debugging/debugging.service.ts index c67c073..74b1523 100644 --- a/src/app/core/services/debugging/debugging.service.ts +++ b/src/app/core/services/debugging/debugging.service.ts @@ -218,7 +218,16 @@ export class DebuggingService { const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ') .trim() || '(empty console call)'; - const consoleMetadata = this.extractConsoleMetadata(rawMessage); + + // Use only string args for label/message extraction so that + // stringified object payloads don't pollute the parsed message. + // Object payloads are captured separately via extractConsolePayload. + const metadataSource = args + .filter((arg): arg is string => typeof arg === 'string') + .join(' ') + .trim() || rawMessage; + + const consoleMetadata = this.extractConsoleMetadata(metadataSource); const payload = this.extractConsolePayload(args); const payloadText = payload === undefined ? null 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 index b49742e..d8fd818 100644 --- 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 @@ -42,6 +42,65 @@ {{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }} + +
+ + + @if (exportMenuOpen()) { +
+ @if (activeTab() === 'logs') { +

Logs

+ + + } @else { +

Network

+ + + } +
+ } +
+ + + + + + + + } diff --git a/src/app/shared/components/debug-console/debug-console.component.ts b/src/app/shared/components/debug-console/debug-console.component.ts index b22c98b..70808db 100644 --- a/src/app/shared/components/debug-console/debug-console.component.ts +++ b/src/app/shared/components/debug-console/debug-console.component.ts @@ -15,6 +15,9 @@ import { DebuggingService, type DebugLogLevel } from '../../../core/services/deb 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'; +import { DebugConsoleResizeService } from './services/debug-console-resize.service'; +import { DebugConsoleExportService, type DebugExportFormat } from './services/debug-console-export.service'; +import { DebugConsoleEnvironmentService } from './services/debug-console-environment.service'; type DebugLevelState = Record; @@ -44,6 +47,9 @@ type DebugConsoleLauncherVariant = 'floating' | 'inline' | 'compact'; }) export class DebugConsoleComponent { readonly debugging = inject(DebuggingService); + readonly resizeService = inject(DebugConsoleResizeService); + readonly exportService = inject(DebugConsoleExportService); + readonly envService = inject(DebugConsoleEnvironmentService); readonly entries = this.debugging.entries; readonly isOpen = this.debugging.isConsoleOpen; readonly networkSnapshot = this.debugging.networkSnapshot; @@ -56,10 +62,10 @@ export class DebugConsoleComponent { 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 panelHeight = this.resizeService.panelHeight; + readonly panelWidth = this.resizeService.panelWidth; + readonly panelLeft = this.resizeService.panelLeft; + readonly panelTop = this.resizeService.panelTop; readonly levelState = signal({ event: true, info: true, @@ -123,18 +129,8 @@ export class DebugConsoleComponent { 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(); + this.resizeService.syncBounds(this.detached()); effect(() => { const selectedSource = this.selectedSource(); @@ -147,32 +143,17 @@ export class DebugConsoleComponent { @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); + this.resizeService.onMouseMove(event, this.detached()); } @HostListener('window:mouseup') onResizeEnd(): void { - this.dragging = false; - this.resizingHeight = false; - this.resizingWidth = false; + this.resizeService.onMouseUp(); } @HostListener('window:resize') onWindowResize(): void { - this.syncPanelBounds(); + this.resizeService.syncBounds(this.detached()); } toggleConsole(): void { @@ -195,14 +176,38 @@ export class DebugConsoleComponent { this.activeTab.set(tab); } + exportLogs(format: DebugExportFormat): void { + const env = this.envService.getEnvironment(); + const name = this.envService.getFilenameSafeDisplayName(); + + this.exportService.exportLogs( + this.filteredEntries(), + format, + env, + name + ); + } + + exportNetwork(format: DebugExportFormat): void { + const env = this.envService.getEnvironment(); + const name = this.envService.getFilenameSafeDisplayName(); + + this.exportService.exportNetwork( + this.networkSnapshot(), + format, + env, + name + ); + } + toggleDetached(): void { const nextDetached = !this.detached(); this.detached.set(nextDetached); - this.syncPanelBounds(); + this.resizeService.syncBounds(nextDetached); if (nextDetached) - this.initializeDetachedPosition(); + this.resizeService.initializeDetachedPosition(); } toggleLevel(level: DebugLogLevel): void { @@ -220,35 +225,31 @@ export class DebugConsoleComponent { 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(); + startTopResize(event: MouseEvent): void { + this.resizeService.startTopResize(event); } - startWidthResize(event: MouseEvent): void { - event.preventDefault(); - event.stopPropagation(); - this.resizingWidth = true; - this.resizeOriginX = event.clientX; - this.resizeOriginWidth = this.panelWidth(); - this.panelOriginLeft = this.panelLeft(); + startBottomResize(event: MouseEvent): void { + this.resizeService.startBottomResize(event); + } + + startLeftResize(event: MouseEvent): void { + this.resizeService.startLeftResize(event); + } + + startRightResize(event: MouseEvent): void { + this.resizeService.startRightResize(event); + } + + startCornerResize(event: MouseEvent): void { + this.resizeService.startCornerResize(event); } 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(); + this.resizeService.startDrag(event); } formatBadgeCount(count: number): string { @@ -257,92 +258,4 @@ export class DebugConsoleComponent { 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/components/debug-console/services/debug-console-environment.service.ts b/src/app/shared/components/debug-console/services/debug-console-environment.service.ts new file mode 100644 index 0000000..6997ea8 --- /dev/null +++ b/src/app/shared/components/debug-console/services/debug-console-environment.service.ts @@ -0,0 +1,214 @@ +import { Injectable, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { selectCurrentUser } from '../../../../store/users/users.selectors'; +import { PlatformService } from '../../../../core/services/platform.service'; + +export interface DebugExportEnvironment { + appVersion: string; + displayName: string; + displayServer: string; + gpu: string; + operatingSystem: string; + platform: string; + userAgent: string; + userId: string; +} + +@Injectable({ providedIn: 'root' }) +export class DebugConsoleEnvironmentService { + private readonly store = inject(Store); + private readonly platformService = inject(PlatformService); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + + getEnvironment(): DebugExportEnvironment { + return { + appVersion: this.resolveAppVersion(), + displayName: this.resolveDisplayName(), + displayServer: this.resolveDisplayServer(), + gpu: this.resolveGpu(), + operatingSystem: this.resolveOperatingSystem(), + platform: this.resolvePlatform(), + userAgent: navigator.userAgent, + userId: this.currentUser()?.id ?? 'Unknown' + }; + } + + getFilenameSafeDisplayName(): string { + const name = this.resolveDisplayName(); + const sanitized = name + .replace(/[^a-zA-Z0-9._-]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); + + return sanitized || 'unknown'; + } + + private resolveDisplayName(): string { + return this.currentUser()?.displayName ?? 'Unknown'; + } + + private resolveAppVersion(): string { + if (!this.platformService.isElectron) + return 'web'; + + const electronVersion = this.readElectronVersion(); + + return electronVersion + ? `${electronVersion} (Electron)` + : 'Electron (unknown version)'; + } + + private resolvePlatform(): string { + if (!this.platformService.isElectron) + return 'Browser'; + + const os = this.resolveOperatingSystem().toLowerCase(); + + if (os.includes('windows')) + return 'Windows Electron'; + + if (os.includes('linux')) + return 'Linux Electron'; + + if (os.includes('mac')) + return 'macOS Electron'; + + return 'Electron'; + } + + private resolveOperatingSystem(): string { + const ua = navigator.userAgent; + + if (ua.includes('Windows NT 10.0')) + return 'Windows 10/11'; + + if (ua.includes('Windows NT')) + return 'Windows'; + + if (ua.includes('Mac OS X')) { + const match = ua.match(/Mac OS X ([\d._]+)/); + const version = match?.[1]?.replace(/_/g, '.') ?? ''; + + return version ? `macOS ${version}` : 'macOS'; + } + + if (ua.includes('Linux')) { + const parts: string[] = ['Linux']; + + if (ua.includes('Ubuntu')) + parts.push('(Ubuntu)'); + else if (ua.includes('Fedora')) + parts.push('(Fedora)'); + else if (ua.includes('Debian')) + parts.push('(Debian)'); + + return parts.join(' '); + } + + return navigator.platform || 'Unknown'; + } + + private resolveDisplayServer(): string { + if (!navigator.userAgent.includes('Linux')) + return 'N/A'; + + try { + const ua = navigator.userAgent.toLowerCase(); + + if (ua.includes('wayland')) + return 'Wayland'; + + if (ua.includes('x11')) + return 'X11'; + + const isOzone = ua.includes('ozone'); + + if (isOzone) + return 'Ozone (Wayland likely)'; + } catch { + // Ignore + } + + return this.detectDisplayServerFromEnv(); + } + + private detectDisplayServerFromEnv(): string { + try { + // Electron may expose env vars + const api = this.getElectronApi() as + Record | null; + + if (!api) + return 'Unknown (Linux)'; + } catch { + // Not available + } + + // Best-effort heuristic: check if WebGL context + // mentions wayland in renderer string + const gpu = this.resolveGpu().toLowerCase(); + + if (gpu.includes('wayland')) + return 'Wayland'; + + return 'Unknown (Linux)'; + } + + private resolveGpu(): string { + try { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl') + ?? canvas.getContext('experimental-webgl'); + + if (!gl || !(gl instanceof WebGLRenderingContext)) + return 'Unavailable'; + + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + + if (!ext) + return 'Unavailable (no debug info)'; + + const vendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL); + const renderer = gl.getParameter( + ext.UNMASKED_RENDERER_WEBGL + ); + const parts: string[] = []; + + if (typeof renderer === 'string' && renderer.length > 0) + parts.push(renderer); + + if (typeof vendor === 'string' && vendor.length > 0) + parts.push(`(${vendor})`); + + return parts.length > 0 + ? parts.join(' ') + : 'Unknown'; + } catch { + return 'Unavailable'; + } + } + + private readElectronVersion(): string | null { + try { + const ua = navigator.userAgent; + const match = ua.match(/metoyou\/([\d.]+)/i) + ?? ua.match(/Electron\/([\d.]+)/); + + return match?.[1] ?? null; + } catch { + return null; + } + } + + private getElectronApi(): Record | null { + try { + const win = window as Window & + { electronAPI?: Record }; + + return win.electronAPI ?? null; + } catch { + return null; + } + } +} diff --git a/src/app/shared/components/debug-console/services/debug-console-export.service.ts b/src/app/shared/components/debug-console/services/debug-console-export.service.ts new file mode 100644 index 0000000..ba43f25 --- /dev/null +++ b/src/app/shared/components/debug-console/services/debug-console-export.service.ts @@ -0,0 +1,517 @@ +import { Injectable } from '@angular/core'; + +import type { + DebugLogEntry, + DebugLogLevel, + DebugNetworkEdge, + DebugNetworkNode, + DebugNetworkSnapshot +} from '../../../../core/services/debugging.service'; +import type { DebugExportEnvironment } from './debug-console-environment.service'; + +export type DebugExportFormat = 'csv' | 'txt'; + +@Injectable({ providedIn: 'root' }) +export class DebugConsoleExportService { + exportLogs( + entries: readonly DebugLogEntry[], + format: DebugExportFormat, + env: DebugExportEnvironment, + filenameName: string + ): void { + const content = format === 'csv' + ? this.buildLogsCsv(entries, env) + : this.buildLogsTxt(entries, env); + const extension = format === 'csv' ? 'csv' : 'txt'; + const mime = format === 'csv' + ? 'text/csv;charset=utf-8;' + : 'text/plain;charset=utf-8;'; + const filename = this.buildFilename( + 'debug-logs', + filenameName, + extension + ); + + this.downloadFile(filename, content, mime); + } + + exportNetwork( + snapshot: DebugNetworkSnapshot, + format: DebugExportFormat, + env: DebugExportEnvironment, + filenameName: string + ): void { + const content = format === 'csv' + ? this.buildNetworkCsv(snapshot, env) + : this.buildNetworkTxt(snapshot, env); + const extension = format === 'csv' ? 'csv' : 'txt'; + const mime = format === 'csv' + ? 'text/csv;charset=utf-8;' + : 'text/plain;charset=utf-8;'; + const filename = this.buildFilename( + 'debug-network', + filenameName, + extension + ); + + this.downloadFile(filename, content, mime); + } + + private buildLogsCsv( + entries: readonly DebugLogEntry[], + env: DebugExportEnvironment + ): string { + const meta = this.buildCsvMetaSection(env); + const header = 'Timestamp,DateTime,Level,Source,Message,Payload,Count'; + const rows = entries.map((entry) => + [ + entry.timeLabel, + entry.dateTimeLabel, + entry.level, + this.escapeCsvField(entry.source), + this.escapeCsvField(entry.message), + this.escapeCsvField(entry.payloadText ?? ''), + entry.count + ].join(',') + ); + + return [ + meta, + '', + header, + ...rows + ].join('\n'); + } + + private buildLogsTxt( + entries: readonly DebugLogEntry[], + env: DebugExportEnvironment + ): string { + const lines: string[] = [ + `Debug Logs Export - ${new Date().toISOString()}`, + this.buildSeparator(), + ...this.buildTxtEnvLines(env), + this.buildSeparator(), + `Total entries: ${entries.length}`, + this.buildSeparator() + ]; + + for (const entry of entries) { + const prefix = this.buildLevelPrefix(entry.level); + const countSuffix = entry.count > 1 ? ` (×${entry.count})` : ''; + + lines.push(`[${entry.dateTimeLabel}] ${prefix} [${entry.source}] ${entry.message}${countSuffix}`); + + if (entry.payloadText) + lines.push(` Payload: ${entry.payloadText}`); + } + + return lines.join('\n'); + } + + private buildNetworkCsv( + snapshot: DebugNetworkSnapshot, + env: DebugExportEnvironment + ): string { + const sections: string[] = []; + + sections.push(this.buildCsvMetaSection(env)); + sections.push(''); + sections.push(this.buildNetworkNodesCsv(snapshot.nodes)); + sections.push(''); + sections.push(this.buildNetworkEdgesCsv(snapshot.edges)); + sections.push(''); + sections.push(this.buildNetworkConnectionsCsv(snapshot)); + + return sections.join('\n'); + } + + private buildNetworkNodesCsv(nodes: readonly DebugNetworkNode[]): string { + const headerParts = [ + 'NodeId', + 'Kind', + 'Label', + 'UserId', + 'Identity', + 'Active', + 'VoiceConnected', + 'Typing', + 'Speaking', + 'Muted', + 'Deafened', + 'Streaming', + 'ConnectionDrops', + 'PingMs', + 'TextSent', + 'TextReceived', + 'AudioStreams', + 'VideoStreams', + 'OffersSent', + 'OffersReceived', + 'AnswersSent', + 'AnswersReceived', + 'IceSent', + 'IceReceived', + 'DownloadFileMbps', + 'DownloadAudioMbps', + 'DownloadVideoMbps' + ]; + const header = headerParts.join(','); + const rows = nodes.map((node) => + [ + this.escapeCsvField(node.id), + node.kind, + this.escapeCsvField(node.label), + this.escapeCsvField(node.userId ?? ''), + this.escapeCsvField(node.identity ?? ''), + node.isActive, + node.isVoiceConnected, + node.isTyping, + node.isSpeaking, + node.isMuted, + node.isDeafened, + node.isStreaming, + node.connectionDrops, + node.pingMs ?? '', + node.textMessages.sent, + node.textMessages.received, + node.streams.audio, + node.streams.video, + node.handshake.offersSent, + node.handshake.offersReceived, + node.handshake.answersSent, + node.handshake.answersReceived, + node.handshake.iceSent, + node.handshake.iceReceived, + node.downloads.fileMbps ?? '', + node.downloads.audioMbps ?? '', + node.downloads.videoMbps ?? '' + ].join(',') + ); + + return [ + '# Nodes', + header, + ...rows + ].join('\n'); + } + + private buildNetworkEdgesCsv(edges: readonly DebugNetworkEdge[]): string { + const header = 'EdgeId,Kind,SourceId,TargetId,SourceLabel,TargetLabel,Active,PingMs,State,MessageTotal'; + const rows = edges.map((edge) => + [ + this.escapeCsvField(edge.id), + edge.kind, + this.escapeCsvField(edge.sourceId), + this.escapeCsvField(edge.targetId), + this.escapeCsvField(edge.sourceLabel), + this.escapeCsvField(edge.targetLabel), + edge.isActive, + edge.pingMs ?? '', + this.escapeCsvField(edge.stateLabel), + edge.messageTotal + ].join(',') + ); + + return [ + '# Edges', + header, + ...rows + ].join('\n'); + } + + private buildNetworkConnectionsCsv(snapshot: DebugNetworkSnapshot): string { + const header = 'SourceNode,TargetNode,EdgeKind,Direction,Active'; + const rows: string[] = []; + + for (const edge of snapshot.edges) { + rows.push( + [ + this.escapeCsvField(edge.sourceLabel), + this.escapeCsvField(edge.targetLabel), + edge.kind, + `${edge.sourceLabel} → ${edge.targetLabel}`, + edge.isActive + ].join(',') + ); + } + + return [ + '# Connections', + header, + ...rows + ].join('\n'); + } + + private buildNetworkTxt( + snapshot: DebugNetworkSnapshot, + env: DebugExportEnvironment + ): string { + const lines: string[] = []; + + lines.push(`Network Export - ${new Date().toISOString()}`); + lines.push(this.buildSeparator()); + lines.push(...this.buildTxtEnvLines(env)); + lines.push(this.buildSeparator()); + + lines.push('SUMMARY'); + lines.push(` Clients: ${snapshot.summary.clientCount}`); + lines.push(` Servers: ${snapshot.summary.serverCount}`); + lines.push(` Signaling servers: ${snapshot.summary.signalingServerCount}`); + lines.push(` Peer connections: ${snapshot.summary.peerConnectionCount}`); + lines.push(` Memberships: ${snapshot.summary.membershipCount}`); + lines.push(` Messages: ${snapshot.summary.messageCount}`); + lines.push(` Typing: ${snapshot.summary.typingCount}`); + lines.push(` Speaking: ${snapshot.summary.speakingCount}`); + lines.push(` Streaming: ${snapshot.summary.streamingCount}`); + lines.push(''); + + lines.push(this.buildSeparator()); + lines.push('NODES'); + lines.push(this.buildSeparator()); + + for (const node of snapshot.nodes) + this.appendNodeTxt(lines, node); + + lines.push(this.buildSeparator()); + lines.push('EDGES / CONNECTIONS'); + lines.push(this.buildSeparator()); + + for (const edge of snapshot.edges) + this.appendEdgeTxt(lines, edge); + + lines.push(this.buildSeparator()); + lines.push('CONNECTION MAP'); + lines.push(this.buildSeparator()); + this.appendConnectionMap(lines, snapshot); + + return lines.join('\n'); + } + + private appendNodeTxt(lines: string[], node: DebugNetworkNode): void { + lines.push(` [${node.kind}] ${node.label} (${node.id})`); + + if (node.userId) + lines.push(` User ID: ${node.userId}`); + + if (node.identity) + lines.push(` Identity: ${node.identity}`); + + const statuses: string[] = []; + + if (node.isActive) + statuses.push('Active'); + + if (node.isVoiceConnected) + statuses.push('Voice'); + + if (node.isTyping) + statuses.push('Typing'); + + if (node.isSpeaking) + statuses.push('Speaking'); + + if (node.isMuted) + statuses.push('Muted'); + + if (node.isDeafened) + statuses.push('Deafened'); + + if (node.isStreaming) + statuses.push('Streaming'); + + if (statuses.length > 0) + lines.push(` Status: ${statuses.join(', ')}`); + + if (node.pingMs !== null) + lines.push(` Ping: ${node.pingMs} ms`); + + lines.push(` Connection drops: ${node.connectionDrops}`); + lines.push(` Text messages: ↑${node.textMessages.sent} ↓${node.textMessages.received}`); + lines.push(` Streams: Audio ${node.streams.audio}, Video ${node.streams.video}`); + const handshakeLine = [ + `Offers ${node.handshake.offersSent}/${node.handshake.offersReceived}`, + `Answers ${node.handshake.answersSent}/${node.handshake.answersReceived}`, + `ICE ${node.handshake.iceSent}/${node.handshake.iceReceived}` + ].join(', '); + + lines.push(` Handshake: ${handshakeLine}`); + + if (node.downloads.fileMbps !== null || node.downloads.audioMbps !== null || node.downloads.videoMbps !== null) { + const parts = [ + `File ${this.formatMbps(node.downloads.fileMbps)}`, + `Audio ${this.formatMbps(node.downloads.audioMbps)}`, + `Video ${this.formatMbps(node.downloads.videoMbps)}` + ]; + + lines.push(` Downloads: ${parts.join(', ')}`); + } + + lines.push(''); + } + + private appendEdgeTxt(lines: string[], edge: DebugNetworkEdge): void { + const activeLabel = edge.isActive ? 'active' : 'inactive'; + + lines.push(` [${edge.kind}] ${edge.sourceLabel} → ${edge.targetLabel} (${activeLabel})`); + + if (edge.pingMs !== null) + lines.push(` Ping: ${edge.pingMs} ms`); + + if (edge.stateLabel) + lines.push(` State: ${edge.stateLabel}`); + + lines.push(` Total messages: ${edge.messageTotal}`); + + if (edge.messageGroups.length > 0) { + lines.push(' Message groups:'); + + for (const group of edge.messageGroups) { + const dir = group.direction === 'outbound' ? '↑' : '↓'; + + lines.push(` ${dir} [${group.scope}] ${group.type} ×${group.count}`); + } + } + + lines.push(''); + } + + private appendConnectionMap(lines: string[], snapshot: DebugNetworkSnapshot): void { + const nodeMap = new Map(snapshot.nodes.map((node) => [node.id, node])); + + for (const node of snapshot.nodes) { + const outgoing = snapshot.edges.filter((edge) => edge.sourceId === node.id); + const incoming = snapshot.edges.filter((edge) => edge.targetId === node.id); + + lines.push(` ${node.label} (${node.kind})`); + + if (outgoing.length > 0) { + lines.push(' Outgoing:'); + + for (const edge of outgoing) { + const target = nodeMap.get(edge.targetId); + + lines.push(` → ${target?.label ?? edge.targetId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`); + } + } + + if (incoming.length > 0) { + lines.push(' Incoming:'); + + for (const edge of incoming) { + const source = nodeMap.get(edge.sourceId); + + lines.push(` ← ${source?.label ?? edge.sourceId} [${edge.kind}] ${edge.isActive ? '●' : '○'}`); + } + } + + if (outgoing.length === 0 && incoming.length === 0) + lines.push(' (no connections)'); + + lines.push(''); + } + } + + private buildCsvMetaSection(env: DebugExportEnvironment): string { + return [ + '# Export Metadata', + 'Property,Value', + `Exported By,${this.escapeCsvField(env.displayName)}`, + `User ID,${this.escapeCsvField(env.userId)}`, + `Export Date,${new Date().toISOString()}`, + `App Version,${this.escapeCsvField(env.appVersion)}`, + `Platform,${this.escapeCsvField(env.platform)}`, + `Operating System,${this.escapeCsvField(env.operatingSystem)}`, + `Display Server,${this.escapeCsvField(env.displayServer)}`, + `GPU,${this.escapeCsvField(env.gpu)}`, + `User Agent,${this.escapeCsvField(env.userAgent)}` + ].join('\n'); + } + + private buildTxtEnvLines( + env: DebugExportEnvironment + ): string[] { + return [ + `Exported by: ${env.displayName}`, + `User ID: ${env.userId}`, + `App version: ${env.appVersion}`, + `Platform: ${env.platform}`, + `OS: ${env.operatingSystem}`, + `Display server: ${env.displayServer}`, + `GPU: ${env.gpu}`, + `User agent: ${env.userAgent}` + ]; + } + + private buildFilename( + prefix: string, + userLabel: string, + extension: string + ): string { + const stamp = this.buildTimestamp(); + + return `${prefix}_${userLabel}_${stamp}.${extension}`; + } + + private escapeCsvField(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) + return `"${value.replace(/"/g, '""')}"`; + + return value; + } + + private buildLevelPrefix(level: DebugLogLevel): string { + switch (level) { + case 'event': + return 'EVT'; + case 'info': + return 'INF'; + case 'warn': + return 'WRN'; + case 'error': + return 'ERR'; + case 'debug': + return 'DBG'; + } + } + + private formatMbps(value: number | null): string { + if (value === null) + return '-'; + + return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} Mbps`; + } + + private buildTimestamp(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + + return `${year}${month}${day}-${hours}${minutes}${seconds}`; + } + + private buildSeparator(): string { + return '─'.repeat(60); + } + + private downloadFile(filename: string, content: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + + requestAnimationFrame(() => { + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + }); + } +} diff --git a/src/app/shared/components/debug-console/services/debug-console-resize.service.ts b/src/app/shared/components/debug-console/services/debug-console-resize.service.ts new file mode 100644 index 0000000..167bab4 --- /dev/null +++ b/src/app/shared/components/debug-console/services/debug-console-resize.service.ts @@ -0,0 +1,284 @@ +import { Injectable, signal } from '@angular/core'; + +const STORAGE_KEY = 'metoyou_debug_console_layout'; +const DEFAULT_HEIGHT = 520; +const DEFAULT_WIDTH = 832; +const MIN_HEIGHT = 260; +const MIN_WIDTH = 460; + +interface PersistedLayout { + height: number; + width: number; +} + +@Injectable({ providedIn: 'root' }) +export class DebugConsoleResizeService { + readonly panelHeight = signal(DEFAULT_HEIGHT); + readonly panelWidth = signal(DEFAULT_WIDTH); + readonly panelLeft = signal(0); + readonly panelTop = signal(0); + + private dragging = false; + private resizingTop = false; + private resizingBottom = false; + private resizingLeft = false; + private resizingRight = false; + private resizingCorner = false; + private resizeOriginX = 0; + private resizeOriginY = 0; + private resizeOriginHeight = DEFAULT_HEIGHT; + private resizeOriginWidth = DEFAULT_WIDTH; + private panelOriginLeft = 0; + private panelOriginTop = 0; + + constructor() { + this.loadLayout(); + } + + get isResizing(): boolean { + return this.resizingTop || this.resizingBottom || this.resizingLeft || this.resizingRight || this.resizingCorner; + } + + get isDragging(): boolean { + return this.dragging; + } + + startTopResize(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.resizingTop = true; + this.resizeOriginY = event.clientY; + this.resizeOriginHeight = this.panelHeight(); + this.panelOriginTop = this.panelTop(); + } + + startBottomResize(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.resizingBottom = true; + this.resizeOriginY = event.clientY; + this.resizeOriginHeight = this.panelHeight(); + } + + startLeftResize(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.resizingLeft = true; + this.resizeOriginX = event.clientX; + this.resizeOriginWidth = this.panelWidth(); + this.panelOriginLeft = this.panelLeft(); + } + + startRightResize(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.resizingRight = true; + this.resizeOriginX = event.clientX; + this.resizeOriginWidth = this.panelWidth(); + } + + startCornerResize(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.resizingCorner = true; + this.resizeOriginX = event.clientX; + this.resizeOriginY = event.clientY; + this.resizeOriginWidth = this.panelWidth(); + this.resizeOriginHeight = this.panelHeight(); + } + + startDrag(event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.dragging = true; + this.resizeOriginX = event.clientX; + this.resizeOriginY = event.clientY; + this.panelOriginLeft = this.panelLeft(); + this.panelOriginTop = this.panelTop(); + } + + onMouseMove(event: MouseEvent, detached: boolean): void { + if (this.dragging) { + this.updateDetachedPosition(event); + return; + } + + if (this.resizingCorner) { + this.updateCornerResize(event, detached); + return; + } + + if (this.resizingLeft) { + this.updateLeftResize(event, detached); + return; + } + + if (this.resizingRight) { + this.updateRightResize(event, detached); + return; + } + + if (this.resizingTop) { + this.updateTopResize(event, detached); + return; + } + + if (this.resizingBottom) { + this.updateBottomResize(event, detached); + } + } + + onMouseUp(): void { + const wasActive = this.isResizing || this.dragging; + + this.dragging = false; + this.resizingTop = false; + this.resizingBottom = false; + this.resizingLeft = false; + this.resizingRight = false; + this.resizingCorner = false; + + if (wasActive) + this.persistLayout(); + } + + syncBounds(detached: boolean): void { + this.panelWidth.update((width) => this.clampWidth(width, detached)); + this.panelHeight.update((height) => this.clampHeight(height, detached)); + + if (detached) + this.clampDetachedPosition(); + } + + initializeDetachedPosition(): void { + if (this.panelLeft() > 0 || this.panelTop() > 0) { + this.clampDetachedPosition(); + return; + } + + this.panelLeft.set(this.getMaxLeft(this.panelWidth())); + this.panelTop.set( + this.clamp(window.innerHeight - this.panelHeight() - 96, 16, this.getMaxTop(this.panelHeight())) + ); + } + + private updateTopResize(event: MouseEvent, detached: boolean): void { + const delta = this.resizeOriginY - event.clientY; + const nextHeight = this.clampHeight(this.resizeOriginHeight + delta, detached); + + this.panelHeight.set(nextHeight); + + if (!detached) + return; + + const originBottom = this.panelOriginTop + this.resizeOriginHeight; + + this.panelTop.set(this.clamp(originBottom - nextHeight, 16, this.getMaxTop(nextHeight))); + } + + private updateBottomResize(event: MouseEvent, detached: boolean): void { + const delta = event.clientY - this.resizeOriginY; + + this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + delta, detached)); + } + + private updateLeftResize(event: MouseEvent, detached: boolean): void { + const delta = this.resizeOriginX - event.clientX; + const nextWidth = this.clampWidth(this.resizeOriginWidth + delta, detached); + + this.panelWidth.set(nextWidth); + + if (!detached) + return; + + const originRight = this.panelOriginLeft + this.resizeOriginWidth; + + this.panelLeft.set(this.clamp(originRight - nextWidth, 16, this.getMaxLeft(nextWidth))); + } + + private updateRightResize(event: MouseEvent, detached: boolean): void { + const delta = event.clientX - this.resizeOriginX; + + this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + delta, detached)); + } + + private updateCornerResize(event: MouseEvent, detached: boolean): void { + const deltaX = event.clientX - this.resizeOriginX; + const deltaY = event.clientY - this.resizeOriginY; + + this.panelWidth.set(this.clampWidth(this.resizeOriginWidth + deltaX, detached)); + this.panelHeight.set(this.clampHeight(this.resizeOriginHeight + deltaY, detached)); + } + + 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.clamp(nextLeft, 16, this.getMaxLeft(this.panelWidth()))); + this.panelTop.set(this.clamp(nextTop, 16, this.getMaxTop(this.panelHeight()))); + } + + private clampHeight(height: number, detached?: boolean): number { + const maxHeight = detached + ? Math.max(MIN_HEIGHT, window.innerHeight - 32) + : Math.floor(window.innerHeight * 0.75); + + return Math.min(Math.max(height, MIN_HEIGHT), maxHeight); + } + + private clampWidth(width: number, _detached?: boolean): number { + const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - 32); + const minWidth = Math.min(MIN_WIDTH, maxWidth); + + return Math.min(Math.max(width, minWidth), maxWidth); + } + + private clampDetachedPosition(): void { + this.panelLeft.set(this.clamp(this.panelLeft(), 16, this.getMaxLeft(this.panelWidth()))); + this.panelTop.set(this.clamp(this.panelTop(), 16, this.getMaxTop(this.panelHeight()))); + } + + private getMaxLeft(width: number): number { + return Math.max(16, window.innerWidth - width - 16); + } + + private getMaxTop(height: number): number { + return Math.max(16, window.innerHeight - height - 16); + } + + private clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + + private loadLayout(): void { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + if (!raw) + return; + + const parsed = JSON.parse(raw) as PersistedLayout; + + if (typeof parsed.height === 'number' && parsed.height >= MIN_HEIGHT) + this.panelHeight.set(parsed.height); + + if (typeof parsed.width === 'number' && parsed.width >= MIN_WIDTH) + this.panelWidth.set(parsed.width); + } catch { + // Ignore corrupted storage + } + } + + private persistLayout(): void { + try { + const layout: PersistedLayout = { + height: this.panelHeight(), + width: this.panelWidth() + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); + } catch { + // Ignore storage failures + } + } +}