feat(serviceworker): Enhance event and request logging with pagination support
This commit is contained in:
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user