518 lines
14 KiB
TypeScript
518 lines
14 KiB
TypeScript
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);
|
||
});
|
||
}
|
||
}
|