637 lines
19 KiB
TypeScript
637 lines
19 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;
|
|
}
|
|
|
|
type TRequestFilter = 'all' | 'outgoing' | 'incoming';
|
|
type TPhaseFilter = 'all' | 'request' | 'response';
|
|
|
|
/**
|
|
* TypedRequest traffic monitoring panel for sw-dash
|
|
*/
|
|
@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-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);
|
|
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);
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
`
|
|
];
|
|
|
|
@property({ type: Array }) accessor logs: ITypedRequestLogEntry[] = [];
|
|
@state() accessor stats: ITypedRequestStats | null = null;
|
|
@state() accessor directionFilter: TRequestFilter = 'all';
|
|
@state() accessor phaseFilter: TPhaseFilter = 'all';
|
|
@state() accessor methodFilter = '';
|
|
@state() accessor searchText = '';
|
|
@state() accessor totalCount = 0;
|
|
@state() accessor isLoading = true;
|
|
@state() accessor page = 1;
|
|
@state() accessor expandedPayloads: Set<string> = new Set();
|
|
@state() accessor availableMethods: string[] = [];
|
|
private readonly pageSize = 50;
|
|
|
|
// Bound event handler reference for cleanup
|
|
private boundLogHandler: ((e: Event) => void) | null = null;
|
|
|
|
connectedCallback(): void {
|
|
super.connectedCallback();
|
|
this.loadLogs();
|
|
this.loadStats();
|
|
this.loadMethods();
|
|
this.setupPushListener();
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
if (this.boundLogHandler) {
|
|
window.removeEventListener('typedrequest-logged', this.boundLogHandler);
|
|
}
|
|
}
|
|
|
|
private setupPushListener(): void {
|
|
this.boundLogHandler = (e: Event) => {
|
|
const customEvent = e as CustomEvent<ITypedRequestLogEntry>;
|
|
const newLog = customEvent.detail;
|
|
|
|
// Apply filters
|
|
if (this.directionFilter !== 'all' && newLog.direction !== this.directionFilter) return;
|
|
if (this.phaseFilter !== 'all' && newLog.phase !== this.phaseFilter) return;
|
|
if (this.methodFilter && newLog.method !== this.methodFilter) return;
|
|
|
|
// Prepend new log
|
|
this.logs = [newLog, ...this.logs];
|
|
this.totalCount++;
|
|
|
|
// Update available methods if new
|
|
if (!this.availableMethods.includes(newLog.method)) {
|
|
this.availableMethods = [...this.availableMethods, newLog.method];
|
|
}
|
|
};
|
|
|
|
window.addEventListener('typedrequest-logged', this.boundLogHandler);
|
|
}
|
|
|
|
private async loadLogs(): Promise<void> {
|
|
this.isLoading = true;
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.set('limit', String(this.pageSize * this.page));
|
|
if (this.methodFilter) {
|
|
params.set('method', this.methodFilter);
|
|
}
|
|
|
|
const response = await fetch(`/sw-dash/requests?${params}`);
|
|
const data = await response.json();
|
|
this.logs = data.logs;
|
|
this.totalCount = data.totalCount;
|
|
} catch (err) {
|
|
console.error('Failed to load request logs:', err);
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
}
|
|
|
|
private async loadStats(): Promise<void> {
|
|
try {
|
|
const response = await fetch('/sw-dash/requests/stats');
|
|
this.stats = await response.json();
|
|
} catch (err) {
|
|
console.error('Failed to load request stats:', err);
|
|
}
|
|
}
|
|
|
|
private async loadMethods(): Promise<void> {
|
|
try {
|
|
const response = await fetch('/sw-dash/requests/methods');
|
|
const data = await response.json();
|
|
this.availableMethods = data.methods;
|
|
} catch (err) {
|
|
console.error('Failed to load methods:', err);
|
|
}
|
|
}
|
|
|
|
private handleDirectionFilterChange(e: Event): void {
|
|
this.directionFilter = (e.target as HTMLSelectElement).value as TRequestFilter;
|
|
this.page = 1;
|
|
this.loadLogs();
|
|
}
|
|
|
|
private handlePhaseFilterChange(e: Event): void {
|
|
this.phaseFilter = (e.target as HTMLSelectElement).value as TPhaseFilter;
|
|
this.page = 1;
|
|
this.loadLogs();
|
|
}
|
|
|
|
private handleMethodFilterChange(e: Event): void {
|
|
this.methodFilter = (e.target as HTMLSelectElement).value;
|
|
this.page = 1;
|
|
this.loadLogs();
|
|
}
|
|
|
|
private handleSearch(e: Event): void {
|
|
this.searchText = (e.target as HTMLInputElement).value.toLowerCase();
|
|
}
|
|
|
|
private async handleClear(): Promise<void> {
|
|
if (!confirm('Are you sure you want to clear the request logs? This cannot be undone.')) {
|
|
return;
|
|
}
|
|
try {
|
|
await fetch('/sw-dash/requests', { method: 'DELETE' });
|
|
this.loadLogs();
|
|
this.loadStats();
|
|
} catch (err) {
|
|
console.error('Failed to clear request logs:', err);
|
|
}
|
|
}
|
|
|
|
private loadMore(): void {
|
|
this.page++;
|
|
this.loadLogs();
|
|
}
|
|
|
|
private togglePayload(correlationId: string): void {
|
|
const newSet = new Set(this.expandedPayloads);
|
|
if (newSet.has(correlationId)) {
|
|
newSet.delete(correlationId);
|
|
} else {
|
|
newSet.add(correlationId);
|
|
}
|
|
this.expandedPayloads = newSet;
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
public render(): TemplateResult {
|
|
const filteredLogs = this.getFilteredLogs();
|
|
|
|
return html`
|
|
<!-- 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">${filteredLogs.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">
|
|
<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" @change="${this.handleMethodFilterChange}">
|
|
<option value="">All Methods</option>
|
|
${this.availableMethods.map(m => html`<option value="${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 -->
|
|
${this.isLoading && this.logs.length === 0 ? html`
|
|
<div class="empty-state">Loading request logs...</div>
|
|
` : filteredLogs.length === 0 ? html`
|
|
<div class="empty-state">No request logs found. Traffic will appear here as TypedRequests are made.</div>
|
|
` : html`
|
|
<div class="requests-list">
|
|
${filteredLogs.map(log => html`
|
|
<div class="request-card ${log.error ? '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>` : ''}
|
|
</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>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
${log.error ? html`
|
|
<div class="request-error">${log.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>
|
|
` : ''}
|
|
</div>
|
|
`)}
|
|
</div>
|
|
|
|
${this.logs.length < this.totalCount ? html`
|
|
<div class="pagination">
|
|
<button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoading}">
|
|
${this.isLoading ? 'Loading...' : 'Load More'}
|
|
</button>
|
|
<span class="page-info">${this.logs.length} of ${this.totalCount} logs</span>
|
|
</div>
|
|
` : ''}
|
|
`}
|
|
`;
|
|
}
|
|
}
|