Add debugging console
This commit is contained in:
@@ -61,6 +61,7 @@
|
|||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cytoscape": "^3.33.1",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"ngx-remark": "^0.2.2",
|
"ngx-remark": "^0.2.2",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
|
|||||||
@@ -18,3 +18,6 @@
|
|||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
<!-- Unified Settings Modal -->
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|
||||||
|
<!-- Shared Debug Console -->
|
||||||
|
<app-debug-console [showLauncher]="false" />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ServersRailComponent } from './features/servers/servers-rail.component'
|
|||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.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 { UsersActions } from './store/users/users.actions';
|
||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
@@ -39,7 +40,8 @@ import {
|
|||||||
ServersRailComponent,
|
ServersRailComponent,
|
||||||
TitleBarComponent,
|
TitleBarComponent,
|
||||||
FloatingVoiceControlsComponent,
|
FloatingVoiceControlsComponent,
|
||||||
SettingsModalComponent
|
SettingsModalComponent,
|
||||||
|
DebugConsoleComponent
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
|
|||||||
@@ -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_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
||||||
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_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 STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||||
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||||
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||||
|
export const DEBUG_LOG_MAX_ENTRIES = 500;
|
||||||
export const DEFAULT_MAX_USERS = 50;
|
export const DEFAULT_MAX_USERS = 50;
|
||||||
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
||||||
export const DEFAULT_VOLUME = 100;
|
export const DEFAULT_VOLUME = 100;
|
||||||
|
|||||||
26
src/app/core/helpers/debugging-helpers.ts
Normal file
26
src/app/core/helpers/debugging-helpers.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { DebuggingService } from '../services/debugging.service';
|
||||||
|
|
||||||
|
export function reportDebuggingError(
|
||||||
|
debugging: DebuggingService,
|
||||||
|
source: string,
|
||||||
|
message: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
debugging.error(source, message, {
|
||||||
|
...payload,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackDebuggingTaskFailure(
|
||||||
|
task: Promise<unknown> | unknown,
|
||||||
|
debugging: DebuggingService,
|
||||||
|
source: string,
|
||||||
|
message: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
Promise.resolve(task).catch((error) => {
|
||||||
|
reportDebuggingError(debugging, source, message, payload, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
193
src/app/core/models/debugging.models.ts
Normal file
193
src/app/core/models/debugging.models.ts
Normal file
@@ -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<string, MutableDebugNetworkMessageGroup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebugNetworkBuildState {
|
||||||
|
currentRoom: Room | null;
|
||||||
|
currentUser: User | null;
|
||||||
|
edges: Map<string, MutableDebugNetworkEdge>;
|
||||||
|
localIds: Set<string>;
|
||||||
|
nodes: Map<string, MutableDebugNetworkNode>;
|
||||||
|
users: readonly User[];
|
||||||
|
userLookup: Map<string, User>;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { WebRTCService } from './webrtc.service';
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
||||||
import { DatabaseService } from './database.service';
|
import { DatabaseService } from './database.service';
|
||||||
|
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
|
||||||
|
|
||||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
||||||
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
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.
|
* assembled into a Blob and an object URL is created.
|
||||||
*/
|
*/
|
||||||
handleFileChunk(payload: any): void {
|
handleFileChunk(payload: any): void {
|
||||||
const { messageId, fileId, index, total, data } = payload;
|
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!messageId || !fileId ||
|
!messageId || !fileId ||
|
||||||
@@ -444,6 +445,9 @@ export class AttachmentService {
|
|||||||
|
|
||||||
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||||
|
|
||||||
|
if (fromPeerId)
|
||||||
|
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
|
||||||
|
|
||||||
if (!attachment.startedAtMs)
|
if (!attachment.startedAtMs)
|
||||||
attachment.startedAtMs = now;
|
attachment.startedAtMs = now;
|
||||||
|
|
||||||
|
|||||||
386
src/app/core/services/debug-network-metrics.service.ts
Normal file
386
src/app/core/services/debug-network-metrics.service.ts
Normal file
@@ -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<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getString(record: Record<string, unknown>, 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<string, InternalDebugNetworkMetricState>();
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
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<DebugNetworkMetricStreamCounts>): 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<string, unknown>,
|
||||||
|
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<DebugNetworkMetricStreamCounts>
|
||||||
|
): 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);
|
||||||
|
}
|
||||||
2
src/app/core/services/debugging.service.ts
Normal file
2
src/app/core/services/debugging.service.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from '../models/debugging.models';
|
||||||
|
export * from './debugging/debugging.service';
|
||||||
File diff suppressed because it is too large
Load Diff
52
src/app/core/services/debugging/debugging.constants.ts
Normal file
52
src/app/core/services/debugging/debugging.constants.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
804
src/app/core/services/debugging/debugging.service.ts
Normal file
804
src/app/core/services/debugging/debugging.service.ts
Normal file
@@ -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<DebugLogEntry[]>([]);
|
||||||
|
readonly isConsoleOpen = signal(false);
|
||||||
|
readonly networkSnapshot = computed<DebugNetworkSnapshot>(() =>
|
||||||
|
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<ConsoleMethodName, ConsoleMethod> = {
|
||||||
|
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<ConsoleMethodName, ConsoleMethod>;
|
||||||
|
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> | null {
|
||||||
|
const element = this.asElement(target);
|
||||||
|
|
||||||
|
if (!element)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
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<string, unknown> | 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<object>());
|
||||||
|
} 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<object>): 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<string, unknown> = {};
|
||||||
|
const objectValue = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of Object.keys(objectValue).slice(0, 20)) {
|
||||||
|
normalizedObject[key] = this.normalizeValue(objectValue[key], depth + 1, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/app/core/services/debugging/index.ts
Normal file
2
src/app/core/services/debugging/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from '../../models/debugging.models';
|
||||||
|
export * from './debugging.service';
|
||||||
@@ -3,6 +3,8 @@ export * from './platform.service';
|
|||||||
export * from './browser-database.service';
|
export * from './browser-database.service';
|
||||||
export * from './electron-database.service';
|
export * from './electron-database.service';
|
||||||
export * from './database.service';
|
export * from './database.service';
|
||||||
|
export * from '../models/debugging.models';
|
||||||
|
export * from './debugging/debugging.service';
|
||||||
export * from './webrtc.service';
|
export * from './webrtc.service';
|
||||||
export * from './server-directory.service';
|
export * from './server-directory.service';
|
||||||
export * from './klipy.service';
|
export * from './klipy.service';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsModalService {
|
export class SettingsModalService {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Signal
|
Signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
import { DebuggingService } from './debugging.service';
|
||||||
import { WebRTCService } from './webrtc.service';
|
import { WebRTCService } from './webrtc.service';
|
||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ interface TrackedStream {
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class VoiceActivityService implements OnDestroy {
|
export class VoiceActivityService implements OnDestroy {
|
||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
|
private readonly debugging = inject(DebuggingService);
|
||||||
|
|
||||||
private readonly tracked = new Map<string, TrackedStream>();
|
private readonly tracked = new Map<string, TrackedStream>();
|
||||||
private animFrameId: number | null = null;
|
private animFrameId: number | null = null;
|
||||||
@@ -134,6 +136,10 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
if (!entry)
|
if (!entry)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (entry.speakingSignal()) {
|
||||||
|
this.reportSpeakingState(id, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
this.disposeEntry(entry);
|
this.disposeEntry(entry);
|
||||||
this.tracked.delete(id);
|
this.tracked.delete(id);
|
||||||
this.publishSpeakingMap();
|
this.publishSpeakingMap();
|
||||||
@@ -159,7 +165,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
private poll = (): void => {
|
private poll = (): void => {
|
||||||
let mapDirty = false;
|
let mapDirty = false;
|
||||||
|
|
||||||
this.tracked.forEach((entry) => {
|
this.tracked.forEach((entry, id) => {
|
||||||
const { analyser, dataArray, volumeSignal, speakingSignal } = entry;
|
const { analyser, dataArray, volumeSignal, speakingSignal } = entry;
|
||||||
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
@@ -183,6 +189,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
|
|
||||||
if (!wasSpeaking) {
|
if (!wasSpeaking) {
|
||||||
speakingSignal.set(true);
|
speakingSignal.set(true);
|
||||||
|
this.reportSpeakingState(id, true, rms);
|
||||||
mapDirty = true;
|
mapDirty = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -190,6 +197,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
|
|
||||||
if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) {
|
if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) {
|
||||||
speakingSignal.set(false);
|
speakingSignal.set(false);
|
||||||
|
this.reportSpeakingState(id, false, rms);
|
||||||
mapDirty = true;
|
mapDirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +219,14 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this._speakingMap.set(map);
|
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 {
|
private disposeEntry(entry: TrackedStream): void {
|
||||||
try { entry.source.disconnect(); } catch { /* already disconnected */ }
|
try { entry.source.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Observable, Subject } from 'rxjs';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { SignalingMessage, ChatEvent } from '../models/index';
|
import { SignalingMessage, ChatEvent } from '../models/index';
|
||||||
import { TimeSyncService } from './time-sync.service';
|
import { TimeSyncService } from './time-sync.service';
|
||||||
|
import { DebuggingService } from './debugging.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SignalingManager,
|
SignalingManager,
|
||||||
@@ -55,8 +56,9 @@ import {
|
|||||||
})
|
})
|
||||||
export class WebRTCService implements OnDestroy {
|
export class WebRTCService implements OnDestroy {
|
||||||
private readonly timeSync = inject(TimeSyncService);
|
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 lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||||
private lastJoinedServer: JoinedServerInfo | null = null;
|
private lastJoinedServer: JoinedServerInfo | null = null;
|
||||||
|
|||||||
@@ -631,10 +631,14 @@ export class MediaManager {
|
|||||||
try {
|
try {
|
||||||
this.inputGainSourceNode?.disconnect();
|
this.inputGainSourceNode?.disconnect();
|
||||||
this.inputGainNode?.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') {
|
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;
|
this.inputGainCtx = null;
|
||||||
|
|||||||
@@ -175,20 +175,20 @@ export class NoiseReductionManager {
|
|||||||
private teardownGraph(): void {
|
private teardownGraph(): void {
|
||||||
try {
|
try {
|
||||||
this.sourceNode?.disconnect();
|
this.sourceNode?.disconnect();
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* already disconnected */
|
this.logger.warn('Noise reduction source node already disconnected', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.workletNode?.disconnect();
|
this.workletNode?.disconnect();
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* already disconnected */
|
this.logger.warn('Noise reduction worklet node already disconnected', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.destinationNode?.disconnect();
|
this.destinationNode?.disconnect();
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* already disconnected */
|
this.logger.warn('Noise reduction destination node already disconnected', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sourceNode = null;
|
this.sourceNode = null;
|
||||||
@@ -197,8 +197,8 @@ export class NoiseReductionManager {
|
|||||||
|
|
||||||
// Close the context to free hardware resources
|
// Close the context to free hardware resources
|
||||||
if (this.audioContext && this.audioContext.state !== 'closed') {
|
if (this.audioContext && this.audioContext.state !== 'closed') {
|
||||||
this.audioContext.close().catch(() => {
|
this.audioContext.close().catch((error) => {
|
||||||
/* best-effort */
|
this.logger.warn('Failed to close RNNoise audio context', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TRANSCEIVER_RECV_ONLY,
|
TRANSCEIVER_RECV_ONLY,
|
||||||
TRANSCEIVER_SEND_RECV
|
TRANSCEIVER_SEND_RECV
|
||||||
} from '../../webrtc.constants';
|
} from '../../webrtc.constants';
|
||||||
|
import { recordDebugNetworkConnectionState } from '../../../debug-network-metrics.service';
|
||||||
import { PeerData } from '../../webrtc.types';
|
import { PeerData } from '../../webrtc.types';
|
||||||
import { ConnectionLifecycleHandlers, PeerConnectionManagerContext } from '../shared';
|
import { ConnectionLifecycleHandlers, PeerConnectionManagerContext } from '../shared';
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ export function createPeerConnection(
|
|||||||
state: connection.connectionState
|
state: connection.connectionState
|
||||||
});
|
});
|
||||||
|
|
||||||
|
recordDebugNetworkConnectionState(remotePeerId, connection.connectionState);
|
||||||
|
|
||||||
switch (connection.connectionState) {
|
switch (connection.connectionState) {
|
||||||
case CONNECTION_STATE_CONNECTED:
|
case CONNECTION_STATE_CONNECTED:
|
||||||
handlers.clearPeerDisconnectGraceTimer(remotePeerId);
|
handlers.clearPeerDisconnectGraceTimer(remotePeerId);
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ export async function doCreateAndSendOffer(
|
|||||||
payload: { sdp: offer }
|
payload: { sdp: offer }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 }
|
payload: { sdp: answer }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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) {
|
} 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);
|
peerData.pendingIceCandidates.push(candidate);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 }
|
payload: { sdp: offer }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to renegotiate', error);
|
logger.error('Failed to renegotiate', error, {
|
||||||
|
peerId,
|
||||||
|
signalingState: peerData.connection.signalingState
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
P2P_TYPE_VOICE_STATE,
|
P2P_TYPE_VOICE_STATE,
|
||||||
P2P_TYPE_VOICE_STATE_REQUEST
|
P2P_TYPE_VOICE_STATE_REQUEST
|
||||||
} from '../../webrtc.constants';
|
} from '../../webrtc.constants';
|
||||||
|
import { recordDebugNetworkDataChannelPayload, recordDebugNetworkPing } from '../../../debug-network-metrics.service';
|
||||||
import { PeerConnectionManagerContext } from '../shared';
|
import { PeerConnectionManagerContext } from '../shared';
|
||||||
import { startPingInterval } from './ping';
|
import { startPingInterval } from './ping';
|
||||||
|
|
||||||
@@ -30,33 +31,71 @@ export function setupDataChannel(
|
|||||||
const { logger } = context;
|
const { logger } = context;
|
||||||
|
|
||||||
channel.onopen = () => {
|
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);
|
sendCurrentStatesToChannel(context, channel, remotePeerId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST }));
|
const stateRequest = { type: P2P_TYPE_STATE_REQUEST };
|
||||||
} catch {
|
const rawPayload = JSON.stringify(stateRequest);
|
||||||
/* ignore */
|
|
||||||
|
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 = () => {
|
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) => {
|
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) => {
|
channel.onmessage = (event) => {
|
||||||
|
const rawPayload = typeof event.data === 'string'
|
||||||
|
? event.data
|
||||||
|
: String(event.data ?? '');
|
||||||
|
|
||||||
try {
|
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);
|
handlePeerMessage(context, remotePeerId, message);
|
||||||
} catch (error) {
|
} 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 {
|
): void {
|
||||||
const { logger, state } = context;
|
const { logger, state } = context;
|
||||||
|
|
||||||
logger.info('Received P2P message', {
|
logger.info('[data-channel] Received P2P message', summarizePeerMessage(message, { peerId }));
|
||||||
peerId,
|
recordDebugNetworkDataChannelPayload(peerId, message, 'inbound');
|
||||||
type: message.type
|
|
||||||
});
|
|
||||||
|
|
||||||
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
||||||
sendCurrentStatesToPeer(context, peerId);
|
sendCurrentStatesToPeer(context, peerId);
|
||||||
@@ -98,6 +135,8 @@ export function handlePeerMessage(
|
|||||||
|
|
||||||
state.peerLatencies.set(peerId, latencyMs);
|
state.peerLatencies.set(peerId, latencyMs);
|
||||||
state.peerLatencyChanged$.next({ peerId, latencyMs });
|
state.peerLatencyChanged$.next({ peerId, latencyMs });
|
||||||
|
recordDebugNetworkPing(peerId, latencyMs);
|
||||||
|
logger.info('[data-channel] Peer latency updated', { latencyMs, peerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
state.pendingPings.delete(peerId);
|
state.pendingPings.delete(peerId);
|
||||||
@@ -118,16 +157,35 @@ export function broadcastMessage(
|
|||||||
event: object
|
event: object
|
||||||
): void {
|
): void {
|
||||||
const { logger, state } = context;
|
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) => {
|
state.activePeerConnections.forEach((peerData, peerId) => {
|
||||||
try {
|
try {
|
||||||
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||||
peerData.dataChannel.send(data);
|
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) {
|
} 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 {
|
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) {
|
} 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) {
|
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<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const handleBufferedAmountLow = () => {
|
const handleBufferedAmountLow = () => {
|
||||||
if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES) {
|
if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES) {
|
||||||
@@ -193,8 +270,17 @@ export async function sendToPeerBuffered(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
channel.send(data);
|
channel.send(data);
|
||||||
|
recordDebugNetworkDataChannelPayload(peerId, event as PeerMessage, 'outbound');
|
||||||
|
|
||||||
|
logDataChannelTraffic(context, channel, peerId, 'outbound', data, event as PeerMessage);
|
||||||
} catch (error) {
|
} 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();
|
const voiceState = callbacks.getVoiceStateSnapshot();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
channel.send(
|
const voiceStatePayload = {
|
||||||
JSON.stringify({
|
|
||||||
type: P2P_TYPE_VOICE_STATE,
|
type: P2P_TYPE_VOICE_STATE,
|
||||||
oderId,
|
oderId,
|
||||||
displayName,
|
displayName,
|
||||||
voiceState
|
voiceState
|
||||||
})
|
};
|
||||||
);
|
const screenStatePayload = {
|
||||||
|
|
||||||
channel.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: P2P_TYPE_SCREEN_STATE,
|
type: P2P_TYPE_SCREEN_STATE,
|
||||||
oderId,
|
oderId,
|
||||||
displayName,
|
displayName,
|
||||||
isScreenSharing: callbacks.isScreenSharingActive()
|
isScreenSharing: callbacks.isScreenSharingActive()
|
||||||
})
|
};
|
||||||
);
|
const voiceStateRaw = JSON.stringify(voiceStatePayload);
|
||||||
|
const screenStateRaw = JSON.stringify(screenStatePayload);
|
||||||
|
|
||||||
logger.info('Sent initial states to channel', { remotePeerId, voiceState });
|
channel.send(voiceStateRaw);
|
||||||
|
logDataChannelTraffic(context, channel, remotePeerId, 'outbound', voiceStateRaw, voiceStatePayload);
|
||||||
|
channel.send(screenStateRaw);
|
||||||
|
logDataChannelTraffic(context, channel, remotePeerId, 'outbound', screenStateRaw, screenStatePayload);
|
||||||
|
|
||||||
|
logger.info('[data-channel] Sent initial states to channel', { remotePeerId, voiceState });
|
||||||
} catch (error) {
|
} 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()
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
const summary: Record<string, unknown> = {
|
||||||
|
...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<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function measurePayloadBytes(payload: string): number {
|
||||||
|
return new TextEncoder().encode(payload).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRawPreview(payload: string): string {
|
||||||
|
return payload.replace(/\s+/g, ' ').slice(0, 240);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import {
|
|||||||
P2P_TYPE_PING,
|
P2P_TYPE_PING,
|
||||||
PEER_PING_INTERVAL_MS
|
PEER_PING_INTERVAL_MS
|
||||||
} from '../../webrtc.constants';
|
} from '../../webrtc.constants';
|
||||||
|
import { WebRTCLogger } from '../../webrtc-logger';
|
||||||
import { PeerConnectionManagerState } from '../shared';
|
import { PeerConnectionManagerState } from '../shared';
|
||||||
|
|
||||||
/** Start periodic pings to a peer to measure round-trip latency. */
|
/** 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);
|
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);
|
state.peerPingTimers.set(peerId, timer);
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ export function clearAllPingTimers(state: PeerConnectionManagerState): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Send a single ping to a peer. */
|
/** 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);
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN)
|
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);
|
state.pendingPings.set(peerId, timestamp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
peerData.dataChannel.send(
|
const payload = JSON.stringify({
|
||||||
JSON.stringify({
|
|
||||||
type: P2P_TYPE_PING,
|
type: P2P_TYPE_PING,
|
||||||
ts: timestamp
|
ts: timestamp
|
||||||
})
|
});
|
||||||
);
|
|
||||||
} catch {
|
peerData.dataChannel.send(payload);
|
||||||
/* ignore */
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import { ChatEvent } from '../../../models';
|
import { ChatEvent } from '../../../models';
|
||||||
|
import { recordDebugNetworkDownloadRates } from '../../debug-network-metrics.service';
|
||||||
import { WebRTCLogger } from '../webrtc-logger';
|
import { WebRTCLogger } from '../webrtc-logger';
|
||||||
import { PeerData } from '../webrtc.types';
|
import { PeerData } from '../webrtc.types';
|
||||||
import { createPeerConnection as createManagedPeerConnection } from './connection/create-peer-connection';
|
import { createPeerConnection as createManagedPeerConnection } from './connection/create-peer-connection';
|
||||||
@@ -44,12 +45,24 @@ import {
|
|||||||
RemovePeerOptions
|
RemovePeerOptions
|
||||||
} from './shared';
|
} 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,
|
* Creates and manages RTCPeerConnections, data channels,
|
||||||
* offer/answer negotiation, ICE candidates, and P2P reconnection.
|
* offer/answer negotiation, ICE candidates, and P2P reconnection.
|
||||||
*/
|
*/
|
||||||
export class PeerConnectionManager {
|
export class PeerConnectionManager {
|
||||||
private readonly state = createPeerConnectionManagerState();
|
private readonly state = createPeerConnectionManagerState();
|
||||||
|
private readonly lastInboundByteSnapshots = new Map<string, PeerInboundByteSnapshot>();
|
||||||
|
private statsPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private transportStatsPollInFlight = false;
|
||||||
|
|
||||||
/** Active peer connections keyed by remote peer ID. */
|
/** Active peer connections keyed by remote peer ID. */
|
||||||
readonly activePeerConnections = this.state.activePeerConnections;
|
readonly activePeerConnections = this.state.activePeerConnections;
|
||||||
@@ -74,7 +87,9 @@ export class PeerConnectionManager {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly logger: WebRTCLogger,
|
private readonly logger: WebRTCLogger,
|
||||||
private callbacks: PeerConnectionCallbacks
|
private callbacks: PeerConnectionCallbacks
|
||||||
) {}
|
) {
|
||||||
|
this.startTransportStatsPolling();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the callback set at runtime.
|
* Replace the callback set at runtime.
|
||||||
@@ -183,11 +198,13 @@ export class PeerConnectionManager {
|
|||||||
*/
|
*/
|
||||||
removePeer(peerId: string, options?: RemovePeerOptions): void {
|
removePeer(peerId: string, options?: RemovePeerOptions): void {
|
||||||
removeManagedPeer(this.context, peerId, options);
|
removeManagedPeer(this.context, peerId, options);
|
||||||
|
this.clearPeerTransportStats(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close every active peer connection and clear internal state. */
|
/** Close every active peer connection and clear internal state. */
|
||||||
closeAllPeers(): void {
|
closeAllPeers(): void {
|
||||||
closeManagedPeers(this.state);
|
closeManagedPeers(this.state);
|
||||||
|
this.lastInboundByteSnapshots.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
||||||
@@ -207,6 +224,8 @@ export class PeerConnectionManager {
|
|||||||
|
|
||||||
/** Clean up all resources. */
|
/** Clean up all resources. */
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
this.stopTransportStatsPolling();
|
||||||
|
this.lastInboundByteSnapshots.clear();
|
||||||
this.closeAllPeers();
|
this.closeAllPeers();
|
||||||
this.peerConnected$.complete();
|
this.peerConnected$.complete();
|
||||||
this.peerDisconnected$.complete();
|
this.peerDisconnected$.complete();
|
||||||
@@ -293,4 +312,141 @@ export class PeerConnectionManager {
|
|||||||
private addToConnectedPeers(peerId: string): void {
|
private addToConnectedPeers(peerId: string): void {
|
||||||
addToConnectedPeers(this.state, peerId);
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TRACK_KIND_VIDEO } from '../../webrtc.constants';
|
import { TRACK_KIND_VIDEO } from '../../webrtc.constants';
|
||||||
|
import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service';
|
||||||
import { PeerConnectionManagerContext } from '../shared';
|
import { PeerConnectionManagerContext } from '../shared';
|
||||||
|
|
||||||
export function handleRemoteTrack(
|
export function handleRemoteTrack(
|
||||||
@@ -34,13 +35,25 @@ export function handleRemoteTrack(
|
|||||||
|
|
||||||
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
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.remotePeerStreams.set(remotePeerId, compositeStream);
|
||||||
state.remoteStream$.next({
|
state.remoteStream$.next({
|
||||||
peerId: remotePeerId,
|
peerId: remotePeerId,
|
||||||
stream: compositeStream
|
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(
|
function buildCompositeRemoteStream(
|
||||||
@@ -63,10 +76,11 @@ function buildCompositeRemoteStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeRemoteTrack(
|
function removeRemoteTrack(
|
||||||
state: PeerConnectionManagerContext['state'],
|
context: PeerConnectionManagerContext,
|
||||||
remotePeerId: string,
|
remotePeerId: string,
|
||||||
trackId: string
|
trackId: string
|
||||||
): void {
|
): void {
|
||||||
|
const { logger, state } = context;
|
||||||
const currentStream = state.remotePeerStreams.get(remotePeerId);
|
const currentStream = state.remotePeerStreams.get(remotePeerId);
|
||||||
|
|
||||||
if (!currentStream)
|
if (!currentStream)
|
||||||
@@ -81,6 +95,16 @@ function removeRemoteTrack(
|
|||||||
|
|
||||||
if (remainingTracks.length === 0) {
|
if (remainingTracks.length === 0) {
|
||||||
state.remotePeerStreams.delete(remotePeerId);
|
state.remotePeerStreams.delete(remotePeerId);
|
||||||
|
recordDebugNetworkStreams(remotePeerId, { audio: 0,
|
||||||
|
video: 0 });
|
||||||
|
|
||||||
|
logger.info('Remote stream updated', {
|
||||||
|
audioTrackCount: 0,
|
||||||
|
remotePeerId,
|
||||||
|
trackCount: 0,
|
||||||
|
videoTrackCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,4 +115,16 @@ function removeRemoteTrack(
|
|||||||
peerId: remotePeerId,
|
peerId: remotePeerId,
|
||||||
stream: nextStream
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,11 @@ export class ScreenShareManager {
|
|||||||
|
|
||||||
// Clean up mixed audio
|
// Clean up mixed audio
|
||||||
if (this.combinedAudioStream) {
|
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;
|
this.combinedAudioStream = null;
|
||||||
}
|
}
|
||||||
@@ -179,7 +183,9 @@ export class ScreenShareManager {
|
|||||||
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
|
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
|
||||||
|
|
||||||
if (videoTransceiver) {
|
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) {
|
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
||||||
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
|
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
|
||||||
@@ -340,7 +346,11 @@ export class ScreenShareManager {
|
|||||||
this.stopScreenShare();
|
this.stopScreenShare();
|
||||||
|
|
||||||
if (this.audioMixingContext) {
|
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;
|
this.audioMixingContext = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { Observable, Subject } from 'rxjs';
|
||||||
import { SignalingMessage } from '../../models';
|
import { SignalingMessage } from '../../models';
|
||||||
|
import { recordDebugNetworkSignalingPayload } from '../debug-network-metrics.service';
|
||||||
import { WebRTCLogger } from './webrtc-logger';
|
import { WebRTCLogger } from './webrtc-logger';
|
||||||
import { IdentifyCredentials, JoinedServerInfo } from './webrtc.types';
|
import { IdentifyCredentials, JoinedServerInfo } from './webrtc.types';
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,8 @@ export class SignalingManager {
|
|||||||
this.lastSignalingUrl = serverUrl;
|
this.lastSignalingUrl = serverUrl;
|
||||||
return new Observable<boolean>((observer) => {
|
return new Observable<boolean>((observer) => {
|
||||||
try {
|
try {
|
||||||
|
this.logger.info('[signaling] Connecting to signaling server', { serverUrl });
|
||||||
|
|
||||||
if (this.signalingWebSocket) {
|
if (this.signalingWebSocket) {
|
||||||
this.signalingWebSocket.close();
|
this.signalingWebSocket.close();
|
||||||
}
|
}
|
||||||
@@ -53,7 +56,11 @@ export class SignalingManager {
|
|||||||
this.signalingWebSocket = new WebSocket(serverUrl);
|
this.signalingWebSocket = new WebSocket(serverUrl);
|
||||||
|
|
||||||
this.signalingWebSocket.onopen = () => {
|
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.clearReconnect();
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
this.connectionStatus$.next({ connected: true });
|
this.connectionStatus$.next({ connected: true });
|
||||||
@@ -62,25 +69,56 @@ export class SignalingManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.signalingWebSocket.onmessage = (event) => {
|
this.signalingWebSocket.onmessage = (event) => {
|
||||||
|
const rawPayload = this.stringifySocketPayload(event.data);
|
||||||
|
const payloadBytes = rawPayload ? this.measurePayloadBytes(rawPayload) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(rawPayload) as SignalingMessage & Record<string, unknown>;
|
||||||
|
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);
|
this.messageReceived$.next(message);
|
||||||
} catch (error) {
|
} 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.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,
|
this.connectionStatus$.next({ connected: false,
|
||||||
errorMessage: 'Connection to signaling server failed' });
|
errorMessage: 'Connection to signaling server failed' });
|
||||||
|
|
||||||
observer.error(error);
|
observer.error(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.signalingWebSocket.onclose = () => {
|
this.signalingWebSocket.onclose = (event) => {
|
||||||
this.logger.info('Disconnected from signaling server');
|
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.stopHeartbeat();
|
||||||
this.connectionStatus$.next({ connected: false,
|
this.connectionStatus$.next({ connected: false,
|
||||||
errorMessage: 'Disconnected from signaling server' });
|
errorMessage: 'Disconnected from signaling server' });
|
||||||
@@ -88,6 +126,11 @@ export class SignalingManager {
|
|||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.logger.error('[signaling] Failed to initialize signaling socket', error, {
|
||||||
|
readyState: this.getSocketReadyStateLabel(),
|
||||||
|
url: serverUrl
|
||||||
|
});
|
||||||
|
|
||||||
observer.error(error);
|
observer.error(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -118,7 +161,12 @@ export class SignalingManager {
|
|||||||
/** Send a signaling message (with `from` / `timestamp` populated). */
|
/** Send a signaling message (with `from` / `timestamp` populated). */
|
||||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>, localPeerId: string): void {
|
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>, localPeerId: string): void {
|
||||||
if (!this.isSocketOpen()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,17 +174,30 @@ export class SignalingManager {
|
|||||||
from: localPeerId,
|
from: localPeerId,
|
||||||
timestamp: Date.now() };
|
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.). */
|
/** Send a raw JSON payload (for identify, join_server, etc.). */
|
||||||
sendRawMessage(message: Record<string, unknown>): void {
|
sendRawMessage(message: Record<string, unknown>): void {
|
||||||
if (!this.isSocketOpen()) {
|
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;
|
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. */
|
/** Gracefully close the WebSocket. */
|
||||||
@@ -212,7 +273,12 @@ export class SignalingManager {
|
|||||||
this.signalingReconnectTimer = setTimeout(() => {
|
this.signalingReconnectTimer = setTimeout(() => {
|
||||||
this.signalingReconnectTimer = null;
|
this.signalingReconnectTimer = null;
|
||||||
this.signalingReconnectAttempts++;
|
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({
|
this.connect(this.lastSignalingUrl!).subscribe({
|
||||||
next: () => { this.signalingReconnectAttempts = 0; },
|
next: () => { this.signalingReconnectAttempts = 0; },
|
||||||
error: () => { this.scheduleReconnect(); }
|
error: () => { this.scheduleReconnect(); }
|
||||||
@@ -251,4 +317,152 @@ export class SignalingManager {
|
|||||||
this.messageReceived$.complete();
|
this.messageReceived$.complete();
|
||||||
this.connectionStatus$.complete();
|
this.connectionStatus$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendSerializedPayload(
|
||||||
|
message: SignalingMessage | Record<string, unknown>,
|
||||||
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
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<string, unknown> | 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<string, unknown>[] | undefined {
|
||||||
|
if (!Array.isArray(value))
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const users: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
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<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,33 @@
|
|||||||
* Lightweight logging utility for the WebRTC subsystem.
|
* Lightweight logging utility for the WebRTC subsystem.
|
||||||
* All log lines are prefixed with `[WebRTC]`.
|
* 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 {
|
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). */
|
/** Informational log (only when debug is enabled). */
|
||||||
info(prefix: string, ...args: unknown[]): void {
|
info(prefix: string, ...args: unknown[]): void {
|
||||||
if (!this.isEnabled)
|
if (!this.debugEnabled)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
||||||
@@ -16,12 +37,17 @@ export class WebRTCLogger {
|
|||||||
|
|
||||||
/** Warning log (only when debug is enabled). */
|
/** Warning log (only when debug is enabled). */
|
||||||
warn(prefix: string, ...args: unknown[]): void {
|
warn(prefix: string, ...args: unknown[]): void {
|
||||||
if (!this.isEnabled)
|
if (!this.debugEnabled)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
|
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 log (always emitted regardless of debug flag). */
|
||||||
error(prefix: string, err: unknown, extra?: Record<string, unknown>): void {
|
error(prefix: string, err: unknown, extra?: Record<string, unknown>): void {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<div class="max-w-3xl space-y-6">
|
||||||
|
<section class="rounded-xl border border-border bg-card/40 p-5">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="rounded-xl bg-primary/10 p-2 text-primary">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideBug"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground">App-wide debugging</h4>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="peer sr-only"
|
||||||
|
[checked]="enabled()"
|
||||||
|
(change)="onEnabledChange($event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="h-5 w-10 rounded-full bg-secondary after:absolute after:left-[2px] after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideClock3"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide">Captured events</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-2xl font-semibold text-foreground">{{ entryCount() }}</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Last update: {{ lastUpdatedLabel() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideCircleAlert"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide">Errors</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-2xl font-semibold text-destructive">{{ errorCount() }}</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Unhandled runtime failures and rejected promises.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border bg-secondary/20 p-4">
|
||||||
|
<div class="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideTriangleAlert"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide">Warnings</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-2xl font-semibold text-yellow-400">{{ warningCount() }}</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Navigation cancellations, offline events, and other warnings.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-border bg-card/40 p-5">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-foreground">Floating debug console</h5>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openConsole()"
|
||||||
|
[disabled]="!enabled()"
|
||||||
|
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ isConsoleOpen() ? 'Console open' : 'Open console' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="clearLogs()"
|
||||||
|
[disabled]="entryCount() === 0"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
Clear logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
signal,
|
signal
|
||||||
computed
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -60,18 +60,21 @@ export class ServerSettingsComponent {
|
|||||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
/** Reload form fields whenever the server input changes. */
|
/** Reload form fields whenever the server input changes. */
|
||||||
serverData = computed(() => {
|
readonly serverData = this.server;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
const room = this.server();
|
const room = this.server();
|
||||||
|
|
||||||
if (room) {
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
this.roomName = room.name;
|
this.roomName = room.name;
|
||||||
this.roomDescription = room.description || '';
|
this.roomDescription = room.description || '';
|
||||||
this.isPrivate.set(room.isPrivate);
|
this.isPrivate.set(room.isPrivate);
|
||||||
this.maxUsers = room.maxUsers || 0;
|
this.maxUsers = room.maxUsers || 0;
|
||||||
}
|
|
||||||
|
|
||||||
return room;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
togglePrivate(): void {
|
togglePrivate(): void {
|
||||||
this.isPrivate.update((currentValue) => !currentValue);
|
this.isPrivate.update((currentValue) => !currentValue);
|
||||||
|
|||||||
@@ -122,6 +122,9 @@
|
|||||||
@case ('voice') {
|
@case ('voice') {
|
||||||
Voice & Audio
|
Voice & Audio
|
||||||
}
|
}
|
||||||
|
@case ('debugging') {
|
||||||
|
Debugging
|
||||||
|
}
|
||||||
@case ('server') {
|
@case ('server') {
|
||||||
Server Settings
|
Server Settings
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,9 @@
|
|||||||
@case ('voice') {
|
@case ('voice') {
|
||||||
<app-voice-settings />
|
<app-voice-settings />
|
||||||
}
|
}
|
||||||
|
@case ('debugging') {
|
||||||
|
<app-debugging-settings />
|
||||||
|
}
|
||||||
@case ('server') {
|
@case ('server') {
|
||||||
<app-server-settings
|
<app-server-settings
|
||||||
[server]="selectedServer()"
|
[server]="selectedServer()"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideX,
|
lucideX,
|
||||||
|
lucideBug,
|
||||||
lucideGlobe,
|
lucideGlobe,
|
||||||
lucideAudioLines,
|
lucideAudioLines,
|
||||||
lucideSettings,
|
lucideSettings,
|
||||||
@@ -33,6 +34,7 @@ import { ServerSettingsComponent } from './server-settings/server-settings.compo
|
|||||||
import { MembersSettingsComponent } from './members-settings/members-settings.component';
|
import { MembersSettingsComponent } from './members-settings/members-settings.component';
|
||||||
import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
||||||
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
||||||
|
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -44,6 +46,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
|||||||
NgIcon,
|
NgIcon,
|
||||||
NetworkSettingsComponent,
|
NetworkSettingsComponent,
|
||||||
VoiceSettingsComponent,
|
VoiceSettingsComponent,
|
||||||
|
DebuggingSettingsComponent,
|
||||||
ServerSettingsComponent,
|
ServerSettingsComponent,
|
||||||
MembersSettingsComponent,
|
MembersSettingsComponent,
|
||||||
BansSettingsComponent,
|
BansSettingsComponent,
|
||||||
@@ -52,6 +55,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
|||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideX,
|
lucideX,
|
||||||
|
lucideBug,
|
||||||
lucideGlobe,
|
lucideGlobe,
|
||||||
lucideAudioLines,
|
lucideAudioLines,
|
||||||
lucideSettings,
|
lucideSettings,
|
||||||
@@ -82,7 +86,10 @@ export class SettingsModalComponent {
|
|||||||
icon: 'lucideGlobe' },
|
icon: 'lucideGlobe' },
|
||||||
{ id: 'voice',
|
{ id: 'voice',
|
||||||
label: 'Voice & Audio',
|
label: 'Voice & Audio',
|
||||||
icon: 'lucideAudioLines' }
|
icon: 'lucideAudioLines' },
|
||||||
|
{ id: 'debugging',
|
||||||
|
label: 'Debugging',
|
||||||
|
icon: 'lucideBug' }
|
||||||
];
|
];
|
||||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'server',
|
{ id: 'server',
|
||||||
|
|||||||
@@ -73,6 +73,11 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<app-debug-console
|
||||||
|
launcherVariant="compact"
|
||||||
|
[showPanel]="false"
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
(click)="disconnect()"
|
(click)="disconnect()"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ import { WebRTCService } from '../../../core/services/webrtc.service';
|
|||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
|
import { DebugConsoleComponent } from '../../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-voice-controls',
|
selector: 'app-floating-voice-controls',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon, DebugConsoleComponent],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideMic,
|
lucideMic,
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<app-debug-console
|
||||||
|
launcherVariant="inline"
|
||||||
|
[showPanel]="false"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleSettings()"
|
(click)="toggleSettings()"
|
||||||
@@ -45,6 +50,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Voice Controls -->
|
<!-- Voice Controls -->
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
|||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
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';
|
import { PlaybackOptions, VoicePlaybackService } from './services/voice-playback.service';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
@@ -45,6 +48,7 @@ interface AudioDevice {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
|
DebugConsoleComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<div
|
||||||
|
#viewport
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-background/50"
|
||||||
|
>
|
||||||
|
@if (entries().length === 0) {
|
||||||
|
<div class="flex h-full min-h-56 items-center justify-center px-6 py-10 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">No logs match the current filters.</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Generate activity in the app or loosen the filters to see captured events.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="divide-y divide-border/70">
|
||||||
|
@for (entry of entries(); track entry.id) {
|
||||||
|
<article
|
||||||
|
class="px-4 py-3 transition-colors"
|
||||||
|
[class]="getRowClass(entry.level)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left disabled:cursor-default"
|
||||||
|
[disabled]="!entry.payloadText"
|
||||||
|
(click)="toggleExpanded(entry.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="min-w-[88px] pt-0.5 text-[11px] font-mono text-muted-foreground"
|
||||||
|
[title]="entry.dateTimeLabel"
|
||||||
|
>
|
||||||
|
{{ entry.timeLabel }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span [class]="getBadgeClass(entry.level)">{{ getLevelLabel(entry.level) }}</span>
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{{ entry.source }}
|
||||||
|
</span>
|
||||||
|
@if (entry.count > 1) {
|
||||||
|
<span class="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
x{{ entry.count }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 break-words text-sm text-foreground">{{ entry.message }}</p>
|
||||||
|
|
||||||
|
@if (entry.payloadText) {
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
|
{{ isExpanded(entry.id) ? 'Hide details' : 'Show details' }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (entry.payloadText) {
|
||||||
|
<span class="pt-1 text-muted-foreground">
|
||||||
|
<ng-icon
|
||||||
|
[name]="isExpanded(entry.id) ? 'lucideChevronDown' : 'lucideChevronRight'"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (entry.payloadText && isExpanded(entry.id)) {
|
||||||
|
<pre class="mt-3 overflow-x-auto rounded-lg bg-background px-3 py-3 text-[11px] leading-5 text-muted-foreground">{{
|
||||||
|
entry.payloadText
|
||||||
|
}}</pre>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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<DebugLogEntry[]>();
|
||||||
|
readonly autoScroll = input.required<boolean>();
|
||||||
|
readonly expandedEntryIds = signal<number[]>([]);
|
||||||
|
|
||||||
|
private readonly viewportRef = viewChild<ElementRef<HTMLDivElement>>('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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<div class="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_20rem] overflow-hidden bg-background/50">
|
||||||
|
<section class="relative min-h-0 border-r border-border bg-background/70">
|
||||||
|
<div
|
||||||
|
#graph
|
||||||
|
class="h-full min-h-[22rem] w-full"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute left-3 top-3 rounded-xl border border-border/80 bg-card/90 px-3 py-2 shadow-xl backdrop-blur-sm">
|
||||||
|
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-foreground">
|
||||||
|
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.clientCount }} clients</span>
|
||||||
|
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.serverCount }} servers</span>
|
||||||
|
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.peerConnectionCount }} peer links</span>
|
||||||
|
<span class="rounded-full border border-border bg-secondary px-2 py-0.5">{{ snapshot().summary.messageCount }} grouped messages</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span class="rounded-full border border-blue-400/40 bg-blue-500/10 px-2 py-0.5 text-blue-200">Local client</span>
|
||||||
|
<span class="rounded-full border border-emerald-400/40 bg-emerald-500/10 px-2 py-0.5 text-emerald-200">Remote client</span>
|
||||||
|
<span class="rounded-full border border-orange-400/40 bg-orange-500/10 px-2 py-0.5 text-orange-200">Signaling</span>
|
||||||
|
<span class="rounded-full border border-violet-400/40 bg-violet-500/10 px-2 py-0.5 text-violet-200">Server</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (snapshot().edges.length === 0) {
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center px-8 text-center">
|
||||||
|
<div class="max-w-sm rounded-2xl border border-border/80 bg-card/85 px-5 py-4 shadow-xl backdrop-blur-sm">
|
||||||
|
<p class="text-sm font-semibold text-foreground">No network activity captured yet.</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Enable debugging before connecting to signaling, joining a server, or opening peer channels to populate the live map.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="min-h-0 overflow-y-auto bg-card/60">
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">Peer details</h3>
|
||||||
|
<span class="text-[11px] text-muted-foreground">Updated {{ formatAge(snapshot().generatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (statusNodes().length === 0) {
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
|
Connected clients appear here with IDs, handshakes, text counts, streams, drops, and live download metrics.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
@for (node of statusNodes(); track node.id) {
|
||||||
|
<article class="rounded-xl border border-border/80 bg-background/70 px-3 py-2">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-foreground">{{ node.label }}</p>
|
||||||
|
<p class="truncate text-[11px] text-muted-foreground">{{ node.secondaryLabel }}</p>
|
||||||
|
<p class="mt-1 break-all text-[10px] text-muted-foreground/90">ID {{ formatClientId(node) }}</p>
|
||||||
|
|
||||||
|
@if (formatPeerIdentity(node); as peerIdentity) {
|
||||||
|
<p class="mt-0.5 break-all text-[10px] text-muted-foreground/80">Peer {{ peerIdentity }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span [class]="getStatusBadgeClass(node)">
|
||||||
|
{{ getNodeActivityLabel(node) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
@for (status of node.statuses; track status) {
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5 text-[10px] text-muted-foreground">{{ status }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid grid-cols-1 gap-2 text-[11px] text-muted-foreground sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
|
||||||
|
<p class="font-medium text-foreground/90">Streams</p>
|
||||||
|
<p
|
||||||
|
class="mt-1"
|
||||||
|
title="A = audio streams, V = video streams"
|
||||||
|
>
|
||||||
|
Streams
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="Audio streams"
|
||||||
|
>A</span
|
||||||
|
>{{ node.streams.audio }}
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="Video streams"
|
||||||
|
>V</span
|
||||||
|
>{{ node.streams.video }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
|
||||||
|
<p class="font-medium text-foreground/90">Text</p>
|
||||||
|
<p
|
||||||
|
class="mt-1"
|
||||||
|
title="Up arrow = sent messages, down arrow = received messages"
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="Sent messages"
|
||||||
|
>↑</span
|
||||||
|
>{{ node.textMessages.sent }}
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="Received messages"
|
||||||
|
>↓</span
|
||||||
|
>{{ node.textMessages.received }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2 sm:col-span-2">
|
||||||
|
<p class="font-medium text-foreground/90">Handshakes</p>
|
||||||
|
<p
|
||||||
|
class="mt-1"
|
||||||
|
title="Counts are shown as sent / received"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="WebRTC offers"
|
||||||
|
>O</span
|
||||||
|
>
|
||||||
|
{{ node.handshake.offersSent }}/{{ node.handshake.offersReceived }}
|
||||||
|
·
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="WebRTC answers"
|
||||||
|
>A</span
|
||||||
|
>
|
||||||
|
{{ node.handshake.answersSent }}/{{ node.handshake.answersReceived }}
|
||||||
|
·
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="ICE candidates"
|
||||||
|
>ICE</span
|
||||||
|
>
|
||||||
|
{{ node.handshake.iceSent }}/{{ node.handshake.iceReceived }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2 sm:col-span-2">
|
||||||
|
<p class="font-medium text-foreground/90">Download Mbps</p>
|
||||||
|
<p
|
||||||
|
class="mt-1"
|
||||||
|
title="Down arrow = download rate. F = file, A = audio, V = video."
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="Download rate"
|
||||||
|
>↓</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="File download Mbps"
|
||||||
|
>F</span
|
||||||
|
>
|
||||||
|
{{ formatMbps(node.downloads.fileMbps) }}
|
||||||
|
·
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="Audio download Mbps"
|
||||||
|
>A</span
|
||||||
|
>
|
||||||
|
{{ formatMbps(node.downloads.audioMbps) }}
|
||||||
|
·
|
||||||
|
<span
|
||||||
|
class="cursor-help underline decoration-dotted underline-offset-2"
|
||||||
|
title="Video download Mbps"
|
||||||
|
>V</span
|
||||||
|
>
|
||||||
|
{{ formatMbps(node.downloads.videoMbps) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
|
||||||
|
<p class="font-medium text-foreground/90">Ping</p>
|
||||||
|
<p class="mt-1">{{ node.pingMs !== null ? node.pingMs + ' ms' : '-' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/70 bg-secondary/30 px-2.5 py-2">
|
||||||
|
<p class="font-medium text-foreground/90">Connection drops</p>
|
||||||
|
<p class="mt-1">{{ node.connectionDrops }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold text-foreground">Connection flows</h3>
|
||||||
|
<span class="text-[11px] text-muted-foreground">Grouped by edge + message type</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (connectionEdges().length === 0) {
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">Once logs arrive, each edge will show grouped signaling or P2P message types with counts.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
@for (edge of connectionEdges(); track edge.id) {
|
||||||
|
<article class="rounded-xl border border-border/80 bg-background/70 px-3 py-3">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-foreground">{{ formatEdgeHeading(edge) }}</p>
|
||||||
|
<p class="mt-0.5 text-[11px] text-muted-foreground">{{ edge.stateLabel }} · {{ formatAge(edge.lastSeen) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span [class]="getConnectionBadgeClass(edge)">
|
||||||
|
{{ getEdgeKindLabel(edge) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1.5 text-[10px] text-muted-foreground">
|
||||||
|
@if (edge.pingMs !== null) {
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">Ping {{ edge.pingMs }} ms</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ edge.messageTotal }} grouped messages</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (edge.messageGroups.length > 0) {
|
||||||
|
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
@for (group of getVisibleMessageGroups(edge); track group.id) {
|
||||||
|
<span [class]="getMessageBadgeClass(group)">{{ formatMessageGroup(group) }}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="rounded-full border border-border bg-secondary/70 px-2 py-0.5 text-[10px] text-muted-foreground"
|
||||||
|
[class.hidden]="getHiddenMessageGroupCount(edge) === 0"
|
||||||
|
>+{{ getHiddenMessageGroupCount(edge) }} more</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="mt-3 text-[11px] text-muted-foreground">No grouped messages on this edge yet.</p>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
@@ -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<DebugNetworkSnapshot>();
|
||||||
|
readonly graphRef = viewChild<ElementRef<HTMLDivElement>>('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<string, { x: number; y: number }> {
|
||||||
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
|
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<string, { x: number; y: number }>,
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<div class="border-b border-border bg-card/90 px-4 py-3">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-semibold text-foreground">Debug Console</span>
|
||||||
|
@if (activeTab() === 'logs') {
|
||||||
|
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{{ visibleCount() }} visible</span>
|
||||||
|
} @else {
|
||||||
|
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||||
|
>{{ networkSummary().clientCount }} clients · {{ networkSummary().peerConnectionCount }} links</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{
|
||||||
|
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.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="toggleDetached()"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
{{ getDetachLabel() }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="toggleAutoScroll()"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
[attr.aria-pressed]="autoScroll()"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
[name]="autoScroll() ? 'lucidePause' : 'lucidePlay'"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{{ autoScroll() ? 'Pause auto-scroll' : 'Resume auto-scroll' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="clear()"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="close()"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-secondary px-2.5 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
@for (tab of tabs; track tab) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setActiveTab(tab)"
|
||||||
|
[class]="getTabButtonClass(tab)"
|
||||||
|
[attr.aria-pressed]="activeTab() === tab"
|
||||||
|
>
|
||||||
|
{{ getTabLabel(tab) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (activeTab() === 'logs') {
|
||||||
|
<div class="mt-3 grid gap-3 xl:grid-cols-[minmax(0,1fr)_12rem]">
|
||||||
|
<label class="relative block">
|
||||||
|
<span class="sr-only">Search logs</span>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideSearch"
|
||||||
|
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Search messages, payloads, timestamps, and sources"
|
||||||
|
[value]="searchTerm()"
|
||||||
|
(input)="onSearchInput($event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="relative block">
|
||||||
|
<span class="sr-only">Filter by source</span>
|
||||||
|
<select
|
||||||
|
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
[value]="selectedSource()"
|
||||||
|
(change)="onSourceChange($event)"
|
||||||
|
>
|
||||||
|
<option value="all">All sources</option>
|
||||||
|
@for (source of sourceOptions(); track source) {
|
||||||
|
<option [value]="source">{{ source }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<div class="mr-1 inline-flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideFilter"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
Levels
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (level of levels; track level) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="toggleLevel(level)"
|
||||||
|
[class]="getLevelButtonClass(level)"
|
||||||
|
[attr.aria-pressed]="levelState()[level]"
|
||||||
|
>
|
||||||
|
{{ getLevelLabel(level) }}
|
||||||
|
<span class="ml-1 text-[11px] opacity-80">{{ levelCounts()[level] }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="mt-3 rounded-xl border border-border/80 bg-background/70 px-3 py-3 text-xs text-muted-foreground">
|
||||||
|
<p>Traffic is grouped by edge and message type to keep signaling, voice-state, and screen-state chatter readable.</p>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2 text-[11px]">
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().typingCount }} typing</span>
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().speakingCount }} speaking</span>
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().streamingCount }} streaming</span>
|
||||||
|
<span class="rounded-full border border-border bg-secondary/70 px-2 py-0.5">{{ networkSummary().membershipCount }} memberships</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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<boolean>();
|
||||||
|
readonly searchTerm = input.required<string>();
|
||||||
|
readonly selectedSource = input.required<string>();
|
||||||
|
readonly sourceOptions = input.required<string[]>();
|
||||||
|
readonly levelState = input.required<Record<DebugLogLevel, boolean>>();
|
||||||
|
readonly levelCounts = input.required<Record<DebugLogLevel, number>>();
|
||||||
|
readonly visibleCount = input.required<number>();
|
||||||
|
readonly autoScroll = input.required<boolean>();
|
||||||
|
readonly networkSummary = input.required<DebugNetworkSummary>();
|
||||||
|
|
||||||
|
readonly activeTabChange = output<'logs' | 'network'>();
|
||||||
|
readonly detachToggled = output<undefined>();
|
||||||
|
readonly searchTermChange = output<string>();
|
||||||
|
readonly selectedSourceChange = output<string>();
|
||||||
|
readonly levelToggled = output<DebugLogLevel>();
|
||||||
|
readonly autoScrollToggled = output<undefined>();
|
||||||
|
readonly clearRequested = output<undefined>();
|
||||||
|
readonly closeRequested = output<undefined>();
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
@if (debugging.enabled()) {
|
||||||
|
@if (showLauncher()) {
|
||||||
|
@if (launcherVariant() === 'floating') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fixed bottom-4 right-4 z-[80] inline-flex h-12 w-12 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-lg transition-colors hover:bg-secondary"
|
||||||
|
[class.bg-primary]="isOpen()"
|
||||||
|
[class.text-primary-foreground]="isOpen()"
|
||||||
|
[class.border-primary/50]="isOpen()"
|
||||||
|
(click)="toggleConsole()"
|
||||||
|
[attr.aria-expanded]="isOpen()"
|
||||||
|
aria-label="Toggle debug console"
|
||||||
|
title="Toggle debug console"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideBug"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (badgeCount() > 0) {
|
||||||
|
<span
|
||||||
|
class="absolute -right-1 -top-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold shadow-sm"
|
||||||
|
[class.bg-destructive]="hasErrors()"
|
||||||
|
[class.text-destructive-foreground]="hasErrors()"
|
||||||
|
[class.bg-secondary]="!hasErrors()"
|
||||||
|
[class.text-foreground]="!hasErrors()"
|
||||||
|
>
|
||||||
|
{{ formatBadgeCount(badgeCount()) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else if (launcherVariant() === 'compact') {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex h-7 w-7 items-center justify-center rounded-lg transition-opacity hover:opacity-90"
|
||||||
|
[class.bg-primary/20]="isOpen()"
|
||||||
|
[class.text-primary]="isOpen()"
|
||||||
|
[class.bg-secondary]="!isOpen()"
|
||||||
|
[class.text-foreground]="!isOpen()"
|
||||||
|
(click)="toggleConsole()"
|
||||||
|
[attr.aria-expanded]="isOpen()"
|
||||||
|
aria-label="Toggle debug console"
|
||||||
|
title="Toggle debug console"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideBug"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (badgeCount() > 0) {
|
||||||
|
<span
|
||||||
|
class="absolute -right-1 -top-1 rounded-full px-1 py-0 text-[9px] font-semibold leading-tight shadow-sm"
|
||||||
|
[class.bg-destructive]="hasErrors()"
|
||||||
|
[class.text-destructive-foreground]="hasErrors()"
|
||||||
|
[class.bg-secondary]="!hasErrors()"
|
||||||
|
[class.text-foreground]="!hasErrors()"
|
||||||
|
>
|
||||||
|
{{ formatBadgeCount(badgeCount()) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-secondary"
|
||||||
|
[class.bg-secondary]="isOpen()"
|
||||||
|
[class.text-foreground]="isOpen()"
|
||||||
|
[class.text-muted-foreground]="!isOpen()"
|
||||||
|
(click)="toggleConsole()"
|
||||||
|
[attr.aria-expanded]="isOpen()"
|
||||||
|
aria-label="Toggle debug console"
|
||||||
|
title="Toggle debug console"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideBug"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (badgeCount() > 0) {
|
||||||
|
<span
|
||||||
|
class="absolute -right-1 -top-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none shadow-sm"
|
||||||
|
[class.bg-destructive]="hasErrors()"
|
||||||
|
[class.text-destructive-foreground]="hasErrors()"
|
||||||
|
[class.bg-secondary]="!hasErrors()"
|
||||||
|
[class.text-foreground]="!hasErrors()"
|
||||||
|
>
|
||||||
|
{{ formatBadgeCount(badgeCount()) }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showPanel() && isOpen()) {
|
||||||
|
<div class="pointer-events-none fixed inset-0 z-[79]">
|
||||||
|
<section
|
||||||
|
class="pointer-events-auto absolute flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
|
[class.bottom-20]="!detached()"
|
||||||
|
[class.right-4]="!detached()"
|
||||||
|
[style.height.px]="panelHeight()"
|
||||||
|
[style.width.px]="panelWidth()"
|
||||||
|
[style.left.px]="detached() ? panelLeft() : null"
|
||||||
|
[style.top.px]="detached() ? panelTop() : null"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group absolute inset-y-0 left-0 z-[1] w-3 cursor-col-resize bg-transparent"
|
||||||
|
(mousedown)="startWidthResize($event)"
|
||||||
|
aria-label="Resize debug console width"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute left-1/2 top-1/2 h-20 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border/60 transition-colors group-hover:bg-primary/60"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group relative h-3 w-full cursor-row-resize bg-transparent"
|
||||||
|
(mousedown)="startResize($event)"
|
||||||
|
aria-label="Resize debug console"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute left-1/2 top-1/2 h-1 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border transition-colors group-hover:bg-primary/50"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (detached()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-full cursor-move items-center justify-center border-b border-border bg-background/70 px-4 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground transition-colors hover:bg-background"
|
||||||
|
(mousedown)="startDrag($event)"
|
||||||
|
aria-label="Move debug console"
|
||||||
|
>
|
||||||
|
Drag to move
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-debug-console-toolbar
|
||||||
|
[activeTab]="activeTab()"
|
||||||
|
[detached]="detached()"
|
||||||
|
[searchTerm]="searchTerm()"
|
||||||
|
[selectedSource]="selectedSource()"
|
||||||
|
[sourceOptions]="sourceOptions()"
|
||||||
|
[levelState]="levelState()"
|
||||||
|
[levelCounts]="levelCounts()"
|
||||||
|
[visibleCount]="visibleCount()"
|
||||||
|
[autoScroll]="autoScroll()"
|
||||||
|
[networkSummary]="networkSummary()"
|
||||||
|
(activeTabChange)="setActiveTab($event)"
|
||||||
|
(detachToggled)="toggleDetached()"
|
||||||
|
(searchTermChange)="updateSearchTerm($event)"
|
||||||
|
(selectedSourceChange)="updateSelectedSource($event)"
|
||||||
|
(levelToggled)="toggleLevel($event)"
|
||||||
|
(autoScrollToggled)="toggleAutoScroll()"
|
||||||
|
(clearRequested)="clearLogs()"
|
||||||
|
(closeRequested)="closeConsole()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (activeTab() === 'logs') {
|
||||||
|
<app-debug-console-entry-list
|
||||||
|
class="min-h-0 flex-1 overflow-hidden"
|
||||||
|
[entries]="filteredEntries()"
|
||||||
|
[autoScroll]="autoScroll()"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<app-debug-console-network-map
|
||||||
|
class="min-h-0 flex-1 overflow-hidden"
|
||||||
|
[snapshot]="networkSnapshot()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DebugLogLevel, boolean>;
|
||||||
|
|
||||||
|
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<DebugConsoleLauncherVariant>('floating');
|
||||||
|
readonly showLauncher = input(true);
|
||||||
|
readonly showPanel = input(true);
|
||||||
|
|
||||||
|
readonly activeTab = signal<DebugConsoleTab>('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<DebugLevelState>({
|
||||||
|
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<Record<DebugLogLevel, number>>(() => {
|
||||||
|
const counts: Record<DebugLogLevel, number> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dial
|
|||||||
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
|
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
|
||||||
export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-audio-player.component';
|
export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-audio-player.component';
|
||||||
export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-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';
|
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
import { mergeMap } from 'rxjs/operators';
|
import { mergeMap } from 'rxjs/operators';
|
||||||
import { Action } from '@ngrx/store';
|
import { Action } from '@ngrx/store';
|
||||||
import { Message } from '../../core/models/index';
|
import { Message } from '../../core/models/index';
|
||||||
|
import type { DebuggingService } from '../../core/services';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { AttachmentService } from '../../core/services/attachment.service';
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
@@ -39,6 +41,7 @@ export interface IncomingMessageContext {
|
|||||||
db: DatabaseService;
|
db: DatabaseService;
|
||||||
webrtc: WebRTCService;
|
webrtc: WebRTCService;
|
||||||
attachments: AttachmentService;
|
attachments: AttachmentService;
|
||||||
|
debugging: DebuggingService;
|
||||||
currentUser: any;
|
currentUser: any;
|
||||||
currentRoom: any;
|
currentRoom: any;
|
||||||
}
|
}
|
||||||
@@ -256,7 +259,7 @@ function requestMissingImages(
|
|||||||
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
|
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
|
||||||
function handleChatMessage(
|
function handleChatMessage(
|
||||||
event: any,
|
event: any,
|
||||||
{ db, currentUser }: IncomingMessageContext
|
{ db, debugging, currentUser }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
const msg = event.message;
|
const msg = event.message;
|
||||||
|
|
||||||
@@ -271,22 +274,43 @@ function handleChatMessage(
|
|||||||
if (isOwnMessage)
|
if (isOwnMessage)
|
||||||
return EMPTY;
|
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 }));
|
return of(MessagesActions.receiveMessage({ message: msg }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies a remote message edit to the local DB and store. */
|
/** Applies a remote message edit to the local DB and store. */
|
||||||
function handleMessageEdited(
|
function handleMessageEdited(
|
||||||
event: any,
|
event: any,
|
||||||
{ db }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId || !event.content)
|
if (!event.messageId || !event.content)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
|
trackBackgroundOperation(
|
||||||
db.updateMessage(event.messageId, {
|
db.updateMessage(event.messageId, {
|
||||||
content: event.content,
|
content: event.content,
|
||||||
editedAt: event.editedAt
|
editedAt: event.editedAt
|
||||||
});
|
}),
|
||||||
|
debugging,
|
||||||
|
'Failed to persist incoming message edit',
|
||||||
|
{
|
||||||
|
editedAt: event.editedAt ?? null,
|
||||||
|
fromPeerId: event.fromPeerId ?? null,
|
||||||
|
messageId: event.messageId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return of(
|
return of(
|
||||||
MessagesActions.editMessageSuccess({
|
MessagesActions.editMessageSuccess({
|
||||||
@@ -300,12 +324,22 @@ function handleMessageEdited(
|
|||||||
/** Applies a remote message deletion to the local DB and store. */
|
/** Applies a remote message deletion to the local DB and store. */
|
||||||
function handleMessageDeleted(
|
function handleMessageDeleted(
|
||||||
event: any,
|
event: any,
|
||||||
{ db }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId)
|
if (!event.messageId)
|
||||||
return EMPTY;
|
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(
|
return of(
|
||||||
MessagesActions.deleteMessageSuccess({ messageId: event.messageId })
|
MessagesActions.deleteMessageSuccess({ messageId: event.messageId })
|
||||||
);
|
);
|
||||||
@@ -314,24 +348,46 @@ function handleMessageDeleted(
|
|||||||
/** Saves an incoming reaction to DB and updates the store. */
|
/** Saves an incoming reaction to DB and updates the store. */
|
||||||
function handleReactionAdded(
|
function handleReactionAdded(
|
||||||
event: any,
|
event: any,
|
||||||
{ db }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId || !event.reaction)
|
if (!event.messageId || !event.reaction)
|
||||||
return EMPTY;
|
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 }));
|
return of(MessagesActions.addReactionSuccess({ reaction: event.reaction }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes a reaction from DB and updates the store. */
|
/** Removes a reaction from DB and updates the store. */
|
||||||
function handleReactionRemoved(
|
function handleReactionRemoved(
|
||||||
event: any,
|
event: any,
|
||||||
{ db }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messageId || !event.oderId || !event.emoji)
|
if (!event.messageId || !event.oderId || !event.emoji)
|
||||||
return EMPTY;
|
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(
|
return of(
|
||||||
MessagesActions.removeReactionSuccess({
|
MessagesActions.removeReactionSuccess({
|
||||||
messageId: event.messageId,
|
messageId: event.messageId,
|
||||||
@@ -442,12 +498,24 @@ function handleSyncRequest(
|
|||||||
/** Merges a full message dump from a peer into the local DB and store. */
|
/** Merges a full message dump from a peer into the local DB and store. */
|
||||||
function handleSyncFull(
|
function handleSyncFull(
|
||||||
event: any,
|
event: any,
|
||||||
{ db }: IncomingMessageContext
|
{ db, debugging }: IncomingMessageContext
|
||||||
): Observable<Action> {
|
): Observable<Action> {
|
||||||
if (!event.messages || !Array.isArray(event.messages))
|
if (!event.messages || !Array.isArray(event.messages))
|
||||||
return EMPTY;
|
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 }));
|
return of(MessagesActions.syncMessages({ messages: event.messages }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,3 +561,12 @@ export function dispatchIncomingMessage(
|
|||||||
|
|
||||||
return handler ? handler(event, ctx) : EMPTY;
|
return handler ? handler(event, ctx) : EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackBackgroundOperation(
|
||||||
|
task: Promise<unknown> | unknown,
|
||||||
|
debugging: DebuggingService,
|
||||||
|
message: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
trackDebuggingTaskFailure(task, debugging, 'messages', message, payload);
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { RoomsActions } from '../rooms/rooms.actions';
|
|||||||
import { selectMessagesSyncing } from './messages.selectors';
|
import { selectMessagesSyncing } from './messages.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { DebuggingService } from '../../core/services/debugging.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import {
|
import {
|
||||||
INVENTORY_LIMIT,
|
INVENTORY_LIMIT,
|
||||||
@@ -55,6 +56,7 @@ export class MessagesSyncEffects {
|
|||||||
private readonly actions$ = inject(Actions);
|
private readonly actions$ = inject(Actions);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly db = inject(DatabaseService);
|
private readonly db = inject(DatabaseService);
|
||||||
|
private readonly debugging = inject(DebuggingService);
|
||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
/** Tracks whether the last sync cycle found no new messages. */
|
/** Tracks whether the last sync cycle found no new messages. */
|
||||||
@@ -135,8 +137,12 @@ export class MessagesSyncEffects {
|
|||||||
type: 'chat-inventory-request',
|
type: 'chat-inventory-request',
|
||||||
roomId: activeRoom.id
|
roomId: activeRoom.id
|
||||||
} as any);
|
} as any);
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* peer may have disconnected */
|
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',
|
type: 'chat-inventory-request',
|
||||||
roomId: room.id
|
roomId: room.id
|
||||||
} as any);
|
} as any);
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* peer may have disconnected */
|
this.debugging.warn('messages', 'Failed to request peer inventory during sync poll', {
|
||||||
|
error,
|
||||||
|
peerId: pid,
|
||||||
|
roomId: room.id
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MessagesActions.startSync();
|
return MessagesActions.startSync();
|
||||||
}),
|
}),
|
||||||
catchError(() => {
|
catchError((error) => {
|
||||||
this.lastSyncClean = false;
|
this.lastSyncClean = false;
|
||||||
|
this.debugging.warn('messages', 'Periodic sync poll failed', {
|
||||||
|
error,
|
||||||
|
roomId: room.id
|
||||||
|
});
|
||||||
|
|
||||||
return of(MessagesActions.syncComplete());
|
return of(MessagesActions.syncComplete());
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { MessagesActions } from './messages.actions';
|
|||||||
import { selectCurrentUser } from '../users/users.selectors';
|
import { selectCurrentUser } from '../users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
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 { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
import { AttachmentService } from '../../core/services/attachment.service';
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
@@ -44,6 +46,7 @@ export class MessagesEffects {
|
|||||||
private readonly actions$ = inject(Actions);
|
private readonly actions$ = inject(Actions);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly db = inject(DatabaseService);
|
private readonly db = inject(DatabaseService);
|
||||||
|
private readonly debugging = inject(DebuggingService);
|
||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
private readonly timeSync = inject(TimeSyncService);
|
private readonly timeSync = inject(TimeSyncService);
|
||||||
private readonly attachments = inject(AttachmentService);
|
private readonly attachments = inject(AttachmentService);
|
||||||
@@ -97,7 +100,17 @@ export class MessagesEffects {
|
|||||||
replyToId
|
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',
|
this.webrtc.broadcastMessage({ type: 'chat-message',
|
||||||
message });
|
message });
|
||||||
|
|
||||||
@@ -131,8 +144,16 @@ export class MessagesEffects {
|
|||||||
|
|
||||||
const editedAt = this.timeSync.now();
|
const editedAt = this.timeSync.now();
|
||||||
|
|
||||||
|
this.trackBackgroundOperation(
|
||||||
this.db.updateMessage(messageId, { content,
|
this.db.updateMessage(messageId, { content,
|
||||||
editedAt });
|
editedAt }),
|
||||||
|
'Failed to persist edited chat message',
|
||||||
|
{
|
||||||
|
contentLength: content.length,
|
||||||
|
editedAt,
|
||||||
|
messageId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({ type: 'message-edited',
|
this.webrtc.broadcastMessage({ type: 'message-edited',
|
||||||
messageId,
|
messageId,
|
||||||
@@ -171,7 +192,12 @@ export class MessagesEffects {
|
|||||||
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
|
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',
|
this.webrtc.broadcastMessage({ type: 'message-deleted',
|
||||||
messageId });
|
messageId });
|
||||||
|
|
||||||
@@ -204,7 +230,15 @@ export class MessagesEffects {
|
|||||||
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
|
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',
|
this.webrtc.broadcastMessage({ type: 'message-deleted',
|
||||||
messageId,
|
messageId,
|
||||||
deletedBy: currentUser.id });
|
deletedBy: currentUser.id });
|
||||||
@@ -235,7 +269,17 @@ export class MessagesEffects {
|
|||||||
timestamp: this.timeSync.now()
|
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',
|
this.webrtc.broadcastMessage({ type: 'reaction-added',
|
||||||
messageId,
|
messageId,
|
||||||
reaction });
|
reaction });
|
||||||
@@ -254,7 +298,16 @@ export class MessagesEffects {
|
|||||||
if (!currentUser)
|
if (!currentUser)
|
||||||
return EMPTY;
|
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({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'reaction-removed',
|
type: 'reaction-removed',
|
||||||
messageId,
|
messageId,
|
||||||
@@ -286,18 +339,43 @@ export class MessagesEffects {
|
|||||||
mergeMap(([
|
mergeMap(([
|
||||||
event,
|
event,
|
||||||
currentUser,
|
currentUser,
|
||||||
currentRoom]: [any, any, any
|
currentRoom
|
||||||
]) => {
|
]) => {
|
||||||
const ctx: IncomingMessageContext = {
|
const ctx: IncomingMessageContext = {
|
||||||
db: this.db,
|
db: this.db,
|
||||||
webrtc: this.webrtc,
|
webrtc: this.webrtc,
|
||||||
attachments: this.attachments,
|
attachments: this.attachments,
|
||||||
|
debugging: this.debugging,
|
||||||
currentUser,
|
currentUser,
|
||||||
currentRoom
|
currentRoom
|
||||||
};
|
};
|
||||||
|
|
||||||
return dispatchIncomingMessage(event, ctx);
|
return dispatchIncomingMessage(event, ctx).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
const eventRecord = event as unknown as Record<string, unknown>;
|
||||||
|
const messageRecord = (eventRecord['message'] && typeof eventRecord['message'] === 'object' && !Array.isArray(eventRecord['message']))
|
||||||
|
? eventRecord['message'] as Record<string, unknown>
|
||||||
|
: 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> | unknown, message: string, payload: Record<string, unknown>): void {
|
||||||
|
trackDebuggingTaskFailure(task, this.debugging, 'messages', message, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user