feat(serviceworker): Enhance event and request logging with pagination support

This commit is contained in:
2025-12-04 20:07:40 +00:00
parent 065987c854
commit 3baf171394
10 changed files with 394 additions and 357 deletions

View File

@@ -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[];
@@ -579,6 +580,7 @@ export interface IRequest_Serviceworker_GetTypedRequestLogs
limit?: number;
method?: string;
since?: number;
before?: number; // For pagination: get logs before this timestamp
};
response: {
logs: ITypedRequestLogEntry[];

View File

@@ -27,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 {
@@ -127,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
@@ -147,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();
@@ -163,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;
@@ -183,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();
@@ -215,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;
@@ -270,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 {};
}
);
@@ -299,23 +296,51 @@ export class SwDashApp extends LitElement {
}
);
// Handle TypedRequest logged push updates - dispatch to requests component
// Handle new TypedRequest logged - add to our logs array
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_TypedRequestLogged>(
'serviceworker_typedRequestLogged',
async (entry) => {
// Dispatch custom event for sw-dash-requests component
this.dispatchEvent(new CustomEvent('typedrequest-logged', {
detail: entry,
bubbles: true,
composed: true,
}));
// 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 () => {
@@ -323,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;
@@ -336,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 {
@@ -414,6 +527,7 @@ export class SwDashApp extends LitElement {
<div class="view ${this.currentView === 'overview' ? 'active' : ''}">
<sw-dash-overview
.metrics="${this.metrics}"
.eventCountLastHour="${this.eventCountLastHour}"
@speedtest-complete="${this.handleSpeedtestComplete}"
></sw-dash-overview>
</div>
@@ -431,11 +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></sw-dash-requests>
<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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -26,6 +26,10 @@ 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 {
@@ -303,137 +307,70 @@ export class SwDashRequests extends LitElement {
`
];
// Received from parent (sw-dash-app)
@property({ type: Array }) accessor logs: ITypedRequestLogEntry[] = [];
@state() accessor stats: ITypedRequestStats | null = null;
@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 totalCount = 0;
@state() accessor isLoading = true;
@state() accessor page = 1;
@state() accessor expandedPayloads: Set<string> = new Set();
@state() accessor availableMethods: string[] = [];
private readonly pageSize = 50;
// Bound event handler reference for cleanup
private boundLogHandler: ((e: Event) => void) | null = null;
connectedCallback(): void {
super.connectedCallback();
this.loadLogs();
this.loadStats();
this.loadMethods();
this.setupPushListener();
}
disconnectedCallback(): void {
super.disconnectedCallback();
if (this.boundLogHandler) {
window.removeEventListener('typedrequest-logged', this.boundLogHandler);
}
}
private setupPushListener(): void {
this.boundLogHandler = (e: Event) => {
const customEvent = e as CustomEvent<ITypedRequestLogEntry>;
const newLog = customEvent.detail;
// Apply filters
if (this.directionFilter !== 'all' && newLog.direction !== this.directionFilter) return;
if (this.phaseFilter !== 'all' && newLog.phase !== this.phaseFilter) return;
if (this.methodFilter && newLog.method !== this.methodFilter) return;
// Prepend new log
this.logs = [newLog, ...this.logs];
this.totalCount++;
// Update available methods if new
if (!this.availableMethods.includes(newLog.method)) {
this.availableMethods = [...this.availableMethods, newLog.method];
}
};
window.addEventListener('typedrequest-logged', this.boundLogHandler);
}
private async loadLogs(): Promise<void> {
this.isLoading = true;
try {
const params = new URLSearchParams();
params.set('limit', String(this.pageSize * this.page));
if (this.methodFilter) {
params.set('method', this.methodFilter);
}
const response = await fetch(`/sw-dash/requests?${params}`);
const data = await response.json();
this.logs = data.logs;
this.totalCount = data.totalCount;
} catch (err) {
console.error('Failed to load request logs:', err);
} finally {
this.isLoading = false;
}
}
private async loadStats(): Promise<void> {
try {
const response = await fetch('/sw-dash/requests/stats');
this.stats = await response.json();
} catch (err) {
console.error('Failed to load request stats:', err);
}
}
private async loadMethods(): Promise<void> {
try {
const response = await fetch('/sw-dash/requests/methods');
const data = await response.json();
this.availableMethods = data.methods;
} catch (err) {
console.error('Failed to load methods:', err);
}
}
@state() accessor isLoadingMore = false;
private handleDirectionFilterChange(e: Event): void {
this.directionFilter = (e.target as HTMLSelectElement).value as TRequestFilter;
this.page = 1;
this.loadLogs();
// Local filtering - no HTTP request
}
private handlePhaseFilterChange(e: Event): void {
this.phaseFilter = (e.target as HTMLSelectElement).value as TPhaseFilter;
this.page = 1;
this.loadLogs();
// Local filtering - no HTTP request
}
private handleMethodFilterChange(e: Event): void {
this.methodFilter = (e.target as HTMLSelectElement).value;
this.page = 1;
this.loadLogs();
// 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 request logs? This cannot be undone.')) {
return;
}
try {
await fetch('/sw-dash/requests', { method: 'DELETE' });
this.loadLogs();
this.loadStats();
} catch (err) {
console.error('Failed to clear request logs:', err);
}
// Dispatch event to parent to clear via DeesComms
this.dispatchEvent(new CustomEvent('clear-requests', {
bubbles: true,
composed: true,
}));
}
private loadMore(): void {
this.page++;
this.loadLogs();
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 togglePayload(correlationId: string): void {
@@ -469,6 +406,9 @@ export class SwDashRequests extends LitElement {
return `${(durationMs / 1000).toFixed(2)}s`;
}
/**
* Filter logs locally based on direction, phase, method, and search text
*/
private getFilteredLogs(): ITypedRequestLogEntry[] {
let result = this.logs;
@@ -482,6 +422,11 @@ export class SwDashRequests extends LitElement {
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 =>
@@ -563,7 +508,7 @@ export class SwDashRequests extends LitElement {
<span class="filter-label">Method:</span>
<select class="filter-select" @change="${this.handleMethodFilterChange}">
<option value="">All Methods</option>
${this.availableMethods.map(m => html`<option value="${m}">${m}</option>`)}
${this.methods.map(m => html`<option value="${m}">${m}</option>`)}
</select>
<input
@@ -579,10 +524,10 @@ export class SwDashRequests extends LitElement {
</div>
<!-- Request List -->
${this.isLoading && this.logs.length === 0 ? html`
<div class="empty-state">Loading request logs...</div>
` : filteredLogs.length === 0 ? html`
${this.logs.length === 0 ? html`
<div class="empty-state">No request logs found. Traffic will appear here as TypedRequests are made.</div>
` : filteredLogs.length === 0 ? html`
<div class="empty-state">No logs match filter</div>
` : html`
<div class="requests-list">
${filteredLogs.map(log => html`
@@ -624,8 +569,8 @@ export class SwDashRequests extends LitElement {
${this.logs.length < this.totalCount ? html`
<div class="pagination">
<button class="btn btn-secondary" @click="${this.loadMore}" ?disabled="${this.isLoading}">
${this.isLoading ? 'Loading...' : 'Load More'}
<button 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>

View File

@@ -120,6 +120,7 @@ export class ServiceworkerBackend {
limit: reqArg.limit,
type: reqArg.type,
since: reqArg.since,
before: reqArg.before,
});
});
@@ -164,6 +165,7 @@ export class ServiceworkerBackend {
limit: reqArg.limit,
method: reqArg.method,
since: reqArg.since,
before: reqArg.before,
});
const totalCount = requestLogStore.getTotalCount({
method: reqArg.method,

View File

@@ -210,65 +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;
}
// TypedRequest traffic monitoring endpoints
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'GET') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestLogs(parsedUrl.searchParams)));
return;
}
if (parsedUrl.pathname === '/sw-dash/requests/stats') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestStats()));
return;
}
if (parsedUrl.pathname === '/sw-dash/requests/methods') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.serveTypedRequestMethods()));
return;
}
// DELETE method for clearing TypedRequest logs
if (parsedUrl.pathname === '/sw-dash/requests' && originalRequest.method === 'DELETE') {
const dashboard = getDashboardGenerator();
fetchEventArg.respondWith(Promise.resolve(dashboard.clearTypedRequestLogs()));
return;
}
// 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 (

View File

@@ -25,10 +25,52 @@ 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(), {
public async serveMetrics(): Promise<Response> {
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',

View File

@@ -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);

View File

@@ -103,6 +103,7 @@ export class RequestLogStore {
limit?: number;
method?: string;
since?: number;
before?: number;
}): interfaces.serviceworker.ITypedRequestLogEntry[] {
let result = [...this.logs];
@@ -111,11 +112,16 @@ export class RequestLogStore {
result = result.filter((e) => e.method === options.method);
}
// Filter by timestamp
// 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);