913 lines
27 KiB
TypeScript
913 lines
27 KiB
TypeScript
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<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 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}">×</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' : ''}">
|
|
<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>
|
|
` : ''}
|
|
`}
|
|
`;
|
|
}
|
|
}
|