import { LitElement, html, css, property, state, customElement } from './plugins.js'; import type { CSSResult, TemplateResult } from './plugins.js'; import { sharedStyles, panelStyles, tableStyles, buttonStyles } from './sw-dash-styles.js'; export interface IEventLogEntry { id: string; timestamp: number; type: string; message: string; details?: Record; } type TEventFilter = 'all' | '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'; /** * Events panel component for sw-dash */ @customElement('sw-dash-events') export class SwDashEvents extends LitElement { public static styles: CSSResult[] = [ sharedStyles, panelStyles, tableStyles, buttonStyles, css` :host { display: block; } .events-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-4); gap: var(--space-3); flex-wrap: wrap; } .filter-group { display: flex; align-items: center; gap: var(--space-2); } .filter-label { font-size: 12px; color: var(--text-tertiary); } .filter-select { background: var(--bg-secondary); border: 1px solid var(--border-default); border-radius: var(--radius-sm); padding: var(--space-1) var(--space-2); color: var(--text-primary); font-size: 12px; } .filter-select:focus { outline: none; border-color: var(--accent-primary); } .events-list { display: flex; flex-direction: column; gap: var(--space-2); max-height: 600px; overflow-y: auto; } .event-card { background: var(--bg-secondary); border: 1px solid var(--border-default); border-radius: var(--radius-md); padding: var(--space-3); } .event-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-2); } .event-type { display: inline-flex; align-items: center; padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .event-type.sw { background: rgba(99, 102, 241, 0.15); color: var(--accent-primary); } .event-type.speedtest { background: rgba(59, 130, 246, 0.15); color: #3b82f6; } .event-type.network { background: rgba(34, 197, 94, 0.15); color: var(--accent-success); } .event-type.cache { background: rgba(251, 191, 36, 0.15); color: var(--accent-warning); } .event-type.error { background: rgba(239, 68, 68, 0.15); color: var(--accent-error); } .event-time { font-size: 11px; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } .event-message { font-size: 13px; color: var(--text-primary); margin-bottom: var(--space-2); } .event-details { font-size: 11px; color: var(--text-tertiary); background: var(--bg-tertiary); padding: var(--space-2); border-radius: var(--radius-sm); font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; white-space: pre-wrap; word-break: break-all; } .stats-bar { display: flex; gap: var(--space-4); margin-bottom: var(--space-4); padding: var(--space-3); background: var(--bg-secondary); border-radius: var(--radius-md); border: 1px solid var(--border-default); } .stat-item { display: flex; flex-direction: column; gap: var(--space-1); } .stat-value { font-size: 18px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; } .stat-label { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; } .empty-state { text-align: center; padding: var(--space-6); color: var(--text-tertiary); } .clear-btn { background: rgba(239, 68, 68, 0.1); color: var(--accent-error); border: 1px solid transparent; } .clear-btn:hover { background: rgba(239, 68, 68, 0.2); border-color: var(--accent-error); } .pagination { display: flex; justify-content: center; align-items: center; gap: var(--space-2); margin-top: var(--space-4); } .page-info { font-size: 12px; color: var(--text-tertiary); } ` ]; @property({ type: Array }) accessor events: IEventLogEntry[] = []; @state() accessor filter: TEventFilter = 'all'; @state() accessor searchText = ''; @state() accessor totalCount = 0; @state() accessor isLoading = true; @state() accessor page = 1; private readonly pageSize = 50; connectedCallback(): void { super.connectedCallback(); this.loadEvents(); } private async loadEvents(): Promise { 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; } } private handleFilterChange(e: Event): void { this.filter = (e.target as HTMLSelectElement).value as TEventFilter; this.page = 1; this.loadEvents(); } private handleSearch(e: Event): void { this.searchText = (e.target as HTMLInputElement).value.toLowerCase(); } private async handleClear(): Promise { 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); } } private loadMore(): void { this.page++; this.loadEvents(); } private getTypeClass(type: string): string { if (type.startsWith('sw_')) return 'sw'; if (type.startsWith('speedtest_')) return 'speedtest'; if (type.startsWith('network_') || type.startsWith('backend_')) return 'network'; if (type.startsWith('cache_') || type === 'update_check') return 'cache'; if (type === 'error') return 'error'; return 'sw'; } private formatTimestamp(ts: number): string { const date = new Date(ts); return date.toLocaleString(); } private formatTypeLabel(type: string): string { return type.replace(/_/g, ' '); } 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)) ); } public render(): TemplateResult { const filteredEvents = this.getFilteredEvents(); return html`
${this.totalCount} Total Events
${filteredEvents.length} Showing
Filter:
${this.isLoading && this.events.length === 0 ? html`
Loading events...
` : filteredEvents.length === 0 ? html`
No events found
` : html`
${filteredEvents.map(event => html`
${this.formatTypeLabel(event.type)} ${this.formatTimestamp(event.timestamp)}
${event.message}
${event.details ? html`
${JSON.stringify(event.details, null, 2)}
` : ''}
`)}
${this.events.length < this.totalCount ? html` ` : ''} `} `; } }