diff --git a/ts_swdash/sw-dash-requests.ts b/ts_swdash/sw-dash-requests.ts index 8bfd53e..66fd1e1 100644 --- a/ts_swdash/sw-dash-requests.ts +++ b/ts_swdash/sw-dash-requests.ts @@ -21,6 +21,19 @@ export interface ITypedRequestStats { 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'; @@ -159,20 +172,6 @@ export class SwDashRequests extends LitElement { 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); @@ -288,22 +287,188 @@ export class SwDashRequests extends LitElement { 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; } + + /* 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); + } ` ]; @@ -318,9 +483,12 @@ export class SwDashRequests extends LitElement { @state() accessor phaseFilter: TPhaseFilter = 'all'; @state() accessor methodFilter = ''; @state() accessor searchText = ''; - @state() accessor expandedPayloads: Set = new Set(); @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 @@ -373,14 +541,36 @@ export class SwDashRequests extends LitElement { }, 1000); } - private togglePayload(correlationId: string): void { - const newSet = new Set(this.expandedPayloads); - if (newSet.has(correlationId)) { - newSet.delete(correlationId); - } else { - newSet.add(correlationId); + private openPayloadModal(group: IGroupedRequest): void { + this.selectedGroup = group; + this.modalOpen = true; + } + + 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(); } - this.expandedPayloads = newSet; + } + + 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 { @@ -440,10 +630,127 @@ export class SwDashRequests extends LitElement { return result; } - public render(): TemplateResult { - const filteredLogs = this.getFilteredLogs(); + /** + * 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()} +
@@ -463,7 +770,7 @@ export class SwDashRequests extends LitElement { Avg Duration
- ${filteredLogs.length} + ${groupedLogs.length} Showing
@@ -523,46 +830,51 @@ export class SwDashRequests extends LitElement { - + ${this.logs.length === 0 ? html`
No request logs found. Traffic will appear here as TypedRequests are made.
- ` : filteredLogs.length === 0 ? html` + ` : groupedLogs.length === 0 ? html`
No logs match filter
` : html`
- ${filteredLogs.map(log => html` -
+ ${groupedLogs.map(group => html` +
- ${log.direction} - ${log.phase} - ${log.error ? html`error` : ''} + ${group.request ? html` + ${group.request.direction} + ` : ''} + ${group.hasError ? html`error` : ''} +
+
${group.method}
+
${group.correlationId}
+
+ + ${group.request ? 'REQ' : 'REQ pending'} + + + ${group.response ? 'RES' : 'RES pending'} +
-
${log.method}
-
${log.correlationId}
- ${this.formatTimestamp(log.timestamp)} - ${log.durationMs !== undefined ? html` - - ${this.formatDuration(log.durationMs)} + ${this.formatTimestamp(group.timestamp)} + ${group.durationMs !== undefined ? html` + + ${this.formatDuration(group.durationMs)} ` : ''}
- ${log.error ? html` -
${log.error}
+ ${group.response?.error ? html` +
${group.response.error}
` : ''} -
- ${this.expandedPayloads.has(log.correlationId) ? 'Hide payload' : 'Show payload'} -
- - ${this.expandedPayloads.has(log.correlationId) ? html` -
${JSON.stringify(log.payload, null, 2)}
- ` : ''} +
`)}