feat(serviceworker): Add TypedRequest traffic monitoring and SW dashboard Requests panel
This commit is contained in:
14
changelog.md
14
changelog.md
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-04 - 7.8.0 - feat(serviceworker)
|
||||
Add TypedRequest traffic monitoring and SW dashboard 'Requests' panel
|
||||
|
||||
- Add TypedRequest traffic monitoring interfaces and shared SW dashboard HTML (SW_DASH_HTML) to ts_interfaces/serviceworker.ts.
|
||||
- Introduce RequestLogStore (ts_web_serviceworker/classes.requestlogstore.ts) to collect, persist in-memory, and compute stats for TypedRequest traffic (logs, counts, methods, averages).
|
||||
- Add service worker backend handlers to receive and broadcast TypedRequest logs and to expose endpoints: serviceworker_typedRequestLog, serviceworker_getTypedRequestLogs, serviceworker_getTypedRequestStats, serviceworker_clearTypedRequestLogs.
|
||||
- Expose HTTP routes and fallback behaviors in the server built-in controller to serve the SW dashboard (GET /sw-dash and /sw-dash/bundle.js) and to return sensible 503 placeholders when the SW is not active.
|
||||
- Extend the service worker CacheManager and DashboardGenerator to serve TypedRequest-related endpoints (/sw-dash/requests, /sw-dash/requests/stats, /sw-dash/requests/methods) and to integrate RequestLogStore data into the dashboard APIs.
|
||||
- Add a Lit-based dashboard component sw-dash-requests (ts_swdash/sw-dash-requests.ts) and integrate it into the main sw-dash-app UI to display live TypedRequest traffic with filtering, payload toggles, pagination and clear logs action.
|
||||
- Enable client-side traffic logging from the injected reload checker (ts_web_inject/index.ts) by setting global TypedRouter hooks that send log entries to the service worker, with safeguards to avoid logging the logging requests themselves.
|
||||
- Add action manager utilities (ts_web_serviceworker_client/classes.actionmanager.ts) to log TypedRequest entries to the SW, query logs and stats, clear logs, and subscribe to real-time TypedRequest broadcasts.
|
||||
- Refactor Dashboard HTML generation to use the shared SW_DASH_HTML constant so server and service worker serve the same UI shell.
|
||||
- Integrate broadcasting of TypedRequest log events from service worker backend to connected clients so the SW dashboard updates in real time.
|
||||
|
||||
## 2025-12-04 - 7.7.1 - fix(web_serviceworker)
|
||||
Standardize DeesComms message format in service worker backend
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '7.7.1',
|
||||
version: '7.8.0',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -124,6 +124,65 @@ export class BuiltInRoutesController {
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash')
|
||||
async getSwDash(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
|
||||
// Import shared HTML from interfaces
|
||||
const { SW_DASH_HTML } = await import('../../dist_ts_interfaces/serviceworker.js');
|
||||
return new Response(SW_DASH_HTML, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
}
|
||||
|
||||
// SW-dash data routes - return empty/unavailable when SW isn't active
|
||||
@plugins.smartserve.Get('/sw-dash/metrics')
|
||||
async getSwDashMetrics(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', data: null }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/resources')
|
||||
async getSwDashResources(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', resources: [], domains: [], contentTypes: [], resourceCount: 0 }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/events')
|
||||
async getSwDashEvents(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', events: [], total: 0 }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/events/count')
|
||||
async getSwDashEventsCount(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', count: 0 }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/cumulative-metrics')
|
||||
async getSwDashCumulativeMetrics(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active', data: null }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/speedtest')
|
||||
async getSwDashSpeedtest(): Promise<Response> {
|
||||
return new Response(JSON.stringify({ error: 'Service worker not active - speedtest unavailable' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/sw-dash/bundle.js')
|
||||
async getSwDashBundle(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
|
||||
try {
|
||||
|
||||
@@ -509,4 +509,131 @@ export interface IMessage_Serviceworker_ResourceCached
|
||||
cached: boolean;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================
|
||||
// TypedRequest Traffic Monitoring
|
||||
// =============================
|
||||
|
||||
/**
|
||||
* Log entry for TypedRequest traffic monitoring
|
||||
*/
|
||||
export interface ITypedRequestLogEntry {
|
||||
correlationId: string;
|
||||
method: string;
|
||||
direction: 'outgoing' | 'incoming';
|
||||
phase: 'request' | 'response';
|
||||
timestamp: number;
|
||||
durationMs?: number;
|
||||
payload: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for TypedRequest traffic
|
||||
*/
|
||||
export interface ITypedRequestStats {
|
||||
totalRequests: number;
|
||||
totalResponses: number;
|
||||
methodCounts: Record<string, { requests: number; responses: number; errors: number; avgDurationMs: number }>;
|
||||
errorCount: number;
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message for logging TypedRequest traffic from client to SW
|
||||
*/
|
||||
export interface IMessage_Serviceworker_TypedRequestLog
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_TypedRequestLog
|
||||
> {
|
||||
method: 'serviceworker_typedRequestLog';
|
||||
request: ITypedRequestLogEntry;
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push notification when a TypedRequest is logged
|
||||
*/
|
||||
export interface IMessage_Serviceworker_TypedRequestLogged
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IMessage_Serviceworker_TypedRequestLogged
|
||||
> {
|
||||
method: 'serviceworker_typedRequestLogged';
|
||||
request: ITypedRequestLogEntry;
|
||||
response: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get TypedRequest logs
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetTypedRequestLogs
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetTypedRequestLogs
|
||||
> {
|
||||
method: 'serviceworker_getTypedRequestLogs';
|
||||
request: {
|
||||
limit?: number;
|
||||
method?: string;
|
||||
since?: number;
|
||||
};
|
||||
response: {
|
||||
logs: ITypedRequestLogEntry[];
|
||||
totalCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to get TypedRequest traffic statistics
|
||||
*/
|
||||
export interface IRequest_Serviceworker_GetTypedRequestStats
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_GetTypedRequestStats
|
||||
> {
|
||||
method: 'serviceworker_getTypedRequestStats';
|
||||
request: {};
|
||||
response: ITypedRequestStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to clear TypedRequest logs
|
||||
*/
|
||||
export interface IRequest_Serviceworker_ClearTypedRequestLogs
|
||||
extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IRequest_Serviceworker_ClearTypedRequestLogs
|
||||
> {
|
||||
method: 'serviceworker_clearTypedRequestLogs';
|
||||
request: {};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================
|
||||
// Shared Constants
|
||||
// =============================
|
||||
|
||||
/**
|
||||
* HTML shell for the SW Dashboard - shared between server and service worker
|
||||
*/
|
||||
export const SW_DASH_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SW Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0a0a; min-height: 100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<sw-dash-app></sw-dash-app>
|
||||
<script type="module" src="/sw-dash/bundle.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -13,9 +13,10 @@ import './sw-dash-urls.js';
|
||||
import './sw-dash-domains.js';
|
||||
import './sw-dash-types.js';
|
||||
import './sw-dash-events.js';
|
||||
import './sw-dash-requests.js';
|
||||
import './sw-dash-table.js';
|
||||
|
||||
type ViewType = 'overview' | 'urls' | 'domains' | 'types' | 'events';
|
||||
type ViewType = 'overview' | 'urls' | 'domains' | 'types' | 'events' | 'requests';
|
||||
|
||||
interface IResourceData {
|
||||
resources: ICachedResource[];
|
||||
@@ -297,6 +298,20 @@ export class SwDashApp extends LitElement {
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
// Handle TypedRequest logged push updates - dispatch to requests component
|
||||
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_TypedRequestLogged>(
|
||||
'serviceworker_typedRequestLogged',
|
||||
async (entry) => {
|
||||
// Dispatch custom event for sw-dash-requests component
|
||||
this.dispatchEvent(new CustomEvent('typedrequest-logged', {
|
||||
detail: entry,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
return {};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +404,10 @@ export class SwDashApp extends LitElement {
|
||||
class="nav-tab ${this.currentView === 'events' ? 'active' : ''}"
|
||||
@click="${() => this.setView('events')}"
|
||||
>Events</button>
|
||||
<button
|
||||
class="nav-tab ${this.currentView === 'requests' ? 'active' : ''}"
|
||||
@click="${() => this.setView('requests')}"
|
||||
>Requests</button>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
@@ -414,6 +433,10 @@ export class SwDashApp extends LitElement {
|
||||
<div class="view ${this.currentView === 'events' ? 'active' : ''}">
|
||||
<sw-dash-events></sw-dash-events>
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'requests' ? 'active' : ''}">
|
||||
<sw-dash-requests></sw-dash-requests>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
|
||||
636
ts_swdash/sw-dash-requests.ts
Normal file
636
ts_swdash/sw-dash-requests.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
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>
|
||||
` : ''}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ logger.log('info', `TypedServer-Devtools initialized!`);
|
||||
|
||||
import { TypedserverStatusPill } from './typedserver_web.statuspill.js';
|
||||
|
||||
// Import hook types from typedrequest
|
||||
type ITypedRequestLogEntry = plugins.typedrequest.ITypedRequestLogEntry;
|
||||
|
||||
export class ReloadChecker {
|
||||
public reloadJustified = false;
|
||||
public backendConnectionLost = false;
|
||||
@@ -18,6 +21,7 @@ export class ReloadChecker {
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private swStatusUnsubscribe: (() => void) | null = null;
|
||||
private trafficLoggingEnabled = false;
|
||||
|
||||
constructor() {
|
||||
// Listen to browser online/offline events
|
||||
@@ -240,6 +244,9 @@ export class ReloadChecker {
|
||||
}
|
||||
});
|
||||
logger.log('success', `ReloadChecker connected through typedsocket!`);
|
||||
|
||||
// Enable traffic logging for sw-dash
|
||||
this.enableTrafficLogging();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +275,51 @@ export class ReloadChecker {
|
||||
this.swStatusUnsubscribe();
|
||||
this.swStatusUnsubscribe = null;
|
||||
}
|
||||
// Clear global hooks when stopping
|
||||
if (this.trafficLoggingEnabled) {
|
||||
plugins.typedrequest.TypedRouter.clearGlobalHooks();
|
||||
this.trafficLoggingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable TypedRequest traffic logging to the service worker
|
||||
* Sets up global hooks on TypedRouter to capture all request/response traffic
|
||||
*/
|
||||
public enableTrafficLogging(): void {
|
||||
if (this.trafficLoggingEnabled) {
|
||||
logger.log('note', 'Traffic logging already enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if service worker client is available
|
||||
if (!globalThis.globalSw?.actionManager) {
|
||||
logger.log('note', 'Service worker client not available, will retry traffic logging setup...');
|
||||
setTimeout(() => this.enableTrafficLogging(), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const actionManager = globalThis.globalSw.actionManager;
|
||||
|
||||
// Helper function to log entries
|
||||
const logEntry = (entry: ITypedRequestLogEntry) => {
|
||||
// Skip logging our own logging requests to avoid infinite loops
|
||||
if (entry.method === 'serviceworker_typedRequestLog') {
|
||||
return;
|
||||
}
|
||||
actionManager.logTypedRequest(entry);
|
||||
};
|
||||
|
||||
// Set up global hooks on TypedRouter
|
||||
plugins.typedrequest.TypedRouter.setGlobalHooks({
|
||||
onOutgoingRequest: logEntry,
|
||||
onIncomingResponse: logEntry,
|
||||
onIncomingRequest: logEntry,
|
||||
onOutgoingResponse: logEntry,
|
||||
});
|
||||
|
||||
this.trafficLoggingEnabled = true;
|
||||
logger.log('success', 'TypedRequest traffic logging enabled');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { logger } from './logging.js';
|
||||
import { getMetricsCollector } from './classes.metrics.js';
|
||||
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
|
||||
import { getPersistentStore } from './classes.persistentstore.js';
|
||||
import { getRequestLogStore } from './classes.requestlogstore.js';
|
||||
|
||||
// Add type definitions for ServiceWorker APIs
|
||||
declare global {
|
||||
@@ -142,6 +143,48 @@ export class ServiceworkerBackend {
|
||||
return { count };
|
||||
});
|
||||
|
||||
// ================================
|
||||
// TypedRequest Traffic Monitoring
|
||||
// ================================
|
||||
|
||||
// Handler for receiving TypedRequest logs from clients
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLog>('serviceworker_typedRequestLog', async (reqArg) => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
requestLogStore.addEntry(reqArg);
|
||||
|
||||
// Broadcast to sw-dash viewers
|
||||
await this.broadcastTypedRequestLogged(reqArg);
|
||||
return {};
|
||||
});
|
||||
|
||||
// Handler for getting TypedRequest logs
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs', async (reqArg) => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
const logs = requestLogStore.getEntries({
|
||||
limit: reqArg.limit,
|
||||
method: reqArg.method,
|
||||
since: reqArg.since,
|
||||
});
|
||||
const totalCount = requestLogStore.getTotalCount({
|
||||
method: reqArg.method,
|
||||
since: reqArg.since,
|
||||
});
|
||||
return { logs, totalCount };
|
||||
});
|
||||
|
||||
// Handler for getting TypedRequest statistics
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestStats>('serviceworker_getTypedRequestStats', async () => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
return requestLogStore.getStats();
|
||||
});
|
||||
|
||||
// Handler for clearing TypedRequest logs
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs', async () => {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
requestLogStore.clear();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Periodically update connected clients count
|
||||
this.startClientCountUpdates();
|
||||
|
||||
@@ -267,6 +310,17 @@ export class ServiceworkerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a TypedRequest log entry to all connected clients (for sw-dash)
|
||||
*/
|
||||
public async broadcastTypedRequestLogged(entry: interfaces.serviceworker.ITypedRequestLogEntry): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_typedRequestLogged', entry));
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to broadcast TypedRequest log: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic updates of connected client count
|
||||
*/
|
||||
|
||||
@@ -247,6 +247,29 @@ export class CacheManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// TypedRequest traffic monitoring endpoints
|
||||
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'GET') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestLogs(parsedUrl.searchParams)));
|
||||
return;
|
||||
}
|
||||
if (parsedUrl.pathname === '/sw-dash/requests/stats') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestStats()));
|
||||
return;
|
||||
}
|
||||
if (parsedUrl.pathname === '/sw-dash/requests/methods') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestMethods()));
|
||||
return;
|
||||
}
|
||||
// DELETE method for clearing TypedRequest logs
|
||||
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'DELETE') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.clearTypedRequestLogs()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Block requests that we don't want the service worker to handle.
|
||||
if (
|
||||
parsedUrl.hostname.includes('paddle.com') ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getMetricsCollector } from './classes.metrics.js';
|
||||
import { getServiceWorkerInstance } from './init.js';
|
||||
import { getPersistentStore } from './classes.persistentstore.js';
|
||||
import { getRequestLogStore } from './classes.requestlogstore.js';
|
||||
import * as interfaces from './env.js';
|
||||
import type { serviceworker } from '../dist_ts_interfaces/index.js';
|
||||
|
||||
@@ -119,6 +120,76 @@ export class DashboardGenerator {
|
||||
});
|
||||
}
|
||||
|
||||
// ================================
|
||||
// TypedRequest Traffic Endpoints
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Serves TypedRequest traffic logs
|
||||
*/
|
||||
public serveTypedRequestLogs(searchParams: URLSearchParams): Response {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
|
||||
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!, 10) : undefined;
|
||||
const method = searchParams.get('method') || undefined;
|
||||
const since = searchParams.get('since') ? parseInt(searchParams.get('since')!, 10) : undefined;
|
||||
|
||||
const logs = requestLogStore.getEntries({ limit, method, since });
|
||||
const totalCount = requestLogStore.getTotalCount({ method, since });
|
||||
|
||||
return new Response(JSON.stringify({ logs, totalCount }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves TypedRequest traffic statistics
|
||||
*/
|
||||
public serveTypedRequestStats(): Response {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
const stats = requestLogStore.getStats();
|
||||
|
||||
return new Response(JSON.stringify(stats), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears TypedRequest traffic logs
|
||||
*/
|
||||
public clearTypedRequestLogs(): Response {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
requestLogStore.clear();
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves unique method names from TypedRequest logs
|
||||
*/
|
||||
public serveTypedRequestMethods(): Response {
|
||||
const requestLogStore = getRequestLogStore();
|
||||
const methods = requestLogStore.getMethods();
|
||||
|
||||
return new Response(JSON.stringify({ methods }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Speedtest configuration
|
||||
private static readonly TEST_DURATION_MS = 5000; // 5 seconds per test
|
||||
private static readonly CHUNK_SIZE_KB = 64; // 64KB chunks
|
||||
@@ -262,25 +333,7 @@ export class DashboardGenerator {
|
||||
* Generates a minimal HTML shell that loads the Lit-based dashboard bundle
|
||||
*/
|
||||
public generateDashboardHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SW Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<sw-dash-app></sw-dash-app>
|
||||
<script type="module" src="/sw-dash/bundle.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
return interfaces.serviceworker.SW_DASH_HTML;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
190
ts_web_serviceworker/classes.requestlogstore.ts
Normal file
190
ts_web_serviceworker/classes.requestlogstore.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { logger } from './logging.js';
|
||||
import * as interfaces from '../dist_ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Store for TypedRequest traffic logs
|
||||
* Keeps recent request/response logs in memory for dashboard display
|
||||
*/
|
||||
export class RequestLogStore {
|
||||
private logs: interfaces.serviceworker.ITypedRequestLogEntry[] = [];
|
||||
private maxEntries: number;
|
||||
|
||||
// Statistics
|
||||
private stats: interfaces.serviceworker.ITypedRequestStats = {
|
||||
totalRequests: 0,
|
||||
totalResponses: 0,
|
||||
methodCounts: {},
|
||||
errorCount: 0,
|
||||
avgDurationMs: 0,
|
||||
};
|
||||
|
||||
// For calculating rolling average
|
||||
private totalDuration = 0;
|
||||
private durationCount = 0;
|
||||
|
||||
constructor(maxEntries = 500) {
|
||||
this.maxEntries = maxEntries;
|
||||
logger.log('info', `RequestLogStore initialized with max ${maxEntries} entries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new log entry
|
||||
*/
|
||||
public addEntry(entry: interfaces.serviceworker.ITypedRequestLogEntry): void {
|
||||
// Add to log
|
||||
this.logs.push(entry);
|
||||
|
||||
// Trim if over max
|
||||
if (this.logs.length > this.maxEntries) {
|
||||
this.logs.shift();
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
this.updateStats(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update statistics based on new entry
|
||||
*/
|
||||
private updateStats(entry: interfaces.serviceworker.ITypedRequestLogEntry): void {
|
||||
// Count request/response
|
||||
if (entry.phase === 'request') {
|
||||
this.stats.totalRequests++;
|
||||
} else {
|
||||
this.stats.totalResponses++;
|
||||
}
|
||||
|
||||
// Count errors
|
||||
if (entry.error) {
|
||||
this.stats.errorCount++;
|
||||
}
|
||||
|
||||
// Update duration average
|
||||
if (entry.durationMs !== undefined) {
|
||||
this.totalDuration += entry.durationMs;
|
||||
this.durationCount++;
|
||||
this.stats.avgDurationMs = Math.round(this.totalDuration / this.durationCount);
|
||||
}
|
||||
|
||||
// Update per-method counts
|
||||
if (!this.stats.methodCounts[entry.method]) {
|
||||
this.stats.methodCounts[entry.method] = {
|
||||
requests: 0,
|
||||
responses: 0,
|
||||
errors: 0,
|
||||
avgDurationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const methodStats = this.stats.methodCounts[entry.method];
|
||||
if (entry.phase === 'request') {
|
||||
methodStats.requests++;
|
||||
} else {
|
||||
methodStats.responses++;
|
||||
}
|
||||
|
||||
if (entry.error) {
|
||||
methodStats.errors++;
|
||||
}
|
||||
|
||||
// Per-method duration tracking (simplified - just uses latest duration)
|
||||
if (entry.durationMs !== undefined) {
|
||||
// Rolling average for method
|
||||
const currentAvg = methodStats.avgDurationMs || 0;
|
||||
const count = methodStats.responses || 1;
|
||||
methodStats.avgDurationMs = Math.round((currentAvg * (count - 1) + entry.durationMs) / count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get log entries with optional filters
|
||||
*/
|
||||
public getEntries(options?: {
|
||||
limit?: number;
|
||||
method?: string;
|
||||
since?: number;
|
||||
}): interfaces.serviceworker.ITypedRequestLogEntry[] {
|
||||
let result = [...this.logs];
|
||||
|
||||
// Filter by method
|
||||
if (options?.method) {
|
||||
result = result.filter((e) => e.method === options.method);
|
||||
}
|
||||
|
||||
// Filter by timestamp
|
||||
if (options?.since) {
|
||||
result = result.filter((e) => e.timestamp >= options.since);
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (newest first)
|
||||
result.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
// Apply limit
|
||||
if (options?.limit && options.limit > 0) {
|
||||
result = result.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of entries (for pagination)
|
||||
*/
|
||||
public getTotalCount(options?: { method?: string; since?: number }): number {
|
||||
if (!options?.method && !options?.since) {
|
||||
return this.logs.length;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const entry of this.logs) {
|
||||
if (options.method && entry.method !== options.method) continue;
|
||||
if (options.since && entry.timestamp < options.since) continue;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
public getStats(): interfaces.serviceworker.ITypedRequestStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logs and reset stats
|
||||
*/
|
||||
public clear(): void {
|
||||
this.logs = [];
|
||||
this.stats = {
|
||||
totalRequests: 0,
|
||||
totalResponses: 0,
|
||||
methodCounts: {},
|
||||
errorCount: 0,
|
||||
avgDurationMs: 0,
|
||||
};
|
||||
this.totalDuration = 0;
|
||||
this.durationCount = 0;
|
||||
logger.log('info', 'RequestLogStore cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique method names
|
||||
*/
|
||||
public getMethods(): string[] {
|
||||
return Object.keys(this.stats.methodCounts);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let requestLogStoreInstance: RequestLogStore | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton RequestLogStore instance
|
||||
*/
|
||||
export function getRequestLogStore(): RequestLogStore {
|
||||
if (!requestLogStoreInstance) {
|
||||
requestLogStoreInstance = new RequestLogStore();
|
||||
}
|
||||
return requestLogStoreInstance;
|
||||
}
|
||||
@@ -191,4 +191,88 @@ export class ActionManager {
|
||||
const response = await tr.fire({});
|
||||
return response;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// TypedRequest Traffic Logging
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Log a TypedRequest entry to the service worker for dashboard display
|
||||
*/
|
||||
public async logTypedRequest(entry: interfaces.serviceworker.ITypedRequestLogEntry): Promise<void> {
|
||||
try {
|
||||
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLog>('serviceworker_typedRequestLog');
|
||||
await tr.fire(entry);
|
||||
} catch (error) {
|
||||
// Silently ignore logging errors to avoid infinite loops
|
||||
// (logging the error would trigger another log entry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TypedRequest traffic logs from the service worker
|
||||
*/
|
||||
public async getTypedRequestLogs(options?: {
|
||||
limit?: number;
|
||||
method?: string;
|
||||
since?: number;
|
||||
}): Promise<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs['response'] | null> {
|
||||
try {
|
||||
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs');
|
||||
return await tr.fire(options || {});
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to get TypedRequest logs: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TypedRequest traffic statistics from the service worker
|
||||
*/
|
||||
public async getTypedRequestStats(): Promise<interfaces.serviceworker.ITypedRequestStats | null> {
|
||||
try {
|
||||
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_GetTypedRequestStats>('serviceworker_getTypedRequestStats');
|
||||
return await tr.fire({});
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to get TypedRequest stats: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear TypedRequest traffic logs
|
||||
*/
|
||||
public async clearTypedRequestLogs(): Promise<boolean> {
|
||||
try {
|
||||
const tr = this.deesComms.createTypedRequest<interfaces.serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs');
|
||||
const result = await tr.fire({});
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to clear TypedRequest logs: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time TypedRequest log entries
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
public subscribeToTypedRequestLogs(callback: (entry: interfaces.serviceworker.ITypedRequestLogEntry) => void): () => void {
|
||||
// Create handler for broadcast messages
|
||||
this.deesComms.createTypedHandler<interfaces.serviceworker.IMessage_Serviceworker_TypedRequestLogged>('serviceworker_typedRequestLogged', async (entry) => {
|
||||
try {
|
||||
callback(entry);
|
||||
} catch (error) {
|
||||
logger.log('warn', `TypedRequest log callback error: ${error}`);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
logger.log('info', 'Subscribed to TypedRequest log updates');
|
||||
|
||||
// Return unsubscribe function (note: DeesComms doesn't support removing handlers)
|
||||
return () => {
|
||||
logger.log('info', 'Unsubscribed from TypedRequest log updates (handler remains active)');
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user