From a35775499b7e23148ea907bfe726b4fbfaa77f71 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 4 Dec 2025 15:13:48 +0000 Subject: [PATCH] feat(serviceworker): Add persistent event store, cumulative metrics and dashboard events UI for service worker observability --- changelog.md | 12 + ts/00_commitinfo_data.ts | 2 +- ts_interfaces/serviceworker.ts | 123 ++++++ ts_swdash/sw-dash-app.ts | 11 +- ts_swdash/sw-dash-events.ts | 353 ++++++++++++++++ ts_swdash/sw-dash-overview.ts | 29 ++ ts_web_serviceworker/classes.backend.ts | 60 ++- ts_web_serviceworker/classes.cachemanager.ts | 21 + ts_web_serviceworker/classes.dashboard.ts | 88 ++++ .../classes.persistentstore.ts | 399 ++++++++++++++++++ ts_web_serviceworker/classes.serviceworker.ts | 26 ++ 11 files changed, 1117 insertions(+), 7 deletions(-) create mode 100644 ts_swdash/sw-dash-events.ts create mode 100644 ts_web_serviceworker/classes.persistentstore.ts diff --git a/changelog.md b/changelog.md index 0f2d49a..a307256 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-12-04 - 7.4.0 - feat(serviceworker) +Add persistent event store, cumulative metrics and dashboard events UI for service worker observability + +- Add PersistentStore (ts_web_serviceworker/classes.persistentstore.ts) to persist event log and cumulative metrics with retention policy and periodic saving. +- Introduce persistent event types and interfaces for event log and cumulative metrics (ts_interfaces/serviceworker.ts). +- Log lifecycle, update, network and speedtest events to the persistent store (install, activate, update available/applied/error, network online/offline, speedtest started/completed/failed, cache invalidation). +- Expose persistent-store APIs via typed handlers in the service worker backend: serviceworker_getEventLog, serviceworker_getCumulativeMetrics, serviceworker_clearEventLog, serviceworker_getEventCount. +- Serve new dashboard endpoints from the service worker: /sw-dash/events (GET), /sw-dash/events/count (GET), /sw-dash/cumulative-metrics (GET) and DELETE /sw-dash/events to clear the log (handled in classes.cachemanager and classes.dashboard). +- Add sw-dash events panel component (ts_swdash/sw-dash-events.ts) and integrate an Events tab into the dashboard UI (ts_swdash/sw-dash-app.ts, sw-dash-overview.ts shows 1h event count). +- Reset cumulative metrics on cache invalidation and increment swRestartCount on PersistentStore.init(). +- Record speedtest lifecycle events (started/completed/failed) and include details in the event log. + ## 2025-12-04 - 7.3.0 - feat(serviceworker) Modernize SW dashboard UI and improve service worker backend and server tooling diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index fc6a3a8..a7d4339 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@api.global/typedserver', - version: '7.3.0', + version: '7.4.0', description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.' } diff --git a/ts_interfaces/serviceworker.ts b/ts_interfaces/serviceworker.ts index 65c1a0f..15b2139 100644 --- a/ts_interfaces/serviceworker.ts +++ b/ts_interfaces/serviceworker.ts @@ -313,4 +313,127 @@ export interface IRequest_Serviceworker_GetStatus connectedClients: number; lastUpdateCheck: number; }; +} + +// =============== +// Persistent Store interfaces +// =============== + +/** + * Event types for the persistent event log + */ +export type TEventType = + | 'sw_installed' + | 'sw_activated' + | 'sw_updated' + | 'sw_stopped' + | 'speedtest_started' + | 'speedtest_completed' + | 'speedtest_failed' + | 'backend_connected' + | 'backend_disconnected' + | 'cache_invalidated' + | 'network_online' + | 'network_offline' + | 'update_check' + | 'error'; + +/** + * Event log entry structure + * Survives both SW restarts AND cache invalidation + */ +export interface IEventLogEntry { + id: string; + timestamp: number; + type: TEventType; + message: string; + details?: Record; +} + +/** + * Cumulative metrics that persist across SW restarts + * Reset on cache invalidation + */ +export interface ICumulativeMetrics { + firstSeenTimestamp: number; + totalCacheHits: number; + totalCacheMisses: number; + totalCacheErrors: number; + totalBytesServedFromCache: number; + totalBytesFetched: number; + totalNetworkRequests: number; + totalNetworkSuccesses: number; + totalNetworkFailures: number; + totalNetworkTimeouts: number; + totalBytesTransferred: number; + totalUpdateChecks: number; + totalUpdatesApplied: number; + totalSpeedtests: number; + swRestartCount: number; + lastUpdatedTimestamp: number; +} + +/** + * Request to get event log from service worker + */ +export interface IRequest_Serviceworker_GetEventLog + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Serviceworker_GetEventLog + > { + method: 'serviceworker_getEventLog'; + request: { + limit?: number; + type?: TEventType; + since?: number; + }; + response: { + events: IEventLogEntry[]; + totalCount: number; + }; +} + +/** + * Request to get cumulative metrics from service worker + */ +export interface IRequest_Serviceworker_GetCumulativeMetrics + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Serviceworker_GetCumulativeMetrics + > { + method: 'serviceworker_getCumulativeMetrics'; + request: {}; + response: ICumulativeMetrics; +} + +/** + * Request to clear event log + */ +export interface IRequest_Serviceworker_ClearEventLog + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Serviceworker_ClearEventLog + > { + method: 'serviceworker_clearEventLog'; + request: {}; + response: { + success: boolean; + }; +} + +/** + * Request to get event count since a timestamp + */ +export interface IRequest_Serviceworker_GetEventCount + extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Serviceworker_GetEventCount + > { + method: 'serviceworker_getEventCount'; + request: { + since: number; + }; + response: { + count: number; + }; } \ No newline at end of file diff --git a/ts_swdash/sw-dash-app.ts b/ts_swdash/sw-dash-app.ts index 0839552..2c68e57 100644 --- a/ts_swdash/sw-dash-app.ts +++ b/ts_swdash/sw-dash-app.ts @@ -11,9 +11,10 @@ import './sw-dash-overview.js'; import './sw-dash-urls.js'; import './sw-dash-domains.js'; import './sw-dash-types.js'; +import './sw-dash-events.js'; import './sw-dash-table.js'; -type ViewType = 'overview' | 'urls' | 'domains' | 'types'; +type ViewType = 'overview' | 'urls' | 'domains' | 'types' | 'events'; interface IResourceData { resources: ICachedResource[]; @@ -228,6 +229,10 @@ export class SwDashApp extends LitElement { class="nav-tab ${this.currentView === 'types' ? 'active' : ''}" @click="${() => this.setView('types')}" >Types +
@@ -249,6 +254,10 @@ export class SwDashApp extends LitElement {
+ +
+ +
diff --git a/ts_web_serviceworker/classes.backend.ts b/ts_web_serviceworker/classes.backend.ts index 0a9d631..5377776 100644 --- a/ts_web_serviceworker/classes.backend.ts +++ b/ts_web_serviceworker/classes.backend.ts @@ -3,6 +3,7 @@ import * as interfaces from '../dist_ts_interfaces/index.js'; import { logger } from './logging.js'; import { getMetricsCollector } from './classes.metrics.js'; import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js'; +import { getPersistentStore } from './classes.persistentstore.js'; // Add type definitions for ServiceWorker APIs declare global { @@ -90,6 +91,36 @@ export class ServiceworkerBackend { }; }); + // Handler for getting event log + this.deesComms.createTypedHandler('serviceworker_getEventLog', async (reqArg) => { + const persistentStore = getPersistentStore(); + return await persistentStore.getEventLog({ + limit: reqArg.limit, + type: reqArg.type, + since: reqArg.since, + }); + }); + + // Handler for getting cumulative metrics + this.deesComms.createTypedHandler('serviceworker_getCumulativeMetrics', async () => { + const persistentStore = getPersistentStore(); + return persistentStore.getCumulativeMetrics(); + }); + + // Handler for clearing event log + this.deesComms.createTypedHandler('serviceworker_clearEventLog', async () => { + const persistentStore = getPersistentStore(); + const success = await persistentStore.clearEventLog(); + return { success }; + }); + + // Handler for getting event count since timestamp + this.deesComms.createTypedHandler('serviceworker_getEventCount', async (reqArg) => { + const persistentStore = getPersistentStore(); + const count = await persistentStore.getEventCount(reqArg.since); + return { count }; + }); + // Periodically update connected clients count this.startClientCountUpdates(); @@ -102,9 +133,10 @@ export class ServiceworkerBackend { */ private setupEventBusSubscriptions(): void { const eventBus = getEventBus(); + const persistentStore = getPersistentStore(); // Network status changes - eventBus.on(ServiceWorkerEvent.NETWORK_ONLINE, () => { + eventBus.on(ServiceWorkerEvent.NETWORK_ONLINE, async () => { this.broadcastStatusUpdate({ source: 'network', type: 'online', @@ -112,9 +144,11 @@ export class ServiceworkerBackend { persist: false, timestamp: Date.now(), }); + // Log to persistent store + await persistentStore.logEvent('network_online', 'Network connection restored'); }); - eventBus.on(ServiceWorkerEvent.NETWORK_OFFLINE, () => { + eventBus.on(ServiceWorkerEvent.NETWORK_OFFLINE, async () => { this.broadcastStatusUpdate({ source: 'network', type: 'offline', @@ -122,10 +156,12 @@ export class ServiceworkerBackend { persist: true, timestamp: Date.now(), }); + // Log to persistent store + await persistentStore.logEvent('network_offline', 'Network connection lost'); }); // Update events - eventBus.on(ServiceWorkerEvent.UPDATE_AVAILABLE, (_event, payload: any) => { + eventBus.on(ServiceWorkerEvent.UPDATE_AVAILABLE, async (_event, payload: any) => { this.broadcastStatusUpdate({ source: 'serviceworker', type: 'update', @@ -136,9 +172,13 @@ export class ServiceworkerBackend { persist: false, timestamp: Date.now(), }); + // Log to persistent store + await persistentStore.logEvent('update_check', `Update available: ${payload.newVersion}`, { + newVersion: payload.newVersion, + }); }); - eventBus.on(ServiceWorkerEvent.UPDATE_APPLIED, (_event, payload: any) => { + eventBus.on(ServiceWorkerEvent.UPDATE_APPLIED, async (_event, payload: any) => { this.broadcastStatusUpdate({ source: 'serviceworker', type: 'update', @@ -149,9 +189,13 @@ export class ServiceworkerBackend { persist: false, timestamp: Date.now(), }); + // Log to persistent store + await persistentStore.logEvent('sw_updated', `Service worker updated to ${payload.newVersion}`, { + newVersion: payload.newVersion, + }); }); - eventBus.on(ServiceWorkerEvent.UPDATE_ERROR, (_event, payload: any) => { + eventBus.on(ServiceWorkerEvent.UPDATE_ERROR, async (_event, payload: any) => { this.broadcastStatusUpdate({ source: 'serviceworker', type: 'error', @@ -159,6 +203,10 @@ export class ServiceworkerBackend { persist: true, timestamp: Date.now(), }); + // Log to persistent store + await persistentStore.logEvent('error', `Update error: ${payload.error || 'Unknown error'}`, { + error: payload.error, + }); }); // Cache invalidation @@ -170,6 +218,7 @@ export class ServiceworkerBackend { persist: false, timestamp: Date.now(), }); + // Note: cache_invalidated event is logged in the ServiceWorker class }); // Lifecycle events @@ -181,6 +230,7 @@ export class ServiceworkerBackend { persist: false, timestamp: Date.now(), }); + // Note: sw_activated event is logged in the ServiceWorker class }); } diff --git a/ts_web_serviceworker/classes.cachemanager.ts b/ts_web_serviceworker/classes.cachemanager.ts index 6216824..bdeb78c 100644 --- a/ts_web_serviceworker/classes.cachemanager.ts +++ b/ts_web_serviceworker/classes.cachemanager.ts @@ -225,6 +225,27 @@ export class CacheManager { 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(Promise.resolve(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; + } // Block requests that we don't want the service worker to handle. if ( diff --git a/ts_web_serviceworker/classes.dashboard.ts b/ts_web_serviceworker/classes.dashboard.ts index 6ed33f3..45b7a3a 100644 --- a/ts_web_serviceworker/classes.dashboard.ts +++ b/ts_web_serviceworker/classes.dashboard.ts @@ -1,6 +1,10 @@ import { getMetricsCollector } from './classes.metrics.js'; import { getServiceWorkerInstance } from './init.js'; +import { getPersistentStore } from './classes.persistentstore.js'; import * as interfaces from './env.js'; +import type { serviceworker } from '../dist_ts_interfaces/index.js'; + +type TEventType = serviceworker.TEventType; /** * Dashboard generator that creates a terminal-like metrics display @@ -43,6 +47,74 @@ export class DashboardGenerator { }); } + /** + * Serves event log data + */ + public async serveEventLog(searchParams: URLSearchParams): Promise { + const persistentStore = getPersistentStore(); + + const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!, 10) : undefined; + const type = searchParams.get('type') as TEventType | undefined; + const since = searchParams.get('since') ? parseInt(searchParams.get('since')!, 10) : undefined; + + const result = await persistentStore.getEventLog({ limit, type, since }); + + return new Response(JSON.stringify(result), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } + + /** + * Serves event count since a timestamp + */ + public async serveEventCount(searchParams: URLSearchParams): Promise { + const persistentStore = getPersistentStore(); + + const since = searchParams.get('since') ? parseInt(searchParams.get('since')!, 10) : Date.now() - 3600000; // Default: last hour + + const count = await persistentStore.getEventCount(since); + + return new Response(JSON.stringify({ count, since }), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } + + /** + * Serves cumulative metrics + */ + public serveCumulativeMetrics(): Response { + const persistentStore = getPersistentStore(); + const metrics = persistentStore.getCumulativeMetrics(); + + return new Response(JSON.stringify(metrics), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } + + /** + * Clears the event log + */ + public async clearEventLog(): Promise { + const persistentStore = getPersistentStore(); + const success = await persistentStore.clearEventLog(); + + return new Response(JSON.stringify({ success }), { + 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 @@ -53,6 +125,7 @@ export class DashboardGenerator { */ public async runSpeedtest(): Promise { const metrics = getMetricsCollector(); + const persistentStore = getPersistentStore(); const results: { latency?: { durationMs: number }; download?: { durationMs: number; speedMbps: number; bytesTransferred: number }; @@ -61,6 +134,9 @@ export class DashboardGenerator { isOnline: boolean; } = { isOnline: false }; + // Log speedtest start + await persistentStore.logEvent('speedtest_started', 'Speedtest initiated'); + try { const sw = getServiceWorkerInstance(); @@ -124,10 +200,22 @@ export class DashboardGenerator { metrics.recordSpeedtest('upload', uploadSpeedMbps); } + // Log speedtest completion + await persistentStore.logEvent('speedtest_completed', 'Speedtest finished', { + downloadMbps: results.download?.speedMbps.toFixed(2), + uploadMbps: results.upload?.speedMbps.toFixed(2), + latencyMs: results.latency?.durationMs, + }); + } catch (error) { results.error = error instanceof Error ? error.message : String(error); results.isOnline = false; metrics.setOnlineStatus(false); + + // Log speedtest failure + await persistentStore.logEvent('speedtest_failed', `Speedtest failed: ${results.error}`, { + error: results.error, + }); } return new Response(JSON.stringify(results), { diff --git a/ts_web_serviceworker/classes.persistentstore.ts b/ts_web_serviceworker/classes.persistentstore.ts new file mode 100644 index 0000000..9269ff2 --- /dev/null +++ b/ts_web_serviceworker/classes.persistentstore.ts @@ -0,0 +1,399 @@ +import * as plugins from './plugins.js'; +import { logger } from './logging.js'; +import type { serviceworker } from '../dist_ts_interfaces/index.js'; + +type ICumulativeMetrics = serviceworker.ICumulativeMetrics; +type IEventLogEntry = serviceworker.IEventLogEntry; +type TEventType = serviceworker.TEventType; + +/** + * Generates a simple UUID + */ +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Default cumulative metrics + */ +function createDefaultMetrics(): ICumulativeMetrics { + return { + firstSeenTimestamp: Date.now(), + totalCacheHits: 0, + totalCacheMisses: 0, + totalCacheErrors: 0, + totalBytesServedFromCache: 0, + totalBytesFetched: 0, + totalNetworkRequests: 0, + totalNetworkSuccesses: 0, + totalNetworkFailures: 0, + totalNetworkTimeouts: 0, + totalBytesTransferred: 0, + totalUpdateChecks: 0, + totalUpdatesApplied: 0, + totalSpeedtests: 0, + swRestartCount: 0, + lastUpdatedTimestamp: Date.now(), + }; +} + +/** + * PersistentStore manages persistent data for the service worker: + * - Cumulative metrics: Persist across SW restarts, reset on cache invalidation + * - Event log: Persists across SW restarts AND cache invalidation + */ +export class PersistentStore { + private static instance: PersistentStore; + private store: plugins.webstore.WebStore; + private initialized = false; + + // Storage keys + private readonly CUMULATIVE_KEY = 'metrics_cumulative'; + private readonly EVENT_LOG_KEY = 'event_log'; + + // Retention settings + private readonly MAX_EVENTS = 10000; + private readonly MAX_AGE_DAYS = 30; + private readonly MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in ms + + // Save interval (60 seconds) + private readonly SAVE_INTERVAL_MS = 60000; + private saveInterval: ReturnType | null = null; + + // In-memory cache for cumulative metrics + private cumulativeMetrics: ICumulativeMetrics | null = null; + private isDirty = false; + + private constructor() { + this.store = new plugins.webstore.WebStore({ + dbName: 'losslessServiceworker', + storeName: 'persistentStore', + }); + } + + /** + * Gets the singleton instance + */ + public static getInstance(): PersistentStore { + if (!PersistentStore.instance) { + PersistentStore.instance = new PersistentStore(); + } + return PersistentStore.instance; + } + + /** + * Initializes the store and starts periodic saving + */ + public async init(): Promise { + if (this.initialized) { + return; + } + + try { + await this.store.init(); + await this.loadCumulativeMetrics(); + + // Increment restart count + if (this.cumulativeMetrics) { + this.cumulativeMetrics.swRestartCount++; + this.isDirty = true; + await this.saveCumulativeMetrics(); + } + + // Start periodic save + this.startPeriodicSave(); + + this.initialized = true; + logger.log('ok', '[PersistentStore] Initialized successfully'); + } catch (error) { + logger.log('error', `[PersistentStore] Failed to initialize: ${error}`); + throw error; + } + } + + /** + * Starts periodic saving of metrics + */ + private startPeriodicSave(): void { + if (this.saveInterval) { + clearInterval(this.saveInterval); + } + + this.saveInterval = setInterval(async () => { + if (this.isDirty) { + await this.saveCumulativeMetrics(); + } + }, this.SAVE_INTERVAL_MS); + } + + /** + * Stops periodic saving + */ + public stopPeriodicSave(): void { + if (this.saveInterval) { + clearInterval(this.saveInterval); + this.saveInterval = null; + } + } + + // =================== + // Cumulative Metrics + // =================== + + /** + * Loads cumulative metrics from store + */ + public async loadCumulativeMetrics(): Promise { + try { + if (await this.store.check(this.CUMULATIVE_KEY)) { + this.cumulativeMetrics = await this.store.get(this.CUMULATIVE_KEY); + } else { + this.cumulativeMetrics = createDefaultMetrics(); + this.isDirty = true; + } + } catch (error) { + logger.log('warn', `[PersistentStore] Failed to load metrics: ${error}`); + this.cumulativeMetrics = createDefaultMetrics(); + this.isDirty = true; + } + + return this.cumulativeMetrics!; + } + + /** + * Saves cumulative metrics to store + */ + public async saveCumulativeMetrics(): Promise { + if (!this.cumulativeMetrics) { + return; + } + + try { + this.cumulativeMetrics.lastUpdatedTimestamp = Date.now(); + await this.store.set(this.CUMULATIVE_KEY, this.cumulativeMetrics); + this.isDirty = false; + logger.log('note', '[PersistentStore] Cumulative metrics saved'); + } catch (error) { + logger.log('error', `[PersistentStore] Failed to save metrics: ${error}`); + } + } + + /** + * Gets the current cumulative metrics + */ + public getCumulativeMetrics(): ICumulativeMetrics { + if (!this.cumulativeMetrics) { + return createDefaultMetrics(); + } + return { ...this.cumulativeMetrics }; + } + + /** + * Updates cumulative metrics with session delta + */ + public updateCumulativeMetrics(delta: Partial): void { + if (!this.cumulativeMetrics) { + this.cumulativeMetrics = createDefaultMetrics(); + } + + // Add delta values to cumulative + if (delta.totalCacheHits !== undefined) { + this.cumulativeMetrics.totalCacheHits += delta.totalCacheHits; + } + if (delta.totalCacheMisses !== undefined) { + this.cumulativeMetrics.totalCacheMisses += delta.totalCacheMisses; + } + if (delta.totalCacheErrors !== undefined) { + this.cumulativeMetrics.totalCacheErrors += delta.totalCacheErrors; + } + if (delta.totalBytesServedFromCache !== undefined) { + this.cumulativeMetrics.totalBytesServedFromCache += delta.totalBytesServedFromCache; + } + if (delta.totalBytesFetched !== undefined) { + this.cumulativeMetrics.totalBytesFetched += delta.totalBytesFetched; + } + if (delta.totalNetworkRequests !== undefined) { + this.cumulativeMetrics.totalNetworkRequests += delta.totalNetworkRequests; + } + if (delta.totalNetworkSuccesses !== undefined) { + this.cumulativeMetrics.totalNetworkSuccesses += delta.totalNetworkSuccesses; + } + if (delta.totalNetworkFailures !== undefined) { + this.cumulativeMetrics.totalNetworkFailures += delta.totalNetworkFailures; + } + if (delta.totalNetworkTimeouts !== undefined) { + this.cumulativeMetrics.totalNetworkTimeouts += delta.totalNetworkTimeouts; + } + if (delta.totalBytesTransferred !== undefined) { + this.cumulativeMetrics.totalBytesTransferred += delta.totalBytesTransferred; + } + if (delta.totalUpdateChecks !== undefined) { + this.cumulativeMetrics.totalUpdateChecks += delta.totalUpdateChecks; + } + if (delta.totalUpdatesApplied !== undefined) { + this.cumulativeMetrics.totalUpdatesApplied += delta.totalUpdatesApplied; + } + if (delta.totalSpeedtests !== undefined) { + this.cumulativeMetrics.totalSpeedtests += delta.totalSpeedtests; + } + + this.isDirty = true; + } + + /** + * Resets cumulative metrics (called on cache invalidation) + */ + public async resetCumulativeMetrics(): Promise { + this.cumulativeMetrics = createDefaultMetrics(); + this.isDirty = true; + await this.saveCumulativeMetrics(); + logger.log('info', '[PersistentStore] Cumulative metrics reset'); + } + + // =================== + // Event Log + // =================== + + /** + * Logs an event to the persistent event log + */ + public async logEvent( + type: TEventType, + message: string, + details?: Record + ): Promise { + const entry: IEventLogEntry = { + id: generateId(), + timestamp: Date.now(), + type, + message, + details, + }; + + try { + let events: IEventLogEntry[] = []; + + if (await this.store.check(this.EVENT_LOG_KEY)) { + events = await this.store.get(this.EVENT_LOG_KEY); + } + + // Add new entry + events.push(entry); + + // Apply retention policy + events = this.applyRetentionPolicy(events); + + await this.store.set(this.EVENT_LOG_KEY, events); + logger.log('note', `[PersistentStore] Logged event: ${type} - ${message}`); + } catch (error) { + logger.log('error', `[PersistentStore] Failed to log event: ${error}`); + } + } + + /** + * Gets event log entries + */ + public async getEventLog(options?: { + limit?: number; + type?: TEventType; + since?: number; + }): Promise<{ events: IEventLogEntry[]; totalCount: number }> { + try { + let events: IEventLogEntry[] = []; + + if (await this.store.check(this.EVENT_LOG_KEY)) { + events = await this.store.get(this.EVENT_LOG_KEY); + } + + const totalCount = events.length; + + // Filter by type if specified + if (options?.type) { + events = events.filter(e => e.type === options.type); + } + + // Filter by since timestamp if specified + if (options?.since) { + events = events.filter(e => e.timestamp >= options.since); + } + + // Sort by timestamp (newest first) + events.sort((a, b) => b.timestamp - a.timestamp); + + // Apply limit if specified + if (options?.limit && options.limit > 0) { + events = events.slice(0, options.limit); + } + + return { events, totalCount }; + } catch (error) { + logger.log('error', `[PersistentStore] Failed to get event log: ${error}`); + return { events: [], totalCount: 0 }; + } + } + + /** + * Gets count of events since a timestamp + */ + public async getEventCount(since: number): Promise { + try { + if (!(await this.store.check(this.EVENT_LOG_KEY))) { + return 0; + } + + const events: IEventLogEntry[] = await this.store.get(this.EVENT_LOG_KEY); + return events.filter(e => e.timestamp >= since).length; + } catch (error) { + logger.log('error', `[PersistentStore] Failed to get event count: ${error}`); + return 0; + } + } + + /** + * Clears all events from the log + */ + public async clearEventLog(): Promise { + try { + await this.store.set(this.EVENT_LOG_KEY, []); + logger.log('info', '[PersistentStore] Event log cleared'); + return true; + } catch (error) { + logger.log('error', `[PersistentStore] Failed to clear event log: ${error}`); + return false; + } + } + + /** + * Applies retention policy to event log: + * - Max 10,000 events + * - Max 30 days old + */ + private applyRetentionPolicy(events: IEventLogEntry[]): IEventLogEntry[] { + const now = Date.now(); + const cutoffTime = now - this.MAX_AGE_MS; + + // Filter out events older than 30 days + let filtered = events.filter(e => e.timestamp >= cutoffTime); + + // If still over limit, remove oldest entries + if (filtered.length > this.MAX_EVENTS) { + // Sort by timestamp (oldest first) then keep only newest MAX_EVENTS + filtered.sort((a, b) => a.timestamp - b.timestamp); + filtered = filtered.slice(filtered.length - this.MAX_EVENTS); + } + + return filtered; + } + + /** + * Flushes pending changes (call before SW stops) + */ + public async flush(): Promise { + if (this.isDirty && this.cumulativeMetrics) { + await this.saveCumulativeMetrics(); + } + } +} + +// Export singleton getter for convenience +export const getPersistentStore = (): PersistentStore => PersistentStore.getInstance(); diff --git a/ts_web_serviceworker/classes.serviceworker.ts b/ts_web_serviceworker/classes.serviceworker.ts index 6548d42..fa5f66f 100644 --- a/ts_web_serviceworker/classes.serviceworker.ts +++ b/ts_web_serviceworker/classes.serviceworker.ts @@ -12,6 +12,7 @@ import { UpdateManager } from './classes.updatemanager.js'; import { NetworkManager } from './classes.networkmanager.js'; import { TaskManager } from './classes.taskmanager.js'; import { ServiceworkerBackend } from './classes.backend.js'; +import { getPersistentStore } from './classes.persistentstore.js'; export class ServiceWorker { // STATIC @@ -63,6 +64,14 @@ export class ServiceWorker { // its important to not go async before event.waitUntil try { logger.log('success', `service worker installed! TimeStamp = ${new Date().toISOString()}`); + + // Log installation event + const persistentStore = getPersistentStore(); + await persistentStore.init(); + await persistentStore.logEvent('sw_installed', 'Service worker installed', { + timestamp: new Date().toISOString(), + }); + selfArg.skipWaiting(); logger.log('note', `Called skip waiting!`); done.resolve(); @@ -84,6 +93,12 @@ export class ServiceWorker { await this.cacheManager.cleanCaches('new service worker loaded! :)'); logger.log('ok', 'Caches cleaned successfully'); + // Log activation event + const persistentStore = getPersistentStore(); + await persistentStore.logEvent('sw_activated', 'Service worker activated', { + timestamp: new Date().toISOString(), + }); + done.resolve(); logger.log('success', `Service worker activated at ${new Date().toISOString()}`); @@ -105,6 +120,17 @@ export class ServiceWorker { this.typedrouter.addTypedHandler( 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.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();