Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 790b468188 | |||
| 24d6d6d2e7 | |||
| a86fd6c1f3 | |||
| d04179ccbe | |||
| d6eacf5fcc | |||
| 0f974701d4 | |||
| 2ad38dece3 | |||
| 32cb5bb423 | |||
| 5fa97322fb | |||
| af16473495 | |||
| 748a60ef74 | |||
| 3f71643e81 | |||
| 9f107b6876 | |||
| 4a8cd4b4b7 | |||
| 54d2cd1eb7 | |||
| 94eb289081 | |||
| e022ffc2ba | |||
| 25e92f4351 | |||
| b508cbe927 | |||
| 4cbc37c888 | |||
| 16f759c2b9 | |||
| f8fee04751 | |||
| 9406cfa0e2 | |||
| 1f310ef8f1 | |||
| 9cd10118e3 | |||
| 6308e0126d | |||
| e1310269fe | |||
| 1aadc2da21 | |||
| 37426f0708 | |||
| c124a06bc6 | |||
| 849e7f4407 | |||
| 3baf171394 | |||
| 065987c854 | |||
| c5c40e78f9 | |||
| d3330880c0 | |||
| dbbfd313ae |
28
changelog.md
28
changelog.md
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-12-04 - 7.8.11 - fix(web_inject)
|
||||
Improve logging in web injection (TypedRequest) and update dees-comms dependency
|
||||
|
||||
- Add debug logging to ts_web_inject to explicitly filter serviceworker_* methods and avoid infinite loops
|
||||
- Log incoming TypedRequest methods for better visibility during debugging
|
||||
- Bump dependency @design.estate/dees-comms from ^1.0.27 to ^1.0.28
|
||||
|
||||
## 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
|
||||
|
||||
- Add createMessage helper to generate consistent TypedRequest-shaped messages (includes messageId and correlation.id/phase).
|
||||
- Replace inline postMessage payloads with createMessage(...) calls across ServiceworkerBackend (status updates, new-version broadcasts, alerts, event pushes, metrics updates, resource-cached notifications).
|
||||
- Improves message consistency and enables easier correlation/tracing of DeesComms messages; behavior should remain backward-compatible.
|
||||
|
||||
## 2025-12-04 - 7.7.0 - feat(typedserver)
|
||||
Add SPA fallback support to TypedServer
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@api.global/typedserver",
|
||||
"version": "7.7.0",
|
||||
"version": "7.8.17",
|
||||
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -58,11 +58,12 @@
|
||||
],
|
||||
"homepage": "https://code.foss.global/api.global/typedserver",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.1.11",
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@cloudflare/workers-types": "^4.20251202.0",
|
||||
"@design.estate/dees-comms": "^1.0.27",
|
||||
"@design.estate/dees-catalog": "^2.0.3",
|
||||
"@design.estate/dees-comms": "^1.0.30",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartenv": "^6.0.0",
|
||||
@@ -87,7 +88,7 @@
|
||||
"@push.rocks/smartstream": "^3.2.5",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartwatch": "^5.0.0",
|
||||
"@push.rocks/taskbuffer": "^3.4.0",
|
||||
"@push.rocks/taskbuffer": "^3.5.0",
|
||||
"@push.rocks/webrequest": "^4.0.1",
|
||||
"@push.rocks/webstore": "^2.0.20",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
|
||||
937
pnpm-lock.yaml
generated
937
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '7.7.0',
|
||||
version: '7.8.11',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ export class TypedServer {
|
||||
websocket: {
|
||||
typedRouter: this.typedrouter,
|
||||
onConnectionOpen: (peer) => {
|
||||
peer.tags.add('typedserver_frontend');
|
||||
peer.tags.add('allClients');
|
||||
console.log(`WebSocket connected: ${peer.id}`);
|
||||
},
|
||||
onConnectionClose: (peer) => {
|
||||
@@ -644,6 +644,8 @@ export class TypedServer {
|
||||
);
|
||||
pushTime.fire({
|
||||
time: this.lastReload,
|
||||
}).catch(err => {
|
||||
console.warn('Failed to push latest server change time to client:', err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -144,4 +203,25 @@ export class BuiltInRoutesController {
|
||||
return new Response('SW-Dash bundle not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@plugins.smartserve.Get('/serviceworker.bundle.js')
|
||||
async getServiceWorkerBundle(): Promise<Response> {
|
||||
try {
|
||||
const bundleContent = (await plugins.fsInstance
|
||||
.file(paths.serviceworkerBundlePath)
|
||||
.encoding('utf8')
|
||||
.read()) as string;
|
||||
|
||||
return new Response(bundleContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/javascript',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to serve serviceworker bundle:', error);
|
||||
return new Response('ServiceWorker bundle not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export class TypedRequestController {
|
||||
this.typedRouter = typedRouter;
|
||||
}
|
||||
|
||||
@plugins.smartserve.Post('/')
|
||||
@plugins.smartserve.Post('')
|
||||
async handleTypedRequest(ctx: plugins.smartserve.IRequestContext): Promise<Response> {
|
||||
try {
|
||||
const response = await this.typedRouter.routeAndAddResponse(ctx.body as plugins.typedrequestInterfaces.ITypedRequest);
|
||||
@@ -31,4 +31,15 @@ export class TypedRequestController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@plugins.smartserve.Head('')
|
||||
async handleTypedRequestHead(): Promise<Response> {
|
||||
// HEAD request for online checking from service worker
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const injectBundleDir = plugins.path.join(packageDir, './dist_ts_web_inje
|
||||
export const injectBundlePath = plugins.path.join(injectBundleDir, './bundle.js');
|
||||
|
||||
export const serviceworkerBundleDir = plugins.path.join(packageDir, './dist_ts_web_serviceworker');
|
||||
export const serviceworkerBundlePath = plugins.path.join(serviceworkerBundleDir, './serviceworker.bundle.js');
|
||||
|
||||
export const swdashBundleDir = plugins.path.join(packageDir, './dist_ts_swdash');
|
||||
export const swdashBundlePath = plugins.path.join(swdashBundleDir, './bundle.js');
|
||||
@@ -386,6 +386,7 @@ export interface IRequest_Serviceworker_GetEventLog
|
||||
limit?: number;
|
||||
type?: TEventType;
|
||||
since?: number;
|
||||
before?: number; // For pagination: get events before this timestamp
|
||||
};
|
||||
response: {
|
||||
events: IEventLogEntry[];
|
||||
@@ -509,4 +510,132 @@ 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;
|
||||
before?: number; // For pagination: get logs before this timestamp
|
||||
};
|
||||
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>`;
|
||||
@@ -6,6 +6,9 @@ import { customElement, property, state } from 'lit/decorators.js';
|
||||
// DeesComms for push communication
|
||||
import * as deesComms from '@design.estate/dees-comms';
|
||||
|
||||
// Dees-catalog for UI components
|
||||
import { DeesContextmenu } from '@design.estate/dees-catalog';
|
||||
|
||||
export {
|
||||
LitElement,
|
||||
html,
|
||||
@@ -14,6 +17,7 @@ export {
|
||||
property,
|
||||
state,
|
||||
deesComms,
|
||||
DeesContextmenu,
|
||||
};
|
||||
|
||||
export type { CSSResult, TemplateResult };
|
||||
|
||||
@@ -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[];
|
||||
@@ -26,6 +27,11 @@ interface IResourceData {
|
||||
|
||||
/**
|
||||
* Main SW Dashboard application shell
|
||||
*
|
||||
* Architecture:
|
||||
* - ONE initial HTTP seed request to /sw-dash/metrics (provides ALL data)
|
||||
* - HTTP heartbeat every 30s for SW health check
|
||||
* - Everything else via DeesComms (push from SW, requests to SW)
|
||||
*/
|
||||
@customElement('sw-dash-app')
|
||||
export class SwDashApp extends LitElement {
|
||||
@@ -126,18 +132,32 @@ export class SwDashApp extends LitElement {
|
||||
`
|
||||
];
|
||||
|
||||
// Core metrics
|
||||
@state() accessor currentView: ViewType = 'overview';
|
||||
@state() accessor metrics: IMetricsData | null = null;
|
||||
@state() accessor lastRefresh = new Date().toLocaleTimeString();
|
||||
@state() accessor isConnected = false;
|
||||
|
||||
// Resource data (from initial seed)
|
||||
@state() accessor resourceData: IResourceData = {
|
||||
resources: [],
|
||||
domains: [],
|
||||
contentTypes: [],
|
||||
resourceCount: 0
|
||||
};
|
||||
@state() accessor lastRefresh = new Date().toLocaleTimeString();
|
||||
@state() accessor isConnected = false;
|
||||
|
||||
// DeesComms for receiving push updates from service worker
|
||||
// Events data (from initial seed + push updates)
|
||||
@state() accessor events: serviceworker.IEventLogEntry[] = [];
|
||||
@state() accessor eventTotalCount = 0;
|
||||
@state() accessor eventCountLastHour = 0;
|
||||
|
||||
// Request logs data (from initial seed + push updates)
|
||||
@state() accessor requestLogs: serviceworker.ITypedRequestLogEntry[] = [];
|
||||
@state() accessor requestTotalCount = 0;
|
||||
@state() accessor requestStats: serviceworker.ITypedRequestStats | null = null;
|
||||
@state() accessor requestMethods: string[] = [];
|
||||
|
||||
// DeesComms for communication with service worker
|
||||
private comms: deesComms.DeesComms | null = null;
|
||||
|
||||
// Heartbeat interval (30 seconds) for SW health check
|
||||
@@ -146,7 +166,7 @@ export class SwDashApp extends LitElement {
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Initial HTTP seed request to wake up SW and get initial data
|
||||
// Initial HTTP seed request to wake up SW and get ALL initial data
|
||||
this.loadInitialData();
|
||||
// Setup push listeners via DeesComms
|
||||
this.setupPushListeners();
|
||||
@@ -162,19 +182,42 @@ export class SwDashApp extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial HTTP request to seed data and wake up service worker
|
||||
* Initial HTTP request to seed ALL data and wake up service worker
|
||||
* This is the ONE HTTP request that provides everything:
|
||||
* - Core metrics
|
||||
* - Resources, domains, content types
|
||||
* - Events (initial 50)
|
||||
* - Request logs (initial 50), stats, methods
|
||||
*/
|
||||
private async loadInitialData(): Promise<void> {
|
||||
try {
|
||||
// Fetch metrics (wakes up SW)
|
||||
const metricsResponse = await fetch('/sw-dash/metrics');
|
||||
this.metrics = await metricsResponse.json();
|
||||
const response = await fetch('/sw-dash/metrics');
|
||||
const data = await response.json();
|
||||
|
||||
// Core metrics
|
||||
this.metrics = data;
|
||||
|
||||
// Resource data
|
||||
this.resourceData = {
|
||||
resources: data.resources || [],
|
||||
domains: data.domains || [],
|
||||
contentTypes: data.contentTypes || [],
|
||||
resourceCount: data.resourceCount || 0,
|
||||
};
|
||||
|
||||
// Events data
|
||||
this.events = data.events || [];
|
||||
this.eventTotalCount = data.eventTotalCount || 0;
|
||||
this.eventCountLastHour = data.eventCountLastHour || 0;
|
||||
|
||||
// Request logs data
|
||||
this.requestLogs = data.requestLogs || [];
|
||||
this.requestTotalCount = data.requestTotalCount || 0;
|
||||
this.requestStats = data.requestStats || null;
|
||||
this.requestMethods = data.requestMethods || [];
|
||||
|
||||
this.lastRefresh = new Date().toLocaleTimeString();
|
||||
this.isConnected = true;
|
||||
|
||||
// Also load resources
|
||||
const resourcesResponse = await fetch('/sw-dash/resources');
|
||||
this.resourceData = await resourcesResponse.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial data:', err);
|
||||
this.isConnected = false;
|
||||
@@ -182,7 +225,8 @@ export class SwDashApp extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup DeesComms handlers for receiving push updates
|
||||
* Setup DeesComms handlers for receiving push updates from SW
|
||||
* All real-time updates come through here
|
||||
*/
|
||||
private setupPushListeners(): void {
|
||||
this.comms = new deesComms.DeesComms();
|
||||
@@ -214,54 +258,6 @@ export class SwDashApp extends LitElement {
|
||||
resourceCount: snapshot.resourceCount,
|
||||
uptime: snapshot.uptime,
|
||||
};
|
||||
} else {
|
||||
// If no metrics yet, create minimal structure
|
||||
this.metrics = {
|
||||
cache: {
|
||||
hits: snapshot.cache.hits,
|
||||
misses: snapshot.cache.misses,
|
||||
errors: snapshot.cache.errors,
|
||||
bytesServedFromCache: snapshot.cache.bytesServedFromCache,
|
||||
bytesFetched: snapshot.cache.bytesFetched,
|
||||
averageResponseTime: 0,
|
||||
},
|
||||
network: {
|
||||
totalRequests: snapshot.network.totalRequests,
|
||||
successfulRequests: snapshot.network.successfulRequests,
|
||||
failedRequests: snapshot.network.failedRequests,
|
||||
timeouts: 0,
|
||||
averageLatency: 0,
|
||||
totalBytesTransferred: 0,
|
||||
},
|
||||
update: {
|
||||
totalChecks: 0,
|
||||
successfulChecks: 0,
|
||||
failedChecks: 0,
|
||||
updatesFound: 0,
|
||||
updatesApplied: 0,
|
||||
lastCheckTimestamp: 0,
|
||||
lastUpdateTimestamp: 0,
|
||||
},
|
||||
connection: {
|
||||
connectedClients: 0,
|
||||
totalConnectionAttempts: 0,
|
||||
successfulConnections: 0,
|
||||
failedConnections: 0,
|
||||
},
|
||||
speedtest: {
|
||||
lastDownloadSpeedMbps: 0,
|
||||
lastUploadSpeedMbps: 0,
|
||||
lastLatencyMs: 0,
|
||||
lastTestTimestamp: 0,
|
||||
testCount: 0,
|
||||
isOnline: true,
|
||||
},
|
||||
startTime: Date.now() - snapshot.uptime,
|
||||
uptime: snapshot.uptime,
|
||||
cacheHitRate: snapshot.cacheHitRate,
|
||||
networkSuccessRate: snapshot.networkSuccessRate,
|
||||
resourceCount: snapshot.resourceCount,
|
||||
};
|
||||
}
|
||||
this.lastRefresh = new Date().toLocaleTimeString();
|
||||
this.isConnected = true;
|
||||
@@ -269,16 +265,18 @@ export class SwDashApp extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
// Handle event log push updates - dispatch to events component
|
||||
// Handle new event logged - add to our events array
|
||||
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_EventLogged>(
|
||||
'serviceworker_eventLogged',
|
||||
async (entry) => {
|
||||
// Dispatch custom event for sw-dash-events component
|
||||
this.dispatchEvent(new CustomEvent('event-logged', {
|
||||
detail: entry,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
// Prepend new event to array
|
||||
this.events = [entry, ...this.events];
|
||||
this.eventTotalCount++;
|
||||
// Check if event is within last hour
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
if (entry.timestamp >= oneHourAgo) {
|
||||
this.eventCountLastHour++;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
@@ -297,10 +295,52 @@ export class SwDashApp extends LitElement {
|
||||
return {};
|
||||
}
|
||||
);
|
||||
|
||||
// Handle new TypedRequest logged - add to our logs array
|
||||
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_TypedRequestLogged>(
|
||||
'serviceworker_typedRequestLogged',
|
||||
async (entry) => {
|
||||
// Prepend new log to array
|
||||
this.requestLogs = [entry, ...this.requestLogs];
|
||||
this.requestTotalCount++;
|
||||
|
||||
// Update stats optimistically
|
||||
if (this.requestStats) {
|
||||
const newStats = { ...this.requestStats };
|
||||
if (entry.phase === 'request') {
|
||||
newStats.totalRequests++;
|
||||
} else {
|
||||
newStats.totalResponses++;
|
||||
}
|
||||
if (entry.error) {
|
||||
newStats.errorCount++;
|
||||
}
|
||||
// Update method counts
|
||||
if (!newStats.methodCounts[entry.method]) {
|
||||
newStats.methodCounts[entry.method] = { requests: 0, responses: 0, errors: 0, avgDurationMs: 0 };
|
||||
// Add to methods list if new
|
||||
if (!this.requestMethods.includes(entry.method)) {
|
||||
this.requestMethods = [...this.requestMethods, entry.method];
|
||||
}
|
||||
}
|
||||
if (entry.phase === 'request') {
|
||||
newStats.methodCounts[entry.method].requests++;
|
||||
} else {
|
||||
newStats.methodCounts[entry.method].responses++;
|
||||
}
|
||||
if (entry.error) {
|
||||
newStats.methodCounts[entry.method].errors++;
|
||||
}
|
||||
this.requestStats = newStats;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat to check SW health periodically
|
||||
* Heartbeat to check SW health periodically (HTTP)
|
||||
* This is the ONLY periodic HTTP request
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
@@ -308,8 +348,22 @@ export class SwDashApp extends LitElement {
|
||||
const response = await fetch('/sw-dash/metrics');
|
||||
if (response.ok) {
|
||||
this.isConnected = true;
|
||||
// Optionally refresh full metrics periodically
|
||||
this.metrics = await response.json();
|
||||
// Refresh all data from heartbeat response
|
||||
const data = await response.json();
|
||||
this.metrics = data;
|
||||
this.resourceData = {
|
||||
resources: data.resources || [],
|
||||
domains: data.domains || [],
|
||||
contentTypes: data.contentTypes || [],
|
||||
resourceCount: data.resourceCount || 0,
|
||||
};
|
||||
this.events = data.events || [];
|
||||
this.eventTotalCount = data.eventTotalCount || 0;
|
||||
this.eventCountLastHour = data.eventCountLastHour || 0;
|
||||
this.requestLogs = data.requestLogs || [];
|
||||
this.requestTotalCount = data.requestTotalCount || 0;
|
||||
this.requestStats = data.requestStats || null;
|
||||
this.requestMethods = data.requestMethods || [];
|
||||
this.lastRefresh = new Date().toLocaleTimeString();
|
||||
} else {
|
||||
this.isConnected = false;
|
||||
@@ -321,22 +375,96 @@ export class SwDashApp extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load resource data on demand (when switching to urls/domains/types view)
|
||||
* Handle "load more events" request from sw-dash-events component
|
||||
* Uses DeesComms to request older events from SW
|
||||
*/
|
||||
private async loadResourceData(): Promise<void> {
|
||||
private async handleLoadMoreEvents(e: CustomEvent<{ before: number }>): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/sw-dash/resources');
|
||||
this.resourceData = await response.json();
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_GetEventLog>('serviceworker_getEventLog');
|
||||
const result = await tr.fire({
|
||||
limit: 50,
|
||||
before: e.detail.before,
|
||||
});
|
||||
// Append older events to existing array
|
||||
this.events = [...this.events, ...result.events];
|
||||
this.eventTotalCount = result.totalCount;
|
||||
} catch (err) {
|
||||
console.error('Failed to load resources:', err);
|
||||
console.error('Failed to load more events:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "clear events" request from sw-dash-events component
|
||||
* Uses DeesComms to clear event log in SW
|
||||
*/
|
||||
private async handleClearEvents(): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_ClearEventLog>('serviceworker_clearEventLog');
|
||||
await tr.fire({});
|
||||
// Clear local state
|
||||
this.events = [];
|
||||
this.eventTotalCount = 0;
|
||||
this.eventCountLastHour = 0;
|
||||
} catch (err) {
|
||||
console.error('Failed to clear events:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "load more requests" from sw-dash-requests component
|
||||
* Uses DeesComms to request older request logs from SW
|
||||
*/
|
||||
private async handleLoadMoreRequests(e: CustomEvent<{ before: number; method?: string }>): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('serviceworker_getTypedRequestLogs');
|
||||
const result = await tr.fire({
|
||||
limit: 50,
|
||||
before: e.detail.before,
|
||||
method: e.detail.method,
|
||||
});
|
||||
// Append older logs to existing array
|
||||
this.requestLogs = [...this.requestLogs, ...result.logs];
|
||||
this.requestTotalCount = result.totalCount;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more requests:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "clear requests" from sw-dash-requests component
|
||||
* Uses DeesComms to clear request logs in SW
|
||||
*/
|
||||
private async handleClearRequests(): Promise<void> {
|
||||
if (!this.comms) return;
|
||||
|
||||
try {
|
||||
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('serviceworker_clearTypedRequestLogs');
|
||||
await tr.fire({});
|
||||
// Clear local state
|
||||
this.requestLogs = [];
|
||||
this.requestTotalCount = 0;
|
||||
this.requestStats = {
|
||||
totalRequests: 0,
|
||||
totalResponses: 0,
|
||||
methodCounts: {},
|
||||
errorCount: 0,
|
||||
avgDurationMs: 0,
|
||||
};
|
||||
this.requestMethods = [];
|
||||
} catch (err) {
|
||||
console.error('Failed to clear requests:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private setView(view: ViewType): void {
|
||||
this.currentView = view;
|
||||
if (view !== 'overview') {
|
||||
this.loadResourceData();
|
||||
}
|
||||
// No HTTP fetch on view change - data is already loaded from initial seed
|
||||
}
|
||||
|
||||
private handleSpeedtestComplete(_e: CustomEvent): void {
|
||||
@@ -389,12 +517,17 @@ 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">
|
||||
<div class="view ${this.currentView === 'overview' ? 'active' : ''}">
|
||||
<sw-dash-overview
|
||||
.metrics="${this.metrics}"
|
||||
.eventCountLastHour="${this.eventCountLastHour}"
|
||||
@speedtest-complete="${this.handleSpeedtestComplete}"
|
||||
></sw-dash-overview>
|
||||
</div>
|
||||
@@ -412,7 +545,23 @@ export class SwDashApp extends LitElement {
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'events' ? 'active' : ''}">
|
||||
<sw-dash-events></sw-dash-events>
|
||||
<sw-dash-events
|
||||
.events="${this.events}"
|
||||
.totalCount="${this.eventTotalCount}"
|
||||
@load-more-events="${this.handleLoadMoreEvents}"
|
||||
@clear-events="${this.handleClearEvents}"
|
||||
></sw-dash-events>
|
||||
</div>
|
||||
|
||||
<div class="view ${this.currentView === 'requests' ? 'active' : ''}">
|
||||
<sw-dash-requests
|
||||
.logs="${this.requestLogs}"
|
||||
.totalCount="${this.requestTotalCount}"
|
||||
.stats="${this.requestStats}"
|
||||
.methods="${this.requestMethods}"
|
||||
@load-more-requests="${this.handleLoadMoreRequests}"
|
||||
@clear-requests="${this.handleClearRequests}"
|
||||
></sw-dash-requests>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ type TEventFilter = 'all' | 'sw_installed' | 'sw_activated' | 'sw_updated' | 'sw
|
||||
|
||||
/**
|
||||
* Events panel component for sw-dash
|
||||
*
|
||||
* Receives events via property from parent (sw-dash-app).
|
||||
* Filtering is done locally.
|
||||
* Load more and clear operations dispatch events to parent.
|
||||
*/
|
||||
@customElement('sw-dash-events')
|
||||
export class SwDashEvents extends LitElement {
|
||||
@@ -189,97 +193,52 @@ export class SwDashEvents extends LitElement {
|
||||
`
|
||||
];
|
||||
|
||||
// Received from parent (sw-dash-app)
|
||||
@property({ type: Array }) accessor events: IEventLogEntry[] = [];
|
||||
@property({ type: Number }) accessor totalCount = 0;
|
||||
|
||||
// Local state for filtering
|
||||
@state() accessor filter: TEventFilter = 'all';
|
||||
@state() accessor searchText = '';
|
||||
@state() accessor totalCount = 0;
|
||||
@state() accessor isLoading = true;
|
||||
@state() accessor page = 1;
|
||||
private readonly pageSize = 50;
|
||||
|
||||
// Bound event handler reference for cleanup
|
||||
private boundEventHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.loadEvents();
|
||||
// Listen for pushed events from parent
|
||||
this.setupPushEventListener();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
// Clean up event listener
|
||||
if (this.boundEventHandler) {
|
||||
window.removeEventListener('event-logged', this.boundEventHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up listener for pushed events from service worker (via sw-dash-app)
|
||||
*/
|
||||
private setupPushEventListener(): void {
|
||||
this.boundEventHandler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<IEventLogEntry>;
|
||||
const newEvent = customEvent.detail;
|
||||
|
||||
// Only add if it matches current filter (or filter is 'all')
|
||||
if (this.filter === 'all' || newEvent.type === this.filter) {
|
||||
// Prepend new event to the list
|
||||
this.events = [newEvent, ...this.events];
|
||||
this.totalCount++;
|
||||
}
|
||||
};
|
||||
|
||||
// Listen at window level since events bubble up with composed: true
|
||||
window.addEventListener('event-logged', this.boundEventHandler);
|
||||
}
|
||||
|
||||
private async loadEvents(): Promise<void> {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(this.pageSize * this.page));
|
||||
if (this.filter !== 'all') {
|
||||
params.set('type', this.filter);
|
||||
}
|
||||
|
||||
const response = await fetch(`/sw-dash/events?${params}`);
|
||||
const data = await response.json();
|
||||
this.events = data.events;
|
||||
this.totalCount = data.totalCount;
|
||||
} catch (err) {
|
||||
console.error('Failed to load events:', err);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
@state() accessor isLoadingMore = false;
|
||||
|
||||
private handleFilterChange(e: Event): void {
|
||||
this.filter = (e.target as HTMLSelectElement).value as TEventFilter;
|
||||
this.page = 1;
|
||||
this.loadEvents();
|
||||
// Local filtering - no HTTP request
|
||||
}
|
||||
|
||||
private handleSearch(e: Event): void {
|
||||
this.searchText = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
}
|
||||
|
||||
private async handleClear(): Promise<void> {
|
||||
private handleClear(): void {
|
||||
if (!confirm('Are you sure you want to clear the event log? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch('/sw-dash/events', { method: 'DELETE' });
|
||||
this.loadEvents();
|
||||
} catch (err) {
|
||||
console.error('Failed to clear events:', err);
|
||||
}
|
||||
// Dispatch event to parent to clear via DeesComms
|
||||
this.dispatchEvent(new CustomEvent('clear-events', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private loadMore(): void {
|
||||
this.page++;
|
||||
this.loadEvents();
|
||||
if (this.isLoadingMore || this.events.length === 0) return;
|
||||
|
||||
this.isLoadingMore = true;
|
||||
const oldestEvent = this.events[this.events.length - 1];
|
||||
|
||||
// Dispatch event to parent to load more via DeesComms
|
||||
this.dispatchEvent(new CustomEvent('load-more-events', {
|
||||
detail: { before: oldestEvent.timestamp },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
// Reset loading state after a short delay (parent will update events prop)
|
||||
setTimeout(() => {
|
||||
this.isLoadingMore = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private getTypeClass(type: string): string {
|
||||
@@ -300,13 +259,27 @@ export class SwDashEvents extends LitElement {
|
||||
return type.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter events locally based on type and search text
|
||||
*/
|
||||
private getFilteredEvents(): IEventLogEntry[] {
|
||||
if (!this.searchText) return this.events;
|
||||
return this.events.filter(e =>
|
||||
e.message.toLowerCase().includes(this.searchText) ||
|
||||
e.type.toLowerCase().includes(this.searchText) ||
|
||||
(e.details && JSON.stringify(e.details).toLowerCase().includes(this.searchText))
|
||||
);
|
||||
let result = this.events;
|
||||
|
||||
// Filter by type
|
||||
if (this.filter !== 'all') {
|
||||
result = result.filter(e => e.type === this.filter);
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if (this.searchText) {
|
||||
result = result.filter(e =>
|
||||
e.message.toLowerCase().includes(this.searchText) ||
|
||||
e.type.toLowerCase().includes(this.searchText) ||
|
||||
(e.details && JSON.stringify(e.details).toLowerCase().includes(this.searchText))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
@@ -352,10 +325,10 @@ export class SwDashEvents extends LitElement {
|
||||
<button class="btn clear-btn" @click="${this.handleClear}">Clear Log</button>
|
||||
</div>
|
||||
|
||||
${this.isLoading && this.events.length === 0 ? html`
|
||||
<div class="empty-state">Loading events...</div>
|
||||
${this.events.length === 0 ? html`
|
||||
<div class="empty-state">No events recorded</div>
|
||||
` : filteredEvents.length === 0 ? html`
|
||||
<div class="empty-state">No events found</div>
|
||||
<div class="empty-state">No events match filter</div>
|
||||
` : html`
|
||||
<div class="events-list">
|
||||
${filteredEvents.map(event => html`
|
||||
@@ -374,8 +347,8 @@ export class SwDashEvents extends LitElement {
|
||||
|
||||
${this.events.length < this.totalCount ? html`
|
||||
<div class="pagination">
|
||||
<button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoading}">
|
||||
${this.isLoading ? 'Loading...' : 'Load More'}
|
||||
<button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoadingMore}">
|
||||
${this.isLoadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
<span class="page-info">${this.events.length} of ${this.totalCount} events</span>
|
||||
</div>
|
||||
|
||||
@@ -79,42 +79,15 @@ export class SwDashOverview extends LitElement {
|
||||
];
|
||||
|
||||
@property({ type: Object }) accessor metrics: IMetricsData | null = null;
|
||||
@property({ type: Number }) accessor eventCountLastHour = 0;
|
||||
@state() accessor speedtestRunning = false;
|
||||
@state() accessor speedtestPhase: 'idle' | 'latency' | 'download' | 'upload' | 'complete' = 'idle';
|
||||
@state() accessor speedtestProgress = 0;
|
||||
@state() accessor speedtestElapsed = 0;
|
||||
@state() accessor eventCountLastHour = 0;
|
||||
|
||||
// Speedtest timing constants (must match service worker)
|
||||
private static readonly TEST_DURATION_MS = 5000; // 5 seconds per test
|
||||
private progressInterval: number | null = null;
|
||||
private eventCountInterval: number | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.fetchEventCount();
|
||||
// Refresh event count every 30 seconds
|
||||
this.eventCountInterval = window.setInterval(() => this.fetchEventCount(), 30000);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
if (this.eventCountInterval) {
|
||||
window.clearInterval(this.eventCountInterval);
|
||||
this.eventCountInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchEventCount(): Promise<void> {
|
||||
try {
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const response = await fetch(`/sw-dash/events/count?since=${oneHourAgo}`);
|
||||
const data = await response.json();
|
||||
this.eventCountLastHour = data.count;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch event count:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async runSpeedtest(): Promise<void> {
|
||||
if (this.speedtestRunning) return;
|
||||
|
||||
999
ts_swdash/sw-dash-requests.ts
Normal file
999
ts_swdash/sw-dash-requests.ts
Normal file
@@ -0,0 +1,999 @@
|
||||
import { LitElement, html, css, property, state, customElement, DeesContextmenu } 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 handleContextMenu(event: MouseEvent, group: IGroupedRequest): void {
|
||||
// Build full message object for copying
|
||||
const fullMessage = {
|
||||
correlationId: group.correlationId,
|
||||
method: group.method,
|
||||
timestamp: group.timestamp,
|
||||
durationMs: group.durationMs,
|
||||
request: group.request ? {
|
||||
direction: group.request.direction,
|
||||
phase: group.request.phase,
|
||||
timestamp: group.request.timestamp,
|
||||
payload: group.request.payload,
|
||||
} : null,
|
||||
response: group.response ? {
|
||||
direction: group.response.direction,
|
||||
phase: group.response.phase,
|
||||
timestamp: group.response.timestamp,
|
||||
durationMs: group.response.durationMs,
|
||||
payload: group.response.payload,
|
||||
error: group.response.error,
|
||||
} : null,
|
||||
};
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(event, [
|
||||
{
|
||||
name: 'Copy Full Message',
|
||||
iconName: 'copy',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(JSON.stringify(fullMessage, null, 2));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Request Payload',
|
||||
iconName: 'upload',
|
||||
disabled: !group.request,
|
||||
action: async () => {
|
||||
if (group.request) {
|
||||
await navigator.clipboard.writeText(JSON.stringify(group.request.payload, null, 2));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Response Payload',
|
||||
iconName: 'download',
|
||||
disabled: !group.response,
|
||||
action: async () => {
|
||||
if (group.response) {
|
||||
await navigator.clipboard.writeText(JSON.stringify(group.response.payload, null, 2));
|
||||
}
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Copy Correlation ID',
|
||||
iconName: 'hash',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(group.correlationId);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Copy Method Name',
|
||||
iconName: 'tag',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(group.method);
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Filter by Method',
|
||||
iconName: 'filter',
|
||||
action: async () => {
|
||||
this.setMethodFilter(group.method);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Show Payload',
|
||||
iconName: 'eye',
|
||||
action: async () => {
|
||||
this.openPayloadModal(group);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
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' : ''}"
|
||||
@contextmenu="${(e: MouseEvent) => this.handleContextMenu(e, group)}"
|
||||
>
|
||||
<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>
|
||||
` : ''}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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,52 @@ 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 serviceworker_* methods to avoid infinite loops
|
||||
// These are internal SW communication methods, not app traffic
|
||||
if (entry.method.startsWith('serviceworker_')) {
|
||||
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 {
|
||||
@@ -52,6 +53,22 @@ export class ServiceworkerBackend {
|
||||
private pendingMetricsUpdate = false;
|
||||
private readonly METRICS_THROTTLE_MS = 500;
|
||||
|
||||
/**
|
||||
* Helper to create properly formatted TypedRequest messages for DeesComms
|
||||
*/
|
||||
private createMessage<T>(method: string, request: T): any {
|
||||
const id = `${method}_${Date.now()}`;
|
||||
return {
|
||||
method,
|
||||
request,
|
||||
messageId: id,
|
||||
correlation: {
|
||||
id,
|
||||
phase: 'request' as const
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
constructor(optionsArg: {
|
||||
self: any;
|
||||
purgeCache: (reqArg: interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['request']) => Promise<interfaces.serviceworker.IRequest_PurgeServiceWorkerCache['response']>;
|
||||
@@ -103,6 +120,7 @@ export class ServiceworkerBackend {
|
||||
limit: reqArg.limit,
|
||||
type: reqArg.type,
|
||||
since: reqArg.since,
|
||||
before: reqArg.before,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +144,49 @@ 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,
|
||||
before: reqArg.before,
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -244,17 +305,24 @@ export class ServiceworkerBackend {
|
||||
*/
|
||||
public async broadcastStatusUpdate(status: interfaces.serviceworker.IStatusUpdate): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage({
|
||||
method: 'serviceworker_statusUpdate',
|
||||
request: status,
|
||||
messageId: `sw_status_${Date.now()}`
|
||||
});
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_statusUpdate', status));
|
||||
logger.log('info', `Status update broadcast: ${status.source}:${status.type} - ${status.message}`);
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to broadcast status update: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -290,11 +358,7 @@ export class ServiceworkerBackend {
|
||||
|
||||
// Send update message via DeesComms
|
||||
// This will be picked up by clients that have registered a handler for 'serviceworker_newVersion'
|
||||
await this.deesComms.postMessage({
|
||||
method: 'serviceworker_newVersion',
|
||||
request: {},
|
||||
messageId: `sw_update_${Date.now()}`
|
||||
});
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_newVersion', {}));
|
||||
|
||||
// As a fallback, also use the clients API to reload clients that might not catch the broadcast
|
||||
const clients = await this.swSelf.clients.matchAll({ type: 'window' });
|
||||
@@ -360,11 +424,7 @@ export class ServiceworkerBackend {
|
||||
|
||||
// Send message to clients who might be able to show an actual alert
|
||||
try {
|
||||
await this.deesComms.postMessage({
|
||||
method: 'serviceworker_alert',
|
||||
request: { message: alertText },
|
||||
messageId: `sw_alert_${Date.now()}`
|
||||
});
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_alert', { message: alertText }));
|
||||
logger.log('info', `Alert message sent to clients: ${alertText}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to send alert to clients: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -381,11 +441,7 @@ export class ServiceworkerBackend {
|
||||
*/
|
||||
public async pushEvent(entry: interfaces.serviceworker.IEventLogEntry): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage({
|
||||
method: 'serviceworker_eventLogged',
|
||||
request: entry,
|
||||
messageId: `sw_event_${entry.id}`
|
||||
});
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_eventLogged', entry));
|
||||
logger.log('note', `Pushed event to clients: ${entry.type}`);
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to push event: ${error}`);
|
||||
@@ -446,11 +502,7 @@ export class ServiceworkerBackend {
|
||||
};
|
||||
|
||||
try {
|
||||
await this.deesComms.postMessage({
|
||||
method: 'serviceworker_metricsUpdate',
|
||||
request: snapshot,
|
||||
messageId: `sw_metrics_${Date.now()}`
|
||||
});
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_metricsUpdate', snapshot));
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to push metrics update: ${error}`);
|
||||
}
|
||||
@@ -461,11 +513,7 @@ export class ServiceworkerBackend {
|
||||
*/
|
||||
public async pushResourceCached(url: string, contentType: string, size: number, cached: boolean): Promise<void> {
|
||||
try {
|
||||
await this.deesComms.postMessage({
|
||||
method: 'serviceworker_resourceCached',
|
||||
request: { url, contentType, size, cached },
|
||||
messageId: `sw_resource_${Date.now()}`
|
||||
});
|
||||
await this.deesComms.postMessage(this.createMessage('serviceworker_resourceCached', { url, contentType, size, cached }));
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to push resource cached: ${error}`);
|
||||
}
|
||||
|
||||
@@ -210,42 +210,27 @@ export class CacheManager {
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.serveDashboard()));
|
||||
return;
|
||||
}
|
||||
// /sw-dash/metrics - THE initial seed endpoint (provides ALL data)
|
||||
if (parsedUrl.pathname === '/sw-dash/metrics') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.serveMetrics()));
|
||||
fetchEventArg.respondWith(dashboard.serveMetrics());
|
||||
return;
|
||||
}
|
||||
// /sw-dash/speedtest - user-triggered speedtest
|
||||
if (parsedUrl.pathname === '/sw-dash/speedtest') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(dashboard.runSpeedtest());
|
||||
return;
|
||||
}
|
||||
// /sw-dash/resources - resource data (kept for now, could be merged into metrics)
|
||||
if (parsedUrl.pathname === '/sw-dash/resources') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(Promise.resolve(dashboard.serveResources()));
|
||||
return;
|
||||
}
|
||||
if (parsedUrl.pathname === '/sw-dash/events') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(dashboard.serveEventLog(parsedUrl.searchParams));
|
||||
return;
|
||||
}
|
||||
if (parsedUrl.pathname === '/sw-dash/events/count') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(dashboard.serveEventCount(parsedUrl.searchParams));
|
||||
return;
|
||||
}
|
||||
if (parsedUrl.pathname === '/sw-dash/cumulative-metrics') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(dashboard.serveCumulativeMetrics());
|
||||
return;
|
||||
}
|
||||
// DELETE method for clearing events
|
||||
if (parsedUrl.pathname === '/sw-dash/events' && originalRequest.method === 'DELETE') {
|
||||
const dashboard = getDashboardGenerator();
|
||||
fetchEventArg.respondWith(dashboard.clearEventLog());
|
||||
return;
|
||||
}
|
||||
// All other /sw-dash/* routes removed - use DeesComms instead:
|
||||
// - Events: via serviceworker_getEventLog, serviceworker_clearEventLog
|
||||
// - Requests: via serviceworker_getTypedRequestLogs, serviceworker_clearTypedRequestLogs
|
||||
|
||||
// Block requests that we don't want the service worker to handle.
|
||||
if (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -24,15 +25,87 @@ export class DashboardGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the metrics JSON endpoint
|
||||
* Serves the metrics JSON endpoint with ALL initial data
|
||||
* This is the single HTTP seed request that provides:
|
||||
* - Current metrics
|
||||
* - Initial events (last 50)
|
||||
* - Initial request logs (last 50)
|
||||
* - Request stats and methods
|
||||
* - Resource data
|
||||
*/
|
||||
public serveMetrics(): Response {
|
||||
return new Response(this.generateMetricsJson(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
public async serveMetrics(): Promise<Response> {
|
||||
try {
|
||||
const metrics = getMetricsCollector();
|
||||
const persistentStore = getPersistentStore();
|
||||
await persistentStore.init();
|
||||
const requestLogStore = getRequestLogStore();
|
||||
|
||||
// Get event data
|
||||
const eventResult = await persistentStore.getEventLog({ limit: 50 });
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const eventCountLastHour = await persistentStore.getEventCount(oneHourAgo);
|
||||
|
||||
// Build comprehensive initial response
|
||||
const data = {
|
||||
// Core metrics
|
||||
...metrics.getMetrics(),
|
||||
cacheHitRate: metrics.getCacheHitRate(),
|
||||
networkSuccessRate: metrics.getNetworkSuccessRate(),
|
||||
resourceCount: metrics.getResourceCount(),
|
||||
summary: metrics.getSummary(),
|
||||
|
||||
// Resources data
|
||||
resources: metrics.getCachedResources(),
|
||||
domains: metrics.getDomainStats(),
|
||||
contentTypes: metrics.getContentTypeStats(),
|
||||
|
||||
// Events data (initial 50)
|
||||
events: eventResult.events,
|
||||
eventTotalCount: eventResult.totalCount,
|
||||
eventCountLastHour,
|
||||
|
||||
// Request logs data (initial 50)
|
||||
requestLogs: requestLogStore.getEntries({ limit: 50 }),
|
||||
requestTotalCount: requestLogStore.getTotalCount(),
|
||||
requestStats: requestLogStore.getStats(),
|
||||
requestMethods: requestLogStore.getMethods(),
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SW Dashboard] serveMetrics error:', error);
|
||||
// Return error response with valid JSON structure so client doesn't crash
|
||||
return new Response(JSON.stringify({
|
||||
error: String(error),
|
||||
cache: { hits: 0, misses: 0, errors: 0, bytesServedFromCache: 0, bytesFetched: 0, averageResponseTime: 0 },
|
||||
network: { totalRequests: 0, successfulRequests: 0, failedRequests: 0, timeouts: 0, averageLatency: 0, totalBytesTransferred: 0 },
|
||||
update: { totalChecks: 0, successfulChecks: 0, failedChecks: 0, updatesFound: 0, updatesApplied: 0, lastCheckTimestamp: 0, lastUpdateTimestamp: 0 },
|
||||
connection: { connectedClients: 0, totalConnectionAttempts: 0, successfulConnections: 0, failedConnections: 0 },
|
||||
speedtest: { lastDownloadSpeedMbps: 0, lastUploadSpeedMbps: 0, lastLatencyMs: 0, lastTestTimestamp: 0, testCount: 0, isOnline: false },
|
||||
startTime: Date.now(),
|
||||
uptime: 0,
|
||||
cacheHitRate: 0,
|
||||
networkSuccessRate: 0,
|
||||
resourceCount: 0,
|
||||
events: [],
|
||||
eventTotalCount: 0,
|
||||
eventCountLastHour: 0,
|
||||
requestLogs: [],
|
||||
requestTotalCount: 0,
|
||||
requestStats: { totalRequests: 0, totalResponses: 0, methodCounts: {}, errorCount: 0, avgDurationMs: 0 },
|
||||
requestMethods: [],
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +192,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 +405,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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class NetworkManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/sw-typedrequest', {
|
||||
const response = await fetch('/typedrequest', {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
@@ -316,6 +316,7 @@ export class PersistentStore {
|
||||
limit?: number;
|
||||
type?: TEventType;
|
||||
since?: number;
|
||||
before?: number;
|
||||
}): Promise<{ events: IEventLogEntry[]; totalCount: number }> {
|
||||
try {
|
||||
let events: IEventLogEntry[] = [];
|
||||
@@ -336,6 +337,11 @@ export class PersistentStore {
|
||||
events = events.filter(e => e.timestamp >= options.since);
|
||||
}
|
||||
|
||||
// Filter by before timestamp (for pagination)
|
||||
if (options?.before) {
|
||||
events = events.filter(e => e.timestamp < options.before);
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
events.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
|
||||
233
ts_web_serviceworker/classes.requestlogstore.ts
Normal file
233
ts_web_serviceworker/classes.requestlogstore.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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
|
||||
* Rejects entries for serviceworker_* methods to prevent pollution from SW internal messages
|
||||
*/
|
||||
public addEntry(entry: interfaces.serviceworker.ITypedRequestLogEntry): void {
|
||||
// Reject serviceworker_* methods - these are internal SW messages, not app traffic
|
||||
// This prevents infinite loop pollution if hooks bypass somehow
|
||||
if (entry.method && entry.method.startsWith('serviceworker_')) {
|
||||
logger.log('note', `Rejecting serviceworker_* entry: ${entry.method}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also reject entries with deeply nested payloads (sign of previous loop corruption)
|
||||
if (this.hasNestedServiceworkerPayload(entry)) {
|
||||
logger.log('warn', `Rejecting corrupted entry with nested serviceworker_* payload`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entry has nested serviceworker_* methods in its payload (corruption from old loops)
|
||||
*/
|
||||
private hasNestedServiceworkerPayload(entry: interfaces.serviceworker.ITypedRequestLogEntry, depth = 0): boolean {
|
||||
// Limit recursion depth to prevent stack overflow
|
||||
if (depth > 3) return false;
|
||||
|
||||
const payload = entry.payload;
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
|
||||
// Check if payload looks like a TypedRequest log entry with serviceworker_* method
|
||||
if (payload.method && typeof payload.method === 'string' && payload.method.startsWith('serviceworker_')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check nested payload
|
||||
if (payload.payload) {
|
||||
return this.hasNestedServiceworkerPayload({ ...entry, payload: payload.payload }, depth + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
before?: 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 (since)
|
||||
if (options?.since) {
|
||||
result = result.filter((e) => e.timestamp >= options.since);
|
||||
}
|
||||
|
||||
// Filter by timestamp (before - for pagination)
|
||||
if (options?.before) {
|
||||
result = result.filter((e) => e.timestamp < options.before);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export class ServiceWorker {
|
||||
// TypedSocket connection for server communication
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private handlersInitialized = false;
|
||||
|
||||
constructor(selfArg: interfaces.ServiceWindow) {
|
||||
logger.log('info', `Service worker instantiating at ${Date.now()}`);
|
||||
@@ -119,33 +120,43 @@ export class ServiceWorker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize typed handlers (idempotent - safe to call multiple times)
|
||||
*/
|
||||
private initHandlers(): void {
|
||||
if (this.handlersInitialized) return;
|
||||
this.handlersInitialized = true;
|
||||
|
||||
// Register handler for cache invalidation from server
|
||||
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
|
||||
new plugins.typedrequest.TypedHandler('serviceworker_cacheInvalidate', async (reqArg) => {
|
||||
logger.log('info', `Cache invalidation requested from server: ${reqArg.reason}`);
|
||||
|
||||
// Log cache invalidation event (survives)
|
||||
const persistentStore = getPersistentStore();
|
||||
await persistentStore.init(); // Ensure store is initialized
|
||||
await persistentStore.logEvent('cache_invalidated', `Cache invalidated: ${reqArg.reason}`, {
|
||||
reason: reqArg.reason,
|
||||
timestamp: reqArg.timestamp,
|
||||
});
|
||||
|
||||
// Reset cumulative metrics (they don't survive cache invalidation)
|
||||
await persistentStore.resetCumulativeMetrics();
|
||||
|
||||
await this.cacheManager.cleanCaches(reqArg.reason);
|
||||
// Notify all clients to reload
|
||||
await this.leleServiceWorkerBackend.triggerReloadAll();
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to TypedServer via TypedSocket for cache invalidation
|
||||
*/
|
||||
private async connectToServer(): Promise<void> {
|
||||
try {
|
||||
// Register handler for cache invalidation from server
|
||||
this.typedrouter.addTypedHandler<interfaces.serviceworker.IRequest_Serviceworker_CacheInvalidate>(
|
||||
new plugins.typedrequest.TypedHandler('serviceworker_cacheInvalidate', async (reqArg) => {
|
||||
logger.log('info', `Cache invalidation requested from server: ${reqArg.reason}`);
|
||||
|
||||
// Log cache invalidation event (survives)
|
||||
const persistentStore = getPersistentStore();
|
||||
await persistentStore.init(); // Ensure store is initialized
|
||||
await persistentStore.logEvent('cache_invalidated', `Cache invalidated: ${reqArg.reason}`, {
|
||||
reason: reqArg.reason,
|
||||
timestamp: reqArg.timestamp,
|
||||
});
|
||||
|
||||
// Reset cumulative metrics (they don't survive cache invalidation)
|
||||
await persistentStore.resetCumulativeMetrics();
|
||||
|
||||
await this.cacheManager.cleanCaches(reqArg.reason);
|
||||
// Notify all clients to reload
|
||||
await this.leleServiceWorkerBackend.triggerReloadAll();
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
this.initHandlers();
|
||||
|
||||
// Connect to server via TypedSocket
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
|
||||
@@ -177,7 +177,7 @@ export class UpdateManager {
|
||||
try {
|
||||
const getAppHashRequest = new plugins.typedrequest.TypedRequest<
|
||||
interfaces.serviceworker.IRequest_Serviceworker_Backend_VersionInfo
|
||||
>('/sw-typedrequest', 'serviceworker_versionInfo');
|
||||
>('/typedrequest', 'serviceworker_versionInfo');
|
||||
|
||||
// Use networkManager for the request with retries and timeout
|
||||
const response = await getAppHashRequest.fire({});
|
||||
|
||||
@@ -191,4 +191,89 @@ 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');
|
||||
tr.skipHooks = true; // Prevent infinite loops - don't log the logging request
|
||||
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)');
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { ServiceworkerClient } from './classes.serviceworkerclient.js';
|
||||
import { ActionManager } from './classes.actionmanager.js';
|
||||
|
||||
export class GlobalSW {
|
||||
losslessSw: ServiceworkerClient;
|
||||
@@ -8,6 +9,13 @@ export class GlobalSW {
|
||||
globalThis.globalSw = this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exposes the action manager for traffic logging and SW communication
|
||||
*/
|
||||
public get actionManager(): ActionManager {
|
||||
return this.losslessSw.actionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* purges the cache of the app's serviceworker
|
||||
* @returns
|
||||
|
||||
Reference in New Issue
Block a user