import { LitElement, html, css, property, state, customElement } from './plugins.js'; import type { CSSResult, TemplateResult } from './plugins.js'; import { sharedStyles, panelStyles, tableStyles, buttonStyles } from './sw-dash-styles.js'; export interface ITypedRequestLogEntry { correlationId: string; method: string; direction: 'outgoing' | 'incoming'; phase: 'request' | 'response'; timestamp: number; durationMs?: number; payload: any; error?: string; } export interface ITypedRequestStats { totalRequests: number; totalResponses: number; methodCounts: Record; errorCount: number; avgDurationMs: number; } type TRequestFilter = 'all' | 'outgoing' | 'incoming'; type TPhaseFilter = 'all' | 'request' | 'response'; /** * TypedRequest traffic monitoring panel for sw-dash */ @customElement('sw-dash-requests') export class SwDashRequests extends LitElement { public static styles: CSSResult[] = [ sharedStyles, panelStyles, tableStyles, buttonStyles, css` :host { display: block; } .requests-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-4); gap: var(--space-3); flex-wrap: wrap; } .filter-group { display: flex; align-items: center; gap: var(--space-2); } .filter-label { font-size: 12px; color: var(--text-tertiary); } .filter-select { background: var(--bg-secondary); border: 1px solid var(--border-default); border-radius: var(--radius-sm); padding: var(--space-1) var(--space-2); color: var(--text-primary); font-size: 12px; } .filter-select:focus { outline: none; border-color: var(--accent-primary); } .requests-list { display: flex; flex-direction: column; gap: var(--space-2); max-height: 600px; overflow-y: auto; } .request-card { background: var(--bg-secondary); border: 1px solid var(--border-default); border-radius: var(--radius-md); padding: var(--space-3); } .request-card.has-error { border-color: var(--accent-error); } .request-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-2); gap: var(--space-2); } .request-badges { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } .badge { display: inline-flex; align-items: center; padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .badge.direction-outgoing { background: rgba(59, 130, 246, 0.15); color: #3b82f6; } .badge.direction-incoming { background: rgba(34, 197, 94, 0.15); color: var(--accent-success); } .badge.phase-request { background: rgba(251, 191, 36, 0.15); color: var(--accent-warning); } .badge.phase-response { background: rgba(99, 102, 241, 0.15); color: var(--accent-primary); } .badge.error { background: rgba(239, 68, 68, 0.15); color: var(--accent-error); } .method-name { font-size: 13px; font-weight: 600; color: var(--text-primary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } .request-meta { display: flex; gap: var(--space-3); align-items: center; font-size: 11px; color: var(--text-tertiary); } .request-time { font-variant-numeric: tabular-nums; } .request-duration { color: var(--accent-success); } .request-duration.slow { color: var(--accent-warning); } .request-duration.very-slow { color: var(--accent-error); } .request-payload { font-size: 11px; color: var(--text-tertiary); background: var(--bg-tertiary); padding: var(--space-2); border-radius: var(--radius-sm); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; white-space: pre-wrap; word-break: break-all; max-height: 150px; overflow-y: auto; margin-top: var(--space-2); } .request-error { font-size: 12px; color: var(--accent-error); background: rgba(239, 68, 68, 0.1); padding: var(--space-2); border-radius: var(--radius-sm); margin-top: var(--space-2); } .stats-bar { display: flex; gap: var(--space-4); margin-bottom: var(--space-4); padding: var(--space-3); background: var(--bg-secondary); border-radius: var(--radius-md); border: 1px solid var(--border-default); flex-wrap: wrap; } .stat-item { display: flex; flex-direction: column; gap: var(--space-1); } .stat-value { font-size: 18px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; } .stat-value.error { color: var(--accent-error); } .stat-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; } .method-stats { margin-bottom: var(--space-4); } .method-stats-title { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-bottom: var(--space-2); } .method-stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-2); } .method-stat-card { background: var(--bg-tertiary); border-radius: var(--radius-sm); padding: var(--space-2); } .method-stat-name { font-size: 11px; font-weight: 500; color: var(--text-primary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; margin-bottom: var(--space-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .method-stat-details { display: flex; gap: var(--space-3); font-size: 10px; color: var(--text-tertiary); } .empty-state { text-align: center; padding: var(--space-6); color: var(--text-tertiary); } .clear-btn { background: rgba(239, 68, 68, 0.1); color: var(--accent-error); border: 1px solid transparent; } .clear-btn:hover { background: rgba(239, 68, 68, 0.2); border-color: var(--accent-error); } .pagination { display: flex; justify-content: center; align-items: center; gap: var(--space-2); margin-top: var(--space-4); } .page-info { font-size: 12px; color: var(--text-tertiary); } .toggle-payload { font-size: 11px; color: var(--accent-primary); cursor: pointer; margin-top: var(--space-1); } .toggle-payload:hover { text-decoration: underline; } .correlation-id { font-size: 10px; color: var(--text-tertiary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } ` ]; @property({ type: Array }) accessor logs: ITypedRequestLogEntry[] = []; @state() accessor stats: ITypedRequestStats | null = null; @state() accessor directionFilter: TRequestFilter = 'all'; @state() accessor phaseFilter: TPhaseFilter = 'all'; @state() accessor methodFilter = ''; @state() accessor searchText = ''; @state() accessor totalCount = 0; @state() accessor isLoading = true; @state() accessor page = 1; @state() accessor expandedPayloads: Set = new Set(); @state() accessor availableMethods: string[] = []; private readonly pageSize = 50; // Bound event handler reference for cleanup private boundLogHandler: ((e: Event) => void) | null = null; connectedCallback(): void { super.connectedCallback(); this.loadLogs(); this.loadStats(); this.loadMethods(); this.setupPushListener(); } disconnectedCallback(): void { super.disconnectedCallback(); if (this.boundLogHandler) { window.removeEventListener('typedrequest-logged', this.boundLogHandler); } } private setupPushListener(): void { this.boundLogHandler = (e: Event) => { const customEvent = e as CustomEvent; const newLog = customEvent.detail; // Apply filters if (this.directionFilter !== 'all' && newLog.direction !== this.directionFilter) return; if (this.phaseFilter !== 'all' && newLog.phase !== this.phaseFilter) return; if (this.methodFilter && newLog.method !== this.methodFilter) return; // Prepend new log this.logs = [newLog, ...this.logs]; this.totalCount++; // Update available methods if new if (!this.availableMethods.includes(newLog.method)) { this.availableMethods = [...this.availableMethods, newLog.method]; } }; window.addEventListener('typedrequest-logged', this.boundLogHandler); } private async loadLogs(): Promise { this.isLoading = true; try { const params = new URLSearchParams(); params.set('limit', String(this.pageSize * this.page)); if (this.methodFilter) { params.set('method', this.methodFilter); } const response = await fetch(`/sw-dash/requests?${params}`); const data = await response.json(); this.logs = data.logs; this.totalCount = data.totalCount; } catch (err) { console.error('Failed to load request logs:', err); } finally { this.isLoading = false; } } private async loadStats(): Promise { try { const response = await fetch('/sw-dash/requests/stats'); this.stats = await response.json(); } catch (err) { console.error('Failed to load request stats:', err); } } private async loadMethods(): Promise { try { const response = await fetch('/sw-dash/requests/methods'); const data = await response.json(); this.availableMethods = data.methods; } catch (err) { console.error('Failed to load methods:', err); } } private handleDirectionFilterChange(e: Event): void { this.directionFilter = (e.target as HTMLSelectElement).value as TRequestFilter; this.page = 1; this.loadLogs(); } private handlePhaseFilterChange(e: Event): void { this.phaseFilter = (e.target as HTMLSelectElement).value as TPhaseFilter; this.page = 1; this.loadLogs(); } private handleMethodFilterChange(e: Event): void { this.methodFilter = (e.target as HTMLSelectElement).value; this.page = 1; this.loadLogs(); } private handleSearch(e: Event): void { this.searchText = (e.target as HTMLInputElement).value.toLowerCase(); } private async handleClear(): Promise { if (!confirm('Are you sure you want to clear the request logs? This cannot be undone.')) { return; } try { await fetch('/sw-dash/requests', { method: 'DELETE' }); this.loadLogs(); this.loadStats(); } catch (err) { console.error('Failed to clear request logs:', err); } } private loadMore(): void { this.page++; this.loadLogs(); } private togglePayload(correlationId: string): void { const newSet = new Set(this.expandedPayloads); if (newSet.has(correlationId)) { newSet.delete(correlationId); } else { newSet.add(correlationId); } this.expandedPayloads = newSet; } private formatTimestamp(ts: number): string { const date = new Date(ts); return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3 }); } private getDurationClass(durationMs: number | undefined): string { if (!durationMs) return ''; if (durationMs > 5000) return 'very-slow'; if (durationMs > 1000) return 'slow'; return ''; } private formatDuration(durationMs: number | undefined): string { if (!durationMs) return ''; if (durationMs < 1000) return `${durationMs}ms`; return `${(durationMs / 1000).toFixed(2)}s`; } private getFilteredLogs(): ITypedRequestLogEntry[] { let result = this.logs; // Apply direction filter if (this.directionFilter !== 'all') { result = result.filter(l => l.direction === this.directionFilter); } // Apply phase filter if (this.phaseFilter !== 'all') { result = result.filter(l => l.phase === this.phaseFilter); } // Apply search if (this.searchText) { result = result.filter(l => l.method.toLowerCase().includes(this.searchText) || l.correlationId.toLowerCase().includes(this.searchText) || (l.error && l.error.toLowerCase().includes(this.searchText)) || JSON.stringify(l.payload).toLowerCase().includes(this.searchText) ); } return result; } public render(): TemplateResult { const filteredLogs = this.getFilteredLogs(); return html`
${this.stats?.totalRequests ?? 0} Total Requests
${this.stats?.totalResponses ?? 0} Total Responses
${this.stats?.errorCount ?? 0} Errors
${this.stats?.avgDurationMs ?? 0}ms Avg Duration
${filteredLogs.length} Showing
${this.stats && Object.keys(this.stats.methodCounts).length > 0 ? html`
Methods
${Object.entries(this.stats.methodCounts).slice(0, 8).map(([method, data]) => html`
${method}
${data.requests} req ${data.responses} res ${data.errors > 0 ? html`${data.errors} err` : ''} ${data.avgDurationMs}ms avg
`)}
` : ''}
Direction: Phase: Method:
${this.isLoading && this.logs.length === 0 ? html`
Loading request logs...
` : filteredLogs.length === 0 ? html`
No request logs found. Traffic will appear here as TypedRequests are made.
` : html`
${filteredLogs.map(log => html`
${log.direction} ${log.phase} ${log.error ? html`error` : ''}
${log.method}
${log.correlationId}
${this.formatTimestamp(log.timestamp)} ${log.durationMs !== undefined ? html` ${this.formatDuration(log.durationMs)} ` : ''}
${log.error ? html`
${log.error}
` : ''}
${this.expandedPayloads.has(log.correlationId) ? 'Hide payload' : 'Show payload'}
${this.expandedPayloads.has(log.correlationId) ? html`
${JSON.stringify(log.payload, null, 2)}
` : ''}
`)}
${this.logs.length < this.totalCount ? html` ` : ''} `} `; } }