434 lines
13 KiB
TypeScript
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>
|
|
`;
|
|
}
|
|
}
|