Add debugging console

This commit is contained in:
2026-03-07 21:59:39 +01:00
parent 66246e4e16
commit 90f067e662
49 changed files with 5962 additions and 139 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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)}`;
}
}

View File

@@ -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>

View File

@@ -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';
}
}

View File

@@ -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>
}
}

View File

@@ -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);
}
}