Files
typedserver/ts_swdash/sw-dash-app.ts

434 lines
13 KiB
TypeScript

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-table.js';
type ViewType = 'overview' | 'urls' | 'domains' | 'types' | 'events';
interface IResourceData {
resources: ICachedResource[];
domains: IDomainStats[];
contentTypes: IContentTypeStats[];
resourceCount: number;
}
/**
* Main SW Dashboard application shell
*/
@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; }
}
`
];
@state() accessor currentView: ViewType = 'overview';
@state() accessor metrics: IMetricsData | null = null;
@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
private comms: deesComms.DeesComms | null = null;
// Heartbeat interval (30 seconds) for SW health check
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private readonly HEARTBEAT_INTERVAL_MS = 30000;
connectedCallback(): void {
super.connectedCallback();
// Initial HTTP seed request to wake up SW and get 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 data and wake up service worker
*/
private async loadInitialData(): Promise<void> {
try {
// Fetch metrics (wakes up SW)
const metricsResponse = await fetch('/sw-dash/metrics');
this.metrics = await metricsResponse.json();
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;
}
}
/**
* Setup DeesComms handlers for receiving push updates
*/
private setupPushListeners(): void {
this.comms = new deesComms.DeesComms();
// Handle metrics push updates
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_MetricsUpdate>(
'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,
};
} 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;
return {};
}
);
// Handle event log push updates - dispatch to events component
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,
}));
return {};
}
);
// Handle resource cached push updates
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_ResourceCached>(
'serviceworker_resourceCached',
async (resource) => {
// Update resource count optimistically
if (resource.cached && this.metrics) {
this.metrics = {
...this.metrics,
resourceCount: this.metrics.resourceCount + 1,
};
}
return {};
}
);
}
/**
* Heartbeat to check SW health periodically
*/
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(async () => {
try {
const response = await fetch('/sw-dash/metrics');
if (response.ok) {
this.isConnected = true;
// Optionally refresh full metrics periodically
this.metrics = await response.json();
this.lastRefresh = new Date().toLocaleTimeString();
} else {
this.isConnected = false;
}
} catch {
this.isConnected = false;
}
}, this.HEARTBEAT_INTERVAL_MS);
}
/**
* Load resource data on demand (when switching to urls/domains/types view)
*/
private async loadResourceData(): Promise<void> {
try {
const response = await fetch('/sw-dash/resources');
this.resourceData = await response.json();
} catch (err) {
console.error('Failed to load resources:', err);
}
}
private setView(view: ViewType): void {
this.currentView = view;
if (view !== 'overview') {
this.loadResourceData();
}
}
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`
<div class="terminal">
<div class="header">
<div class="header-left">
<div class="logo">SW</div>
<span class="title">Service Worker Dashboard</span>
</div>
<div class="uptime-badge">
Uptime: <span class="value">${this.metrics ? this.formatUptime(this.metrics.uptime) : '--'}</span>
</div>
</div>
<nav class="nav">
<button
class="nav-tab ${this.currentView === 'overview' ? 'active' : ''}"
@click="${() => this.setView('overview')}"
>Overview</button>
<button
class="nav-tab ${this.currentView === 'urls' ? 'active' : ''}"
@click="${() => this.setView('urls')}"
>URLs <span class="count">${this.resourceData.resourceCount}</span></button>
<button
class="nav-tab ${this.currentView === 'domains' ? 'active' : ''}"
@click="${() => this.setView('domains')}"
>Domains</button>
<button
class="nav-tab ${this.currentView === 'types' ? 'active' : ''}"
@click="${() => this.setView('types')}"
>Types</button>
<button
class="nav-tab ${this.currentView === 'events' ? 'active' : ''}"
@click="${() => this.setView('events')}"
>Events</button>
</nav>
<div class="content">
<div class="view ${this.currentView === 'overview' ? 'active' : ''}">
<sw-dash-overview
.metrics="${this.metrics}"
@speedtest-complete="${this.handleSpeedtestComplete}"
></sw-dash-overview>
</div>
<div class="view ${this.currentView === 'urls' ? 'active' : ''}">
<sw-dash-urls .resources="${this.resourceData.resources}"></sw-dash-urls>
</div>
<div class="view ${this.currentView === 'domains' ? 'active' : ''}">
<sw-dash-domains .domains="${this.resourceData.domains}"></sw-dash-domains>
</div>
<div class="view ${this.currentView === 'types' ? 'active' : ''}">
<sw-dash-types .contentTypes="${this.resourceData.contentTypes}"></sw-dash-types>
</div>
<div class="view ${this.currentView === 'events' ? 'active' : ''}">
<sw-dash-events></sw-dash-events>
</div>
</div>
<div class="footer">
<div class="footer-left">
Last updated: ${this.lastRefresh}
</div>
<div class="footer-right">
<div class="auto-refresh">
<span class="dot"></span>
Live
</div>
</div>
</div>
</div>
`;
}
}