UNPKG

@api.global/typedserver

Version:

A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.

1,000 lines (881 loc) 30.2 kB
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<string, { requests: number; responses: number; errors: number; avgDurationMs: number }>; 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<string, IGroupedRequest>(); 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` <div class="payload-modal-overlay" @click="${this.handleModalOverlayClick}"> <div class="payload-modal"> <div class="modal-header"> <div> <div class="modal-title">${group.method}</div> <div class="modal-subtitle"> Correlation ID: ${group.correlationId} ${group.durationMs !== undefined ? html` | Duration: ${this.formatDuration(group.durationMs)}` : ''} </div> </div> <button class="modal-close" @click="${this.closeModal}">&times;</button> </div> <div class="modal-body"> <!-- Request Panel (Left) --> <div class="payload-panel"> <div class="payload-panel-header"> <span class="badge phase-request">REQUEST</span> ${group.request ? html` <span class="badge direction-${group.request.direction}">${group.request.direction}</span> ` : ''} </div> ${group.request ? html` <div class="payload-meta"> Timestamp: ${this.formatTimestamp(group.request.timestamp)} </div> <div class="payload-panel-content"> <pre class="payload-json">${JSON.stringify(group.request.payload, null, 2)}</pre> </div> ` : html` <div class="payload-empty">No request data captured</div> `} </div> <!-- Response Panel (Right) --> <div class="payload-panel"> <div class="payload-panel-header"> <span class="badge phase-response">RESPONSE</span> ${group.response ? html` <span class="badge direction-${group.response.direction}">${group.response.direction}</span> ` : ''} </div> ${group.response?.error ? html` <div class="payload-error">Error: ${group.response.error}</div> ` : ''} ${group.response ? html` <div class="payload-meta"> Timestamp: ${this.formatTimestamp(group.response.timestamp)} ${group.response.durationMs !== undefined ? html` | Duration: ${this.formatDuration(group.response.durationMs)}` : ''} </div> <div class="payload-panel-content"> <pre class="payload-json">${JSON.stringify(group.response.payload, null, 2)}</pre> </div> ` : html` <div class="payload-empty">No response yet (pending)</div> `} </div> </div> </div> </div> `; } public render(): TemplateResult { const groupedLogs = this.getGroupedLogs(); return html` ${this.renderModal()} <!-- Stats Bar --> <div class="stats-bar"> <div class="stat-item"> <span class="stat-value">${this.stats?.totalRequests ?? 0}</span> <span class="stat-label">Total Requests</span> </div> <div class="stat-item"> <span class="stat-value">${this.stats?.totalResponses ?? 0}</span> <span class="stat-label">Total Responses</span> </div> <div class="stat-item"> <span class="stat-value ${(this.stats?.errorCount ?? 0) > 0 ? 'error' : ''}">${this.stats?.errorCount ?? 0}</span> <span class="stat-label">Errors</span> </div> <div class="stat-item"> <span class="stat-value">${this.stats?.avgDurationMs ?? 0}ms</span> <span class="stat-label">Avg Duration</span> </div> <div class="stat-item"> <span class="stat-value">${groupedLogs.length}</span> <span class="stat-label">Showing</span> </div> </div> <!-- Method Stats --> ${this.stats && Object.keys(this.stats.methodCounts).length > 0 ? html` <div class="method-stats"> <div class="method-stats-title">Methods</div> <div class="method-stats-grid"> ${Object.entries(this.stats.methodCounts).slice(0, 8).map(([method, data]) => html` <div class="method-stat-card ${this.methodFilter === method ? 'active' : ''}" @click="${() => this.setMethodFilter(method)}" > <div class="method-stat-name" title="${method}">${method}</div> <div class="method-stat-details"> <span>${data.requests} req</span> <span>${data.responses} res</span> ${data.errors > 0 ? html`<span style="color: var(--accent-error)">${data.errors} err</span>` : ''} <span>${data.avgDurationMs}ms avg</span> </div> </div> `)} </div> </div> ` : ''} <!-- Filters --> <div class="requests-header"> <div class="filter-group"> <span class="filter-label">Direction:</span> <select class="filter-select" @change="${this.handleDirectionFilterChange}"> <option value="all">All</option> <option value="outgoing">Outgoing</option> <option value="incoming">Incoming</option> </select> <span class="filter-label">Phase:</span> <select class="filter-select" @change="${this.handlePhaseFilterChange}"> <option value="all">All</option> <option value="request">Request</option> <option value="response">Response</option> </select> <span class="filter-label">Method:</span> <select class="filter-select" .value="${this.methodFilter}" @change="${this.handleMethodFilterChange}"> <option value="">All Methods</option> ${this.methods.map(m => html`<option value="${m}" ?selected="${this.methodFilter === m}">${m}</option>`)} </select> <input type="text" class="search-input" placeholder="Search..." .value="${this.searchText}" @input="${this.handleSearch}" style="width: 150px;" > </div> <button class="btn clear-btn" @click="${this.handleClear}">Clear Logs</button> </div> <!-- Request List (Grouped by correlationId) --> ${this.logs.length === 0 ? html` <div class="empty-state">No request logs found. Traffic will appear here as TypedRequests are made.</div> ` : groupedLogs.length === 0 ? html` <div class="empty-state">No logs match filter</div> ` : html` <div class="requests-list"> ${groupedLogs.map(group => html` <div class="request-card ${group.hasError ? 'has-error' : ''}" @contextmenu="${(e: MouseEvent) => this.handleContextMenu(e, group)}" > <div class="request-header"> <div> <div class="request-badges"> ${group.request ? html` <span class="badge direction-${group.request.direction}">${group.request.direction}</span> ` : ''} ${group.hasError ? html`<span class="badge error">error</span>` : ''} </div> <div class="method-name">${group.method}</div> <div class="correlation-id">${group.correlationId}</div> <div class="request-response-badges"> <span class="status-badge ${group.request ? 'has-request' : 'pending'}"> ${group.request ? 'REQ' : 'REQ pending'} </span> <span class="status-badge ${group.response ? 'has-response' : 'pending'}"> ${group.response ? 'RES' : 'RES pending'} </span> </div> </div> <div class="request-meta"> <span class="request-time">${this.formatTimestamp(group.timestamp)}</span> ${group.durationMs !== undefined ? html` <span class="request-duration ${this.getDurationClass(group.durationMs)}"> ${this.formatDuration(group.durationMs)} </span> ` : ''} </div> </div> ${group.response?.error ? html` <div class="request-error">${group.response.error}</div> ` : ''} <button class="btn-show-payload" @click="${() => this.openPayloadModal(group)}"> Show Payload </button> </div> `)} </div> ${this.logs.length < this.totalCount ? html` <div class="pagination"> <button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoadingMore}"> ${this.isLoadingMore ? 'Loading...' : 'Load More'} </button> <span class="page-info">${this.logs.length} of ${this.totalCount} logs</span> </div> ` : ''} `} `; } }