583 lines
19 KiB
TypeScript
583 lines
19 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-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<typeof setInterval> | 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<void> {
|
|
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.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,
|
|
};
|
|
}
|
|
this.lastRefresh = new Date().toLocaleTimeString();
|
|
this.isConnected = true;
|
|
return {};
|
|
}
|
|
);
|
|
|
|
// Handle new event logged - add to our events array
|
|
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_EventLogged>(
|
|
'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.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 {};
|
|
}
|
|
);
|
|
|
|
// Handle new TypedRequest logged - add to our logs array
|
|
this.comms.createTypedHandler<serviceworker.IMessage_Serviceworker_TypedRequestLogged>(
|
|
'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<void> {
|
|
if (!this.comms) return;
|
|
|
|
try {
|
|
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_GetEventLog>('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<void> {
|
|
if (!this.comms) return;
|
|
|
|
try {
|
|
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_ClearEventLog>('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<void> {
|
|
if (!this.comms) return;
|
|
|
|
try {
|
|
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_GetTypedRequestLogs>('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<void> {
|
|
if (!this.comms) return;
|
|
|
|
try {
|
|
const tr = this.comms.createTypedRequest<serviceworker.IRequest_Serviceworker_ClearTypedRequestLogs>('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`
|
|
<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>
|
|
<button
|
|
class="nav-tab ${this.currentView === 'requests' ? 'active' : ''}"
|
|
@click="${() => this.setView('requests')}"
|
|
>Requests</button>
|
|
</nav>
|
|
|
|
<div class="content">
|
|
<div class="view ${this.currentView === 'overview' ? 'active' : ''}">
|
|
<sw-dash-overview
|
|
.metrics="${this.metrics}"
|
|
.eventCountLastHour="${this.eventCountLastHour}"
|
|
@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
|
|
.events="${this.events}"
|
|
.totalCount="${this.eventTotalCount}"
|
|
@load-more-events="${this.handleLoadMoreEvents}"
|
|
@clear-events="${this.handleClearEvents}"
|
|
></sw-dash-events>
|
|
</div>
|
|
|
|
<div class="view ${this.currentView === 'requests' ? 'active' : ''}">
|
|
<sw-dash-requests
|
|
.logs="${this.requestLogs}"
|
|
.totalCount="${this.requestTotalCount}"
|
|
.stats="${this.requestStats}"
|
|
.methods="${this.requestMethods}"
|
|
@load-more-requests="${this.handleLoadMoreRequests}"
|
|
@clear-requests="${this.handleClearRequests}"
|
|
></sw-dash-requests>
|
|
</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>
|
|
`;
|
|
}
|
|
}
|