import { LitElement, html, css, state, customElement, deesComms } from './plugins.js'; import type { CSSResult, TemplateResult } from './plugins.js'; import { sharedStyles, terminalStyles, navStyles } from './sw-dash-styles.js'; import type { IMetricsData } from './sw-dash-overview.js'; import type { ICachedResource } from './sw-dash-urls.js'; import type { IDomainStats } from './sw-dash-domains.js'; import type { IContentTypeStats } from './sw-dash-types.js'; import type { serviceworker } from '../dist_ts_interfaces/index.js'; // Import components to register them 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-requests.js'; import './sw-dash-table.js'; type ViewType = 'overview' | 'urls' | 'domains' | 'types' | 'events' | 'requests'; interface IResourceData { resources: ICachedResource[]; domains: IDomainStats[]; contentTypes: IContentTypeStats[]; resourceCount: number; } /** * 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 { public static styles: CSSResult[] = [ sharedStyles, terminalStyles, navStyles, css` :host { display: block; background: var(--bg-primary); min-height: 100vh; padding: var(--space-5); } .view { display: none; } .view.active { display: block; } .header-left { display: flex; align-items: center; gap: var(--space-3); } .logo { width: 24px; height: 24px; background: var(--accent-primary); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12px; color: white; } .uptime-badge { display: inline-flex; align-items: center; gap: var(--space-1); padding: var(--space-1) var(--space-2); background: var(--bg-tertiary); border-radius: var(--radius-sm); font-size: 11px; color: var(--text-tertiary); } .uptime-badge .value { color: var(--text-primary); font-weight: 500; font-variant-numeric: tabular-nums; } .footer-left { display: flex; align-items: center; gap: var(--space-2); color: var(--text-tertiary); font-size: 11px; } .footer-right { display: flex; align-items: center; gap: var(--space-2); } .auto-refresh { display: inline-flex; align-items: center; gap: var(--space-1); padding: var(--space-1) var(--space-2); background: rgba(34, 197, 94, 0.1); color: var(--accent-success); border-radius: var(--radius-sm); font-size: 11px; font-weight: 500; } .auto-refresh .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: pulse 2s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } ` ]; // 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 }; // 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 private heartbeatInterval: ReturnType | null = null; private readonly HEARTBEAT_INTERVAL_MS = 30000; connectedCallback(): void { super.connectedCallback(); // Initial HTTP seed request to wake up SW and get ALL initial data this.loadInitialData(); // Setup push listeners via DeesComms this.setupPushListeners(); // Start heartbeat for SW health check this.startHeartbeat(); } disconnectedCallback(): void { super.disconnectedCallback(); if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } } /** * 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 { try { 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; } catch (err) { console.error('Failed to load initial data:', err); this.isConnected = false; } } /** * Setup DeesComms handlers for receiving push updates from SW * All real-time updates come through here */ private setupPushListeners(): void { this.comms = new deesComms.DeesComms(); // Handle metrics push updates this.comms.createTypedHandler( 'serviceworker_metricsUpdate', async (snapshot) => { // Update metrics from push if (this.metrics) { this.metrics = { ...this.metrics, cache: { ...this.metrics.cache, hits: snapshot.cache.hits, misses: snapshot.cache.misses, errors: snapshot.cache.errors, bytesServedFromCache: snapshot.cache.bytesServedFromCache, bytesFetched: snapshot.cache.bytesFetched, }, network: { ...this.metrics.network, totalRequests: snapshot.network.totalRequests, successfulRequests: snapshot.network.successfulRequests, failedRequests: snapshot.network.failedRequests, }, cacheHitRate: snapshot.cacheHitRate, networkSuccessRate: snapshot.networkSuccessRate, resourceCount: snapshot.resourceCount, uptime: snapshot.uptime, }; } this.lastRefresh = new Date().toLocaleTimeString(); this.isConnected = true; return {}; } ); // Handle new event logged - add to our events array this.comms.createTypedHandler( 'serviceworker_eventLogged', async (entry) => { // 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 {}; } ); // Handle resource cached push updates this.comms.createTypedHandler( 'serviceworker_resourceCached', async (resource) => { // Update resource count optimistically if (resource.cached && this.metrics) { this.metrics = { ...this.metrics, resourceCount: this.metrics.resourceCount + 1, }; } return {}; } ); // Handle new TypedRequest logged - add to our logs array this.comms.createTypedHandler( 'serviceworker_typedRequestLogged', async (entry) => { // Prepend new log to array this.requestLogs = [entry, ...this.requestLogs]; this.requestTotalCount++; // Update stats optimistically if (this.requestStats) { const newStats = { ...this.requestStats }; if (entry.phase === 'request') { newStats.totalRequests++; } else { newStats.totalResponses++; } if (entry.error) { newStats.errorCount++; } // Update method counts if (!newStats.methodCounts[entry.method]) { newStats.methodCounts[entry.method] = { requests: 0, responses: 0, errors: 0, avgDurationMs: 0 }; // Add to methods list if new if (!this.requestMethods.includes(entry.method)) { this.requestMethods = [...this.requestMethods, entry.method]; } } if (entry.phase === 'request') { newStats.methodCounts[entry.method].requests++; } else { newStats.methodCounts[entry.method].responses++; } if (entry.error) { newStats.methodCounts[entry.method].errors++; } this.requestStats = newStats; } return {}; } ); } /** * Heartbeat to check SW health periodically (HTTP) * This is the ONLY periodic HTTP request */ private startHeartbeat(): void { this.heartbeatInterval = setInterval(async () => { try { const response = await fetch('/sw-dash/metrics'); if (response.ok) { this.isConnected = true; // 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; } } catch { this.isConnected = false; } }, this.HEARTBEAT_INTERVAL_MS); } /** * Handle "load more events" request from sw-dash-events component * Uses DeesComms to request older events from SW */ private async handleLoadMoreEvents(e: CustomEvent<{ before: number }>): Promise { if (!this.comms) return; try { const tr = this.comms.createTypedRequest('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 more events:', err); } } /** * Handle "clear events" request from sw-dash-events component * Uses DeesComms to clear event log in SW */ private async handleClearEvents(): Promise { if (!this.comms) return; try { const tr = this.comms.createTypedRequest('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 { if (!this.comms) return; try { const tr = this.comms.createTypedRequest('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 { if (!this.comms) return; try { const tr = this.comms.createTypedRequest('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; // No HTTP fetch on view change - data is already loaded from initial seed } private handleSpeedtestComplete(_e: CustomEvent): void { // Refresh metrics after speedtest via HTTP this.loadInitialData(); } private formatUptime(ms: number): string { const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const h = Math.floor(m / 60); const d = Math.floor(h / 24); if (d > 0) return `${d}d ${h % 24}h`; if (h > 0) return `${h}h ${m % 60}m`; if (m > 0) return `${m}m ${s % 60}s`; return `${s}s`; } public render(): TemplateResult { return html`
Service Worker Dashboard
Uptime: ${this.metrics ? this.formatUptime(this.metrics.uptime) : '--'}
`; } }