feat(sw-dash): Group requests by correlationId with full-page payload modal

- Group request/response entries by correlationId for unified display
- Add IGroupedRequest interface for paired request/response data
- Replace inline payload toggle with Show Payload button
- Create full-page modal with request data on left, response on right
- Support keyboard escape to close modal
- Show REQ/RES status badges in grouped cards
This commit is contained in:
2025-12-05 00:09:37 +00:00
parent 5fa97322fb
commit 32cb5bb423

View File

@@ -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<string> = 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<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">
@@ -463,7 +770,7 @@ export class SwDashRequests extends LitElement {
<span class="stat-label">Avg Duration</span>
</div>
<div class="stat-item">
<span class="stat-value">${filteredLogs.length}</span>
<span class="stat-value">${groupedLogs.length}</span>
<span class="stat-label">Showing</span>
</div>
</div>
@@ -523,46 +830,51 @@ export class SwDashRequests extends LitElement {
<button class="btn clear-btn" @click="${this.handleClear}">Clear Logs</button>
</div>
<!-- Request List -->
<!-- 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>
` : filteredLogs.length === 0 ? html`
` : groupedLogs.length === 0 ? html`
<div class="empty-state">No logs match filter</div>
` : html`
<div class="requests-list">
${filteredLogs.map(log => html`
<div class="request-card ${log.error ? 'has-error' : ''}">
${groupedLogs.map(group => html`
<div class="request-card ${group.hasError ? 'has-error' : ''}">
<div class="request-header">
<div>
<div class="request-badges">
<span class="badge direction-${log.direction}">${log.direction}</span>
<span class="badge phase-${log.phase}">${log.phase}</span>
${log.error ? html`<span class="badge error">error</span>` : ''}
${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 class="method-name">${log.method}</div>
<div class="correlation-id">${log.correlationId}</div>
</div>
<div class="request-meta">
<span class="request-time">${this.formatTimestamp(log.timestamp)}</span>
${log.durationMs !== undefined ? html`
<span class="request-duration ${this.getDurationClass(log.durationMs)}">
${this.formatDuration(log.durationMs)}
<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>
${log.error ? html`
<div class="request-error">${log.error}</div>
${group.response?.error ? html`
<div class="request-error">${group.response.error}</div>
` : ''}
<div class="toggle-payload" @click="${() => this.togglePayload(log.correlationId)}">
${this.expandedPayloads.has(log.correlationId) ? 'Hide payload' : 'Show payload'}
</div>
${this.expandedPayloads.has(log.correlationId) ? html`
<div class="request-payload">${JSON.stringify(log.payload, null, 2)}</div>
` : ''}
<button class="btn-show-payload" @click="${() => this.openPayloadModal(group)}">
Show Payload
</button>
</div>
`)}
</div>