import { LitElement, html, css, property, state, customElement, DeesContextmenu } 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; } /** * Grouped request/response pair by correlationId */ export interface IGroupedRequest { correlationId: string; method: string; request?: ITypedRequestLogEntry; response?: ITypedRequestLogEntry; timestamp: number; durationMs?: number; hasError: boolean; } type TRequestFilter = 'all' | 'outgoing' | 'incoming'; type TPhaseFilter = 'all' | 'request' | 'response'; /** * TypedRequest traffic monitoring panel for sw-dash * * Receives logs, stats, and methods via properties from parent (sw-dash-app). * Filtering is done locally. * Load more and clear operations dispatch events to parent. */ @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-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); cursor: pointer; transition: background 0.15s ease; } .method-stat-card:hover { background: var(--bg-secondary); } .method-stat-card.active { background: rgba(99, 102, 241, 0.15); border: 1px solid var(--accent-primary); } .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); } .correlation-id { font-size: 10px; color: var(--text-tertiary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } /* Grouped request card */ .request-card .request-response-badges { display: flex; gap: var(--space-2); margin-top: var(--space-1); } .request-card .status-badge { font-size: 10px; padding: 2px 6px; border-radius: var(--radius-sm); } .status-badge.has-request { background: rgba(251, 191, 36, 0.15); color: var(--accent-warning); } .status-badge.has-response { background: rgba(99, 102, 241, 0.15); color: var(--accent-primary); } .status-badge.pending { background: rgba(156, 163, 175, 0.15); color: var(--text-tertiary); } .btn-show-payload { background: var(--bg-tertiary); border: 1px solid var(--border-default); color: var(--accent-primary); font-size: 11px; padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); cursor: pointer; margin-top: var(--space-2); } .btn-show-payload:hover { background: var(--accent-primary); color: white; } /* Modal styles */ .payload-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: var(--space-4); } .payload-modal { background: var(--bg-primary); border-radius: var(--radius-lg); border: 1px solid var(--border-default); width: 100%; max-width: 1400px; height: 90vh; display: flex; flex-direction: column; overflow: hidden; } .modal-header { display: flex; justify-content: space-between; align-items: center; padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-default); background: var(--bg-secondary); } .modal-title { font-size: 14px; font-weight: 600; color: var(--text-primary); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } .modal-subtitle { font-size: 11px; color: var(--text-tertiary); margin-top: var(--space-1); } .modal-close { background: transparent; border: none; color: var(--text-tertiary); font-size: 24px; cursor: pointer; padding: var(--space-1); line-height: 1; } .modal-close:hover { color: var(--text-primary); } .modal-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; flex: 1; overflow: hidden; background: var(--border-default); } .payload-panel { background: var(--bg-primary); display: flex; flex-direction: column; overflow: hidden; } .payload-panel-header { padding: var(--space-2) var(--space-3); background: var(--bg-secondary); border-bottom: 1px solid var(--border-default); font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: var(--space-2); } .payload-panel-header .badge { font-size: 10px; } .payload-panel-content { flex: 1; overflow: auto; padding: var(--space-3); } .payload-json { font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; word-break: break-all; line-height: 1.5; } .payload-empty { color: var(--text-tertiary); font-style: italic; font-size: 12px; padding: var(--space-4); text-align: center; } .payload-meta { font-size: 11px; color: var(--text-tertiary); padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border-default); background: var(--bg-tertiary); } .payload-error { background: rgba(239, 68, 68, 0.1); color: var(--accent-error); padding: var(--space-2) var(--space-3); font-size: 12px; border-bottom: 1px solid rgba(239, 68, 68, 0.2); } ` ]; // Received from parent (sw-dash-app) @property({ type: Array }) accessor logs: ITypedRequestLogEntry[] = []; @property({ type: Number }) accessor totalCount = 0; @property({ type: Object }) accessor stats: ITypedRequestStats | null = null; @property({ type: Array }) accessor methods: string[] = []; // Local state for filtering @state() accessor directionFilter: TRequestFilter = 'all'; @state() accessor phaseFilter: TPhaseFilter = 'all'; @state() accessor methodFilter = ''; @state() accessor searchText = ''; @state() accessor isLoadingMore = false; // Modal state @state() accessor modalOpen = false; @state() accessor selectedGroup: IGroupedRequest | null = null; private handleDirectionFilterChange(e: Event): void { this.directionFilter = (e.target as HTMLSelectElement).value as TRequestFilter; // Local filtering - no HTTP request } private handlePhaseFilterChange(e: Event): void { this.phaseFilter = (e.target as HTMLSelectElement).value as TPhaseFilter; // Local filtering - no HTTP request } private handleMethodFilterChange(e: Event): void { this.methodFilter = (e.target as HTMLSelectElement).value; // Local filtering - no HTTP request } private setMethodFilter(method: string): void { // Toggle: clicking the same method clears the filter this.methodFilter = this.methodFilter === method ? '' : method; } private handleSearch(e: Event): void { this.searchText = (e.target as HTMLInputElement).value.toLowerCase(); } private handleClear(): void { if (!confirm('Are you sure you want to clear the request logs? This cannot be undone.')) { return; } // Dispatch event to parent to clear via DeesComms this.dispatchEvent(new CustomEvent('clear-requests', { bubbles: true, composed: true, })); } private loadMore(): void { if (this.isLoadingMore || this.logs.length === 0) return; this.isLoadingMore = true; const oldestLog = this.logs[this.logs.length - 1]; // Dispatch event to parent to load more via DeesComms this.dispatchEvent(new CustomEvent('load-more-requests', { detail: { before: oldestLog.timestamp, method: this.methodFilter || undefined, }, bubbles: true, composed: true, })); // Reset loading state after a short delay (parent will update logs prop) setTimeout(() => { this.isLoadingMore = false; }, 1000); } private openPayloadModal(group: IGroupedRequest): void { this.selectedGroup = group; this.modalOpen = true; } private handleContextMenu(event: MouseEvent, group: IGroupedRequest): void { // Build full message object for copying const fullMessage = { correlationId: group.correlationId, method: group.method, timestamp: group.timestamp, durationMs: group.durationMs, request: group.request ? { direction: group.request.direction, phase: group.request.phase, timestamp: group.request.timestamp, payload: group.request.payload, } : null, response: group.response ? { direction: group.response.direction, phase: group.response.phase, timestamp: group.response.timestamp, durationMs: group.response.durationMs, payload: group.response.payload, error: group.response.error, } : null, }; DeesContextmenu.openContextMenuWithOptions(event, [ { name: 'Copy Full Message', iconName: 'copy', action: async () => { await navigator.clipboard.writeText(JSON.stringify(fullMessage, null, 2)); }, }, { name: 'Copy Request Payload', iconName: 'upload', disabled: !group.request, action: async () => { if (group.request) { await navigator.clipboard.writeText(JSON.stringify(group.request.payload, null, 2)); } }, }, { name: 'Copy Response Payload', iconName: 'download', disabled: !group.response, action: async () => { if (group.response) { await navigator.clipboard.writeText(JSON.stringify(group.response.payload, null, 2)); } }, }, { divider: true }, { name: 'Copy Correlation ID', iconName: 'hash', action: async () => { await navigator.clipboard.writeText(group.correlationId); }, }, { name: 'Copy Method Name', iconName: 'tag', action: async () => { await navigator.clipboard.writeText(group.method); }, }, { divider: true }, { name: 'Filter by Method', iconName: 'filter', action: async () => { this.setMethodFilter(group.method); }, }, { name: 'Show Payload', iconName: 'eye', action: async () => { this.openPayloadModal(group); }, }, ]); } private closeModal(): void { this.modalOpen = false; this.selectedGroup = null; } private handleModalOverlayClick(e: Event): void { if ((e.target as HTMLElement).classList.contains('payload-modal-overlay')) { this.closeModal(); } } private handleKeydown = (e: KeyboardEvent): void => { if (e.key === 'Escape' && this.modalOpen) { this.closeModal(); } }; connectedCallback(): void { super.connectedCallback(); document.addEventListener('keydown', this.handleKeydown); } disconnectedCallback(): void { super.disconnectedCallback(); document.removeEventListener('keydown', this.handleKeydown); } 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`; } /** * Filter logs locally based on direction, phase, method, and search text */ 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 method filter if (this.methodFilter) { result = result.filter(l => l.method === this.methodFilter); } // 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; } /** * Group filtered logs by correlationId to show request/response pairs together */ private getGroupedLogs(): IGroupedRequest[] { const filtered = this.getFilteredLogs(); const groups = new Map(); for (const log of filtered) { let group = groups.get(log.correlationId); if (!group) { group = { correlationId: log.correlationId, method: log.method, timestamp: log.timestamp, hasError: false, }; groups.set(log.correlationId, group); } if (log.phase === 'request') { group.request = log; // Update timestamp to the earliest (request time) if (log.timestamp < group.timestamp) { group.timestamp = log.timestamp; } } else if (log.phase === 'response') { group.response = log; if (log.durationMs !== undefined) { group.durationMs = log.durationMs; } } if (log.error) { group.hasError = true; } } // Convert to array and sort by timestamp (newest first) return Array.from(groups.values()).sort((a, b) => b.timestamp - a.timestamp); } /** * Render the payload modal */ private renderModal(): TemplateResult | null { if (!this.modalOpen || !this.selectedGroup) { return null; } const group = this.selectedGroup; return html`
`; } public render(): TemplateResult { const groupedLogs = this.getGroupedLogs(); return html` ${this.renderModal()}
${this.stats?.totalRequests ?? 0} Total Requests
${this.stats?.totalResponses ?? 0} Total Responses
${this.stats?.errorCount ?? 0} Errors
${this.stats?.avgDurationMs ?? 0}ms Avg Duration
${groupedLogs.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.logs.length === 0 ? html`
No request logs found. Traffic will appear here as TypedRequests are made.
` : groupedLogs.length === 0 ? html`
No logs match filter
` : html`
${groupedLogs.map(group => html`
${group.request ? html` ${group.request.direction} ` : ''} ${group.hasError ? html`error` : ''}
${group.method}
${group.correlationId}
${group.request ? 'REQ' : 'REQ pending'} ${group.response ? 'RES' : 'RES pending'}
${this.formatTimestamp(group.timestamp)} ${group.durationMs !== undefined ? html` ${this.formatDuration(group.durationMs)} ` : ''}
${group.response?.error ? html`
${group.response.error}
` : ''}
`)}
${this.logs.length < this.totalCount ? html` ` : ''} `} `; } }