From 4db6fa6771ea7066bc5974f670d673ee1ea13f69 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 4 Dec 2025 13:10:15 +0000 Subject: [PATCH] feat(swdash): Add SW-Dash (Lit-based service worker dashboard), bundle & serve it; improve servertools and static handlers --- changelog.md | 13 + package.json | 3 +- ts/00_commitinfo_data.ts | 2 +- ts/controllers/controller.builtin.ts | 22 + ts/paths.ts | 5 +- ts_swdash/index.ts | 13 + ts_swdash/plugins.ts | 15 + ts_swdash/sw-dash-app.ts | 186 ++++++ ts_swdash/sw-dash-domains.ts | 52 ++ ts_swdash/sw-dash-overview.ts | 183 ++++++ ts_swdash/sw-dash-styles.ts | 427 ++++++++++++ ts_swdash/sw-dash-table.ts | 173 +++++ ts_swdash/sw-dash-types.ts | 52 ++ ts_swdash/sw-dash-urls.ts | 66 ++ ts_web_serviceworker/classes.dashboard.ts | 749 +--------------------- 15 files changed, 1212 insertions(+), 749 deletions(-) create mode 100644 ts_swdash/index.ts create mode 100644 ts_swdash/plugins.ts create mode 100644 ts_swdash/sw-dash-app.ts create mode 100644 ts_swdash/sw-dash-domains.ts create mode 100644 ts_swdash/sw-dash-overview.ts create mode 100644 ts_swdash/sw-dash-styles.ts create mode 100644 ts_swdash/sw-dash-table.ts create mode 100644 ts_swdash/sw-dash-types.ts create mode 100644 ts_swdash/sw-dash-urls.ts diff --git a/changelog.md b/changelog.md index 7f0e00d..c1e386c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2025-12-04 - 6.8.0 - feat(swdash) +Add SW-Dash (Lit-based service worker dashboard), bundle & serve it; improve servertools and static handlers + +- Add a new sw-dash frontend (ts_swdash) implemented with Lit: sw-dash-app, sw-dash-overview, sw-dash-urls, sw-dash-domains, sw-dash-types, sw-dash-table, shared styles and plugin shims. +- Wire sw-dash into build pipeline and packaging: add ts_swdash bundle to npm build script and include ts_swdash in package files. +- Serve the dashboard bundle: add paths (swdashBundleDir / swdashBundlePath) and a built-in route (/sw-dash/bundle.js) in BuiltInRoutesController. +- Simplify service-worker dashboard HTML output to a minimal shell that mounts and loads the module /sw-dash/bundle.js (reduces inline HTML/CSS/JS duplication). +- Lazy-load service worker bundle and source map in servertools.tools.serviceworker and expose /sw-typedrequest endpoints for SW typed requests (including speedtest handler). +- Enhance compression utilities and static serving: Compressor now caches compressed results, prioritizes preferred compression methods, provides safer zlib calls, and exposes createCompressionStream; HandlerStatic gained improved path resolution, Express 5 wildcard handling and optional compression flow. +- Improve proxy/static handler path handling to be compatible with Express 5 wildcard parameters and more robust fallback logic. +- Deprecate Server.addTypedSocket (no-op) and document recommended SmartServe/TypedServer integration for WebSocket support. +- Various minor packaging/path updates (paths.ts, plugins exports) to support the new dashboard and bundles. + ## 2025-12-04 - 6.7.0 - feat(web_serviceworker) Add per-resource metrics and request deduplication to service worker cache manager diff --git a/package.json b/package.json index 7dd5d9e..64c1dd5 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "test": "npm run build && tstest test/ --verbose --logfile --timeout 60", "build": "tsbuild tsfolders --web --allowimplicitany && npm run bundle", - "bundle": "tsbundle --from ./ts_web_inject/index.ts --to ./dist_ts_web_inject/bundle.js && tsbundle --from ./ts_web_serviceworker/index.ts --to ./dist_ts_web_serviceworker/serviceworker.bundle.js", + "bundle": "tsbundle --from ./ts_web_inject/index.ts --to ./dist_ts_web_inject/bundle.js && tsbundle --from ./ts_web_serviceworker/index.ts --to ./dist_ts_web_serviceworker/serviceworker.bundle.js && tsbundle --from ./ts_swdash/index.ts --to ./dist_ts_swdash/bundle.js", "interfaces": "tsbuild interfaces --web --allowimplicitany --skiplibcheck", "docs": "tsdoc aidoc" }, @@ -47,6 +47,7 @@ "files": [ "ts/**/*", "ts_web/**/*", + "ts_swdash/**/*", "dist/**/*", "dist_*/**/*", "dist_ts/**/*", diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 3e974bf..aee7939 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@api.global/typedserver', - version: '6.7.0', + version: '6.8.0', description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.' } diff --git a/ts/controllers/controller.builtin.ts b/ts/controllers/controller.builtin.ts index 084851d..a5d89dd 100644 --- a/ts/controllers/controller.builtin.ts +++ b/ts/controllers/controller.builtin.ts @@ -1,4 +1,5 @@ import * as plugins from '../plugins.js'; +import * as paths from '../paths.js'; /** * Built-in routes controller for TypedServer @@ -122,4 +123,25 @@ export class BuiltInRoutesController { headers: { 'Content-Type': 'text/plain' }, }); } + + @plugins.smartserve.Get('/sw-dash/bundle.js') + async getSwDashBundle(ctx: plugins.smartserve.IRequestContext): Promise { + try { + const bundleContent = (await plugins.fsInstance + .file(paths.swdashBundlePath) + .encoding('utf8') + .read()) as string; + + return new Response(bundleContent, { + status: 200, + headers: { + 'Content-Type': 'text/javascript', + 'Cache-Control': 'no-cache', + }, + }); + } catch (error) { + console.error('Failed to serve sw-dash bundle:', error); + return new Response('SW-Dash bundle not found', { status: 404 }); + } + } } diff --git a/ts/paths.ts b/ts/paths.ts index 9f383fc..ecf8acd 100644 --- a/ts/paths.ts +++ b/ts/paths.ts @@ -8,4 +8,7 @@ export const packageDir = plugins.path.join( export const injectBundleDir = plugins.path.join(packageDir, './dist_ts_web_inject'); export const injectBundlePath = plugins.path.join(injectBundleDir, './bundle.js'); -export const serviceworkerBundleDir = plugins.path.join(packageDir, './dist_ts_web_serviceworker'); \ No newline at end of file +export const serviceworkerBundleDir = plugins.path.join(packageDir, './dist_ts_web_serviceworker'); + +export const swdashBundleDir = plugins.path.join(packageDir, './dist_ts_swdash'); +export const swdashBundlePath = plugins.path.join(swdashBundleDir, './bundle.js'); \ No newline at end of file diff --git a/ts_swdash/index.ts b/ts_swdash/index.ts new file mode 100644 index 0000000..611b644 --- /dev/null +++ b/ts_swdash/index.ts @@ -0,0 +1,13 @@ +// SW-Dash: Service Worker Dashboard +// Entry point for the Lit-based dashboard application + +// Import the main app component (which imports all others) +import './sw-dash-app.js'; + +// Export components for external use if needed +export { SwDashApp } from './sw-dash-app.js'; +export { SwDashOverview } from './sw-dash-overview.js'; +export { SwDashTable } from './sw-dash-table.js'; +export { SwDashUrls } from './sw-dash-urls.js'; +export { SwDashDomains } from './sw-dash-domains.js'; +export { SwDashTypes } from './sw-dash-types.js'; diff --git a/ts_swdash/plugins.ts b/ts_swdash/plugins.ts new file mode 100644 index 0000000..d239102 --- /dev/null +++ b/ts_swdash/plugins.ts @@ -0,0 +1,15 @@ +// Lit imports +import { LitElement, html, css } from 'lit'; +import type { CSSResult, TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +export { + LitElement, + html, + css, + customElement, + property, + state, +}; + +export type { CSSResult, TemplateResult }; diff --git a/ts_swdash/sw-dash-app.ts b/ts_swdash/sw-dash-app.ts new file mode 100644 index 0000000..93e185d --- /dev/null +++ b/ts_swdash/sw-dash-app.ts @@ -0,0 +1,186 @@ +import { LitElement, html, css, state, customElement } 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 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-table.js'; + +type ViewType = 'overview' | 'urls' | 'domains' | 'types'; + +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(--sw-bg-dark); + min-height: 100vh; + padding: 20px; + } + + .view { + display: none; + } + + .view.active { + display: block; + } + ` + ]; + + @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(); + + private refreshInterval: ReturnType | null = null; + + connectedCallback(): void { + super.connectedCallback(); + this.loadMetrics(); + this.loadResourceData(); + // Auto-refresh every 2 seconds + this.refreshInterval = setInterval(() => { + this.loadMetrics(); + if (this.currentView !== 'overview') { + this.loadResourceData(); + } + }, 2000); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + } + + private async loadMetrics(): Promise { + try { + const response = await fetch('/sw-dash/metrics'); + this.metrics = await response.json(); + this.lastRefresh = new Date().toLocaleTimeString(); + } catch (err) { + console.error('Failed to load metrics:', err); + } + } + + private async loadResourceData(): Promise { + 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 + this.loadMetrics(); + } + + 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` +
+
+ [SW-DASH] Service Worker Dashboard + Uptime: ${this.metrics ? this.formatUptime(this.metrics.uptime) : '...'} +
+ + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+ `; + } +} diff --git a/ts_swdash/sw-dash-domains.ts b/ts_swdash/sw-dash-domains.ts new file mode 100644 index 0000000..8ef9495 --- /dev/null +++ b/ts_swdash/sw-dash-domains.ts @@ -0,0 +1,52 @@ +import { LitElement, html, css, property, customElement } from './plugins.js'; +import type { CSSResult, TemplateResult } from './plugins.js'; +import { sharedStyles, tableStyles } from './sw-dash-styles.js'; +import { SwDashTable } from './sw-dash-table.js'; +import type { IColumnConfig } from './sw-dash-table.js'; + +export interface IDomainStats { + domain: string; + totalResources: number; + totalSize: number; + totalHits: number; + totalMisses: number; + hitRate: number; +} + +/** + * Domains table view component + */ +@customElement('sw-dash-domains') +export class SwDashDomains extends LitElement { + public static styles: CSSResult[] = [ + sharedStyles, + tableStyles, + css` + :host { + display: block; + } + ` + ]; + + @property({ type: Array }) accessor domains: IDomainStats[] = []; + + private columns: IColumnConfig[] = [ + { key: 'domain', label: 'Domain' }, + { key: 'totalResources', label: 'Resources', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'totalSize', label: 'Total Size', className: 'num', formatter: SwDashTable.formatBytes }, + { key: 'totalHits', label: 'Hits', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'totalMisses', label: 'Misses', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'hitRate', label: 'Hit Rate' }, + ]; + + public render(): TemplateResult { + return html` + + `; + } +} diff --git a/ts_swdash/sw-dash-overview.ts b/ts_swdash/sw-dash-overview.ts new file mode 100644 index 0000000..3409d92 --- /dev/null +++ b/ts_swdash/sw-dash-overview.ts @@ -0,0 +1,183 @@ +import { LitElement, html, css, property, state, customElement } from './plugins.js'; +import type { CSSResult, TemplateResult } from './plugins.js'; +import { sharedStyles, panelStyles, gaugeStyles, buttonStyles, speedtestStyles } from './sw-dash-styles.js'; +import { SwDashTable } from './sw-dash-table.js'; + +export interface IMetricsData { + cache: { + hits: number; + misses: number; + errors: number; + bytesServedFromCache: number; + bytesFetched: number; + averageResponseTime: number; + }; + network: { + totalRequests: number; + successfulRequests: number; + failedRequests: number; + timeouts: number; + averageLatency: number; + totalBytesTransferred: number; + }; + update: { + totalChecks: number; + successfulChecks: number; + failedChecks: number; + updatesFound: number; + updatesApplied: number; + lastCheckTimestamp: number; + lastUpdateTimestamp: number; + }; + connection: { + connectedClients: number; + totalConnectionAttempts: number; + successfulConnections: number; + failedConnections: number; + }; + speedtest: { + lastDownloadSpeedMbps: number; + lastUploadSpeedMbps: number; + lastLatencyMs: number; + lastTestTimestamp: number; + testCount: number; + isOnline: boolean; + }; + startTime: number; + uptime: number; + cacheHitRate: number; + networkSuccessRate: number; + resourceCount: number; +} + +/** + * Overview panel component with metrics gauges and stats + */ +@customElement('sw-dash-overview') +export class SwDashOverview extends LitElement { + public static styles: CSSResult[] = [ + sharedStyles, + panelStyles, + gaugeStyles, + buttonStyles, + speedtestStyles, + css` + :host { + display: block; + } + ` + ]; + + @property({ type: Object }) accessor metrics: IMetricsData | null = null; + @state() accessor speedtestRunning = false; + + private async runSpeedtest(): Promise { + if (this.speedtestRunning) return; + this.speedtestRunning = true; + + try { + const response = await fetch('/sw-dash/speedtest'); + const result = await response.json(); + + // Dispatch event to parent to update metrics + this.dispatchEvent(new CustomEvent('speedtest-complete', { + detail: result, + bubbles: true, + composed: true + })); + } catch (err) { + console.error('Speedtest failed:', err); + } finally { + this.speedtestRunning = false; + } + } + + public render(): TemplateResult { + if (!this.metrics) { + return html`
Loading metrics...
`; + } + + const m = this.metrics; + const gaugeClass = SwDashTable.getGaugeClass; + + return html` +
+ +
+
[ CACHE ]
+
+
+
+ ${m.cacheHitRate}% hit rate +
+
+
Hits:${SwDashTable.formatNumber(m.cache.hits)}
+
Misses:${SwDashTable.formatNumber(m.cache.misses)}
+
Errors:${SwDashTable.formatNumber(m.cache.errors)}
+
From Cache:${SwDashTable.formatBytes(m.cache.bytesServedFromCache)}
+
Fetched:${SwDashTable.formatBytes(m.cache.bytesFetched)}
+
Resources:${m.resourceCount}
+
+ + +
+
[ NETWORK ]
+
+
+
+ ${m.networkSuccessRate}% success +
+
+
Total Requests:${SwDashTable.formatNumber(m.network.totalRequests)}
+
Successful:${SwDashTable.formatNumber(m.network.successfulRequests)}
+
Failed:${SwDashTable.formatNumber(m.network.failedRequests)}
+
Timeouts:${SwDashTable.formatNumber(m.network.timeouts)}
+
Avg Latency:${m.network.averageLatency}ms
+
Transferred:${SwDashTable.formatBytes(m.network.totalBytesTransferred)}
+
+ + +
+
[ UPDATES ]
+
Total Checks:${SwDashTable.formatNumber(m.update.totalChecks)}
+
Successful:${SwDashTable.formatNumber(m.update.successfulChecks)}
+
Failed:${SwDashTable.formatNumber(m.update.failedChecks)}
+
Updates Found:${SwDashTable.formatNumber(m.update.updatesFound)}
+
Updates Applied:${SwDashTable.formatNumber(m.update.updatesApplied)}
+
Last Check:${SwDashTable.formatTimestamp(m.update.lastCheckTimestamp)}
+
+ + +
+
[ CONNECTIONS ]
+
Active Clients:${SwDashTable.formatNumber(m.connection.connectedClients)}
+
Total Attempts:${SwDashTable.formatNumber(m.connection.totalConnectionAttempts)}
+
Successful:${SwDashTable.formatNumber(m.connection.successfulConnections)}
+
Failed:${SwDashTable.formatNumber(m.connection.failedConnections)}
+
+ Started:${SwDashTable.formatTimestamp(m.startTime)} +
+
+ + +
+
[ SPEEDTEST ]
+
+ + ${m.speedtest.isOnline ? 'Online' : 'Offline'} +
+
Download:${m.speedtest.lastDownloadSpeedMbps.toFixed(2)} Mbps
+
+
Upload:${m.speedtest.lastUploadSpeedMbps.toFixed(2)} Mbps
+
+
Latency:${m.speedtest.lastLatencyMs.toFixed(0)} ms
+
+ +
+
+
+ `; + } +} diff --git a/ts_swdash/sw-dash-styles.ts b/ts_swdash/sw-dash-styles.ts new file mode 100644 index 0000000..b9c4bcc --- /dev/null +++ b/ts_swdash/sw-dash-styles.ts @@ -0,0 +1,427 @@ +import { css } from './plugins.js'; +import type { CSSResult } from './plugins.js'; + +/** + * Shared terminal-style theme for sw-dash components + */ +export const sharedStyles: CSSResult = css` + :host { + --sw-bg-dark: #0a0a0a; + --sw-bg-panel: #0d0d0d; + --sw-bg-header: #111; + --sw-bg-input: #1a1a1a; + --sw-border: #333; + --sw-border-active: #00ff00; + --sw-text-primary: #00ff00; + --sw-text-secondary: #888; + --sw-text-cyan: #00ffff; + --sw-text-warning: #ffff00; + --sw-text-error: #ff4444; + --sw-gauge-good: #00aa00; + --sw-gauge-warning: #aaaa00; + --sw-gauge-bad: #aa0000; + + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + line-height: 1.4; + color: var(--sw-text-primary); + } +`; + +export const terminalStyles: CSSResult = css` + .terminal { + max-width: 1200px; + margin: 0 auto; + border: 1px solid var(--sw-border-active); + background: var(--sw-bg-panel); + box-shadow: 0 0 20px rgba(0, 255, 0, 0.1); + } + + .header { + border-bottom: 1px solid var(--sw-border-active); + padding: 10px 15px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--sw-bg-header); + } + + .title { + color: var(--sw-text-primary); + font-weight: bold; + font-size: 16px; + } + + .uptime { + color: var(--sw-text-secondary); + } + + .content { + padding: 15px; + min-height: 400px; + } + + .footer { + border-top: 1px solid var(--sw-border-active); + padding: 10px 15px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--sw-bg-header); + font-size: 12px; + } + + .refresh-info { + color: var(--sw-text-secondary); + } + + .status { + display: flex; + align-items: center; + gap: 8px; + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--sw-text-primary); + animation: pulse 2s infinite; + } + + .prompt { + color: var(--sw-text-primary); + } + + .cursor { + display: inline-block; + width: 8px; + height: 14px; + background: var(--sw-text-primary); + animation: blink 1s step-end infinite; + vertical-align: middle; + margin-left: 2px; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + @keyframes blink { + 50% { opacity: 0; } + } +`; + +export const navStyles: CSSResult = css` + .nav { + display: flex; + background: var(--sw-bg-header); + border-bottom: 1px solid var(--sw-border); + padding: 0 10px; + } + + .nav-tab { + padding: 10px 20px; + cursor: pointer; + color: var(--sw-text-secondary); + border: none; + background: transparent; + font-family: inherit; + font-size: 13px; + transition: all 0.2s; + border-bottom: 2px solid transparent; + } + + .nav-tab:hover { + color: var(--sw-text-primary); + } + + .nav-tab.active { + color: var(--sw-text-primary); + border-bottom-color: var(--sw-text-primary); + background: var(--sw-bg-input); + } + + .nav-tab .count { + background: var(--sw-border); + padding: 1px 6px; + border-radius: 8px; + font-size: 11px; + margin-left: 6px; + } +`; + +export const panelStyles: CSSResult = css` + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 15px; + } + + .panel { + border: 1px solid var(--sw-border); + padding: 12px; + background: var(--sw-bg-dark); + } + + .panel-title { + color: var(--sw-text-cyan); + font-weight: bold; + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 1px dashed var(--sw-border); + } + + .row { + display: flex; + justify-content: space-between; + padding: 3px 0; + } + + .label { + color: var(--sw-text-secondary); + } + + .value { + color: var(--sw-text-primary); + } + + .value.warning { + color: var(--sw-text-warning); + } + + .value.error { + color: var(--sw-text-error); + } + + .value.success { + color: var(--sw-text-primary); + } +`; + +export const gaugeStyles: CSSResult = css` + .gauge { + margin: 8px 0; + } + + .gauge-bar { + height: 16px; + background: var(--sw-bg-input); + border: 1px solid var(--sw-border); + position: relative; + font-size: 12px; + } + + .gauge-fill { + height: 100%; + transition: width 0.3s ease; + } + + .gauge-fill.good { + background: var(--sw-gauge-good); + } + + .gauge-fill.warning { + background: var(--sw-gauge-warning); + } + + .gauge-fill.bad { + background: var(--sw-gauge-bad); + } + + .gauge-text { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: #fff; + font-weight: bold; + text-shadow: 1px 1px 2px #000; + } +`; + +export const tableStyles: CSSResult = css` + .table-container { + overflow-x: auto; + } + + .data-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + + .data-table th, + .data-table td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid var(--sw-border); + } + + .data-table th { + background: var(--sw-bg-input); + color: var(--sw-text-cyan); + cursor: pointer; + user-select: none; + white-space: nowrap; + } + + .data-table th:hover { + background: #252525; + } + + .data-table th .sort-icon { + margin-left: 5px; + opacity: 0.5; + } + + .data-table th.sorted .sort-icon { + opacity: 1; + color: var(--sw-text-primary); + } + + .data-table tr:hover { + background: #151515; + } + + .data-table td { + color: #ccc; + } + + .data-table td.url { + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .data-table td.num { + text-align: right; + color: var(--sw-text-primary); + } + + .table-controls { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + gap: 10px; + } + + .search-input { + background: var(--sw-bg-input); + border: 1px solid var(--sw-border); + color: var(--sw-text-primary); + padding: 6px 10px; + font-family: inherit; + font-size: 12px; + width: 250px; + } + + .search-input:focus { + outline: none; + border-color: var(--sw-border-active); + } + + .table-info { + color: var(--sw-text-secondary); + font-size: 12px; + } + + .hit-rate-bar { + width: 60px; + height: 10px; + background: var(--sw-bg-input); + border: 1px solid var(--sw-border); + display: inline-block; + vertical-align: middle; + margin-right: 6px; + } + + .hit-rate-fill { + height: 100%; + } + + .hit-rate-fill.good { + background: var(--sw-gauge-good); + } + + .hit-rate-fill.warning { + background: var(--sw-gauge-warning); + } + + .hit-rate-fill.bad { + background: var(--sw-gauge-bad); + } +`; + +export const buttonStyles: CSSResult = css` + .btn { + background: var(--sw-bg-input); + border: 1px solid var(--sw-border-active); + color: var(--sw-text-primary); + padding: 8px 16px; + cursor: pointer; + font-family: inherit; + font-size: 12px; + transition: all 0.2s ease; + } + + .btn:hover { + background: var(--sw-text-primary); + color: #000; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-row { + display: flex; + justify-content: flex-end; + margin-top: 10px; + } +`; + +export const speedtestStyles: CSSResult = css` + .online-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + margin-bottom: 10px; + border-bottom: 1px dashed var(--sw-border); + } + + .online-dot { + width: 12px; + height: 12px; + border-radius: 50%; + transition: background-color 0.3s ease; + } + + .online-dot.online { + background: var(--sw-text-primary); + box-shadow: 0 0 8px rgba(0, 255, 0, 0.5); + } + + .online-dot.offline { + background: var(--sw-text-error); + box-shadow: 0 0 8px rgba(255, 68, 68, 0.5); + } + + .speed-bar { + height: 8px; + background: var(--sw-bg-input); + border: 1px solid var(--sw-border); + margin: 4px 0; + } + + .speed-fill { + height: 100%; + background: var(--sw-gauge-good); + transition: width 0.5s ease; + } +`; diff --git a/ts_swdash/sw-dash-table.ts b/ts_swdash/sw-dash-table.ts new file mode 100644 index 0000000..2bb9640 --- /dev/null +++ b/ts_swdash/sw-dash-table.ts @@ -0,0 +1,173 @@ +import { LitElement, html, css, property, state, customElement } from './plugins.js'; +import type { CSSResult, TemplateResult } from './plugins.js'; +import { sharedStyles, tableStyles } from './sw-dash-styles.js'; + +export interface IColumnConfig { + key: string; + label: string; + sortable?: boolean; + formatter?: (value: any, row: any) => string; + className?: string; +} + +/** + * Base sortable table component for sw-dash + */ +@customElement('sw-dash-table') +export class SwDashTable extends LitElement { + public static styles: CSSResult[] = [ + sharedStyles, + tableStyles, + css` + :host { + display: block; + } + ` + ]; + + @property({ type: Array }) accessor columns: IColumnConfig[] = []; + @property({ type: Array }) accessor data: any[] = []; + @property({ type: String }) accessor filterPlaceholder = 'Filter...'; + @property({ type: String }) accessor infoLabel = 'items'; + + @state() accessor sortColumn = ''; + @state() accessor sortDirection: 'asc' | 'desc' = 'desc'; + @state() accessor filterText = ''; + + // Utility formatters + static formatNumber(n: number): string { + return n.toLocaleString(); + } + + static formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + static formatTimestamp(ts: number): string { + if (!ts || ts === 0) return 'never'; + const ago = Date.now() - ts; + if (ago < 60000) return Math.floor(ago / 1000) + 's ago'; + if (ago < 3600000) return Math.floor(ago / 60000) + 'm ago'; + if (ago < 86400000) return Math.floor(ago / 3600000) + 'h ago'; + return new Date(ts).toLocaleDateString(); + } + + static getGaugeClass(rate: number): string { + if (rate >= 80) return 'good'; + if (rate >= 50) return 'warning'; + return 'bad'; + } + + private handleSort(column: string): void { + if (this.sortColumn === column) { + this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.sortColumn = column; + this.sortDirection = 'desc'; + } + } + + private handleFilter(e: Event): void { + this.filterText = (e.target as HTMLInputElement).value; + } + + private getSortedFilteredData(): any[] { + let result = [...this.data]; + + // Filter + if (this.filterText) { + const search = this.filterText.toLowerCase(); + result = result.filter(row => { + return this.columns.some(col => { + const val = row[col.key]; + if (val == null) return false; + return String(val).toLowerCase().includes(search); + }); + }); + } + + // Sort + if (this.sortColumn) { + result.sort((a, b) => { + let valA = a[this.sortColumn]; + let valB = b[this.sortColumn]; + if (typeof valA === 'string') valA = valA.toLowerCase(); + if (typeof valB === 'string') valB = valB.toLowerCase(); + if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1; + if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1; + return 0; + }); + } + + return result; + } + + private renderHitRateBar(rate: number): TemplateResult { + const cls = SwDashTable.getGaugeClass(rate); + return html` + + + ${rate}% + `; + } + + protected renderCellValue(value: any, row: any, column: IColumnConfig): any { + if (column.formatter) { + return column.formatter(value, row); + } + // Special handling for hitRate + if (column.key === 'hitRate') { + return this.renderHitRateBar(value); + } + return value; + } + + public render(): TemplateResult { + const sortedData = this.getSortedFilteredData(); + + return html` +
+ + ${sortedData.length} of ${this.data.length} ${this.infoLabel} +
+
+ + + + ${this.columns.map(col => html` + + `)} + + + + ${sortedData.map(row => html` + + ${this.columns.map(col => html` + + `)} + + `)} + +
+ ${col.label} + ${col.sortable !== false ? html` + ${this.sortColumn === col.key && this.sortDirection === 'asc' ? '^' : 'v'} + ` : ''} +
${this.renderCellValue(row[col.key], row, col)}
+
+ `; + } +} diff --git a/ts_swdash/sw-dash-types.ts b/ts_swdash/sw-dash-types.ts new file mode 100644 index 0000000..11c9cbf --- /dev/null +++ b/ts_swdash/sw-dash-types.ts @@ -0,0 +1,52 @@ +import { LitElement, html, css, property, customElement } from './plugins.js'; +import type { CSSResult, TemplateResult } from './plugins.js'; +import { sharedStyles, tableStyles } from './sw-dash-styles.js'; +import { SwDashTable } from './sw-dash-table.js'; +import type { IColumnConfig } from './sw-dash-table.js'; + +export interface IContentTypeStats { + contentType: string; + totalResources: number; + totalSize: number; + totalHits: number; + totalMisses: number; + hitRate: number; +} + +/** + * Content types table view component + */ +@customElement('sw-dash-types') +export class SwDashTypes extends LitElement { + public static styles: CSSResult[] = [ + sharedStyles, + tableStyles, + css` + :host { + display: block; + } + ` + ]; + + @property({ type: Array }) accessor contentTypes: IContentTypeStats[] = []; + + private columns: IColumnConfig[] = [ + { key: 'contentType', label: 'Content Type' }, + { key: 'totalResources', label: 'Resources', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'totalSize', label: 'Total Size', className: 'num', formatter: SwDashTable.formatBytes }, + { key: 'totalHits', label: 'Hits', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'totalMisses', label: 'Misses', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'hitRate', label: 'Hit Rate' }, + ]; + + public render(): TemplateResult { + return html` + + `; + } +} diff --git a/ts_swdash/sw-dash-urls.ts b/ts_swdash/sw-dash-urls.ts new file mode 100644 index 0000000..64a028c --- /dev/null +++ b/ts_swdash/sw-dash-urls.ts @@ -0,0 +1,66 @@ +import { LitElement, html, css, property, customElement } from './plugins.js'; +import type { CSSResult, TemplateResult } from './plugins.js'; +import { sharedStyles, tableStyles } from './sw-dash-styles.js'; +import { SwDashTable } from './sw-dash-table.js'; +import type { IColumnConfig } from './sw-dash-table.js'; + +export interface ICachedResource { + url: string; + domain: string; + contentType: string; + size: number; + hitCount: number; + missCount: number; + lastAccessed: number; + cachedAt: number; + hitRate?: number; +} + +/** + * URLs table view component + */ +@customElement('sw-dash-urls') +export class SwDashUrls extends LitElement { + public static styles: CSSResult[] = [ + sharedStyles, + tableStyles, + css` + :host { + display: block; + } + ` + ]; + + @property({ type: Array }) accessor resources: ICachedResource[] = []; + + private columns: IColumnConfig[] = [ + { key: 'url', label: 'URL', className: 'url' }, + { key: 'contentType', label: 'Type' }, + { key: 'size', label: 'Size', className: 'num', formatter: SwDashTable.formatBytes }, + { key: 'hitCount', label: 'Hits', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'missCount', label: 'Misses', className: 'num', formatter: SwDashTable.formatNumber }, + { key: 'hitRate', label: 'Hit Rate' }, + { key: 'lastAccessed', label: 'Last Access', formatter: SwDashTable.formatTimestamp }, + ]; + + private getDataWithHitRate(): ICachedResource[] { + return this.resources.map(r => { + const total = r.hitCount + r.missCount; + return { + ...r, + hitRate: total > 0 ? Math.round((r.hitCount / total) * 100) : 0 + }; + }); + } + + public render(): TemplateResult { + return html` + + `; + } +} diff --git a/ts_web_serviceworker/classes.dashboard.ts b/ts_web_serviceworker/classes.dashboard.ts index 2f75958..de6a31e 100644 --- a/ts_web_serviceworker/classes.dashboard.ts +++ b/ts_web_serviceworker/classes.dashboard.ts @@ -144,15 +144,9 @@ export class DashboardGenerator { } /** - * Generates the complete HTML dashboard page as a SPA with tab navigation + * Generates a minimal HTML shell that loads the Lit-based dashboard bundle */ public generateDashboardHtml(): string { - const metrics = getMetricsCollector(); - const data = metrics.getMetrics(); - const hitRate = metrics.getCacheHitRate(); - const successRate = metrics.getNetworkSuccessRate(); - const resourceCount = metrics.getResourceCount(); - return ` @@ -163,754 +157,17 @@ export class DashboardGenerator { * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0a0a; - color: #00ff00; - font-family: 'Courier New', Courier, monospace; - font-size: 14px; - line-height: 1.4; - padding: 20px; min-height: 100vh; } - .terminal { - max-width: 1200px; - margin: 0 auto; - border: 1px solid #00ff00; - background: #0d0d0d; - box-shadow: 0 0 20px rgba(0, 255, 0, 0.1); - } - .header { - border-bottom: 1px solid #00ff00; - padding: 10px 15px; - display: flex; - justify-content: space-between; - align-items: center; - background: #111; - } - .title { color: #00ff00; font-weight: bold; font-size: 16px; } - .uptime { color: #888; } - - /* Navigation tabs */ - .nav { - display: flex; - background: #111; - border-bottom: 1px solid #333; - padding: 0 10px; - } - .nav-tab { - padding: 10px 20px; - cursor: pointer; - color: #888; - border: none; - background: transparent; - font-family: inherit; - font-size: 13px; - transition: all 0.2s; - border-bottom: 2px solid transparent; - } - .nav-tab:hover { color: #00ff00; } - .nav-tab.active { - color: #00ff00; - border-bottom-color: #00ff00; - background: #1a1a1a; - } - .nav-tab .count { - background: #333; - padding: 1px 6px; - border-radius: 8px; - font-size: 11px; - margin-left: 6px; - } - - .content { padding: 15px; min-height: 400px; } - .view { display: none; } - .view.active { display: block; } - - /* Grid layout for overview */ - .grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); - gap: 15px; - } - .panel { - border: 1px solid #333; - padding: 12px; - background: #0a0a0a; - } - .panel-title { - color: #00ffff; - font-weight: bold; - margin-bottom: 10px; - padding-bottom: 5px; - border-bottom: 1px dashed #333; - } - .row { display: flex; justify-content: space-between; padding: 3px 0; } - .label { color: #888; } - .value { color: #00ff00; } - .value.warning { color: #ffff00; } - .value.error { color: #ff4444; } - .value.success { color: #00ff00; } - - /* Gauge */ - .gauge { margin: 8px 0; } - .gauge-bar { - height: 16px; - background: #1a1a1a; - border: 1px solid #333; - position: relative; - font-size: 12px; - } - .gauge-fill { height: 100%; transition: width 0.3s ease; } - .gauge-fill.good { background: #00aa00; } - .gauge-fill.warning { background: #aaaa00; } - .gauge-fill.bad { background: #aa0000; } - .gauge-text { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - color: #fff; - font-weight: bold; - text-shadow: 1px 1px 2px #000; - } - - /* Sortable table */ - .table-container { overflow-x: auto; } - .data-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; - } - .data-table th, .data-table td { - padding: 8px 10px; - text-align: left; - border-bottom: 1px solid #333; - } - .data-table th { - background: #1a1a1a; - color: #00ffff; - cursor: pointer; - user-select: none; - white-space: nowrap; - } - .data-table th:hover { background: #252525; } - .data-table th .sort-icon { margin-left: 5px; opacity: 0.5; } - .data-table th.sorted .sort-icon { opacity: 1; color: #00ff00; } - .data-table tr:hover { background: #151515; } - .data-table td { color: #ccc; } - .data-table td.url { - max-width: 400px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .data-table td.num { text-align: right; color: #00ff00; } - - /* Search/filter */ - .table-controls { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; - gap: 10px; - } - .search-input { - background: #1a1a1a; - border: 1px solid #333; - color: #00ff00; - padding: 6px 10px; - font-family: inherit; - font-size: 12px; - width: 250px; - } - .search-input:focus { outline: none; border-color: #00ff00; } - .table-info { color: #888; font-size: 12px; } - - /* Speed bars */ - .speed-bar { - height: 8px; - background: #1a1a1a; - border: 1px solid #333; - margin: 4px 0; - } - .speed-fill { height: 100%; background: #00aa00; transition: width 0.5s ease; } - - /* Online indicator */ - .online-indicator { display: flex; align-items: center; gap: 8px; padding: 8px 0; margin-bottom: 10px; border-bottom: 1px dashed #333; } - .online-dot { width: 12px; height: 12px; border-radius: 50%; transition: background-color 0.3s ease; } - .online-dot.online { background: #00ff00; box-shadow: 0 0 8px rgba(0, 255, 0, 0.5); } - .online-dot.offline { background: #ff4444; box-shadow: 0 0 8px rgba(255, 68, 68, 0.5); } - - /* Buttons */ - .btn { - background: #1a1a1a; - border: 1px solid #00ff00; - color: #00ff00; - padding: 8px 16px; - cursor: pointer; - font-family: inherit; - font-size: 12px; - transition: all 0.2s ease; - } - .btn:hover { background: #00ff00; color: #000; } - .btn:disabled { opacity: 0.5; cursor: not-allowed; } - .btn-row { display: flex; justify-content: flex-end; margin-top: 10px; } - - /* Footer */ - .footer { - border-top: 1px solid #00ff00; - padding: 10px 15px; - display: flex; - justify-content: space-between; - align-items: center; - background: #111; - font-size: 12px; - } - .refresh-info { color: #888; } - .status { display: flex; align-items: center; gap: 8px; } - .status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #00ff00; - animation: pulse 2s infinite; - } - .prompt { color: #00ff00; } - .cursor { - display: inline-block; - width: 8px; - height: 14px; - background: #00ff00; - animation: blink 1s step-end infinite; - vertical-align: middle; - margin-left: 2px; - } - @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } - @keyframes blink { 50% { opacity: 0; } } - - /* Hit rate bar in tables */ - .hit-rate-bar { - width: 60px; - height: 10px; - background: #1a1a1a; - border: 1px solid #333; - display: inline-block; - vertical-align: middle; - margin-right: 6px; - } - .hit-rate-fill { height: 100%; } - .hit-rate-fill.good { background: #00aa00; } - .hit-rate-fill.warning { background: #aaaa00; } - .hit-rate-fill.bad { background: #aa0000; } -
-
- [SW-DASH] Service Worker Dashboard - Uptime: ${this.formatDuration(data.uptime)} -
- - - -
- -
-
-
-
[ CACHE ]
-
-
-
- ${hitRate}% hit rate -
-
-
Hits:${this.formatNumber(data.cache.hits)}
-
Misses:${this.formatNumber(data.cache.misses)}
-
Errors:${this.formatNumber(data.cache.errors)}
-
From Cache:${this.formatBytes(data.cache.bytesServedFromCache)}
-
Fetched:${this.formatBytes(data.cache.bytesFetched)}
-
Resources:${resourceCount}
-
- -
-
[ NETWORK ]
-
-
-
- ${successRate}% success -
-
-
Total Requests:${this.formatNumber(data.network.totalRequests)}
-
Successful:${this.formatNumber(data.network.successfulRequests)}
-
Failed:${this.formatNumber(data.network.failedRequests)}
-
Timeouts:${this.formatNumber(data.network.timeouts)}
-
Avg Latency:${data.network.averageLatency}ms
-
Transferred:${this.formatBytes(data.network.totalBytesTransferred)}
-
- -
-
[ UPDATES ]
-
Total Checks:${this.formatNumber(data.update.totalChecks)}
-
Successful:${this.formatNumber(data.update.successfulChecks)}
-
Failed:${this.formatNumber(data.update.failedChecks)}
-
Updates Found:${this.formatNumber(data.update.updatesFound)}
-
Updates Applied:${this.formatNumber(data.update.updatesApplied)}
-
Last Check:${this.formatTimestamp(data.update.lastCheckTimestamp)}
-
- -
-
[ CONNECTIONS ]
-
Active Clients:${this.formatNumber(data.connection.connectedClients)}
-
Total Attempts:${this.formatNumber(data.connection.totalConnectionAttempts)}
-
Successful:${this.formatNumber(data.connection.successfulConnections)}
-
Failed:${this.formatNumber(data.connection.failedConnections)}
-
- Started:${this.formatTimestamp(data.startTime)} -
-
- -
-
[ SPEEDTEST ]
-
- - ${data.speedtest.isOnline ? 'Online' : 'Offline'} -
-
Download:${data.speedtest.lastDownloadSpeedMbps.toFixed(2)} Mbps
-
-
Upload:${data.speedtest.lastUploadSpeedMbps.toFixed(2)} Mbps
-
-
Latency:${data.speedtest.lastLatencyMs.toFixed(0)} ms
-
-
-
-
- - -
-
- - Loading... -
-
- - - - - - - - - - - - - -
URL ^Type ^Size ^Hits ^Misses ^Hit Rate ^Last Access ^
-
-
- - -
-
- - Loading... -
-
- - - - - - - - - - - - -
Domain ^Resources ^Total Size ^Hits ^Misses ^Hit Rate ^
-
-
- - -
-
- - Loading... -
-
- - - - - - - - - - - - -
Content Type ^Resources ^Total Size ^Hits ^Misses ^Hit Rate ^
-
-
-
- - -
- - + + `; } - /** - * Format bytes to human-readable string - */ - private formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - - /** - * Format duration to human-readable string - */ - private formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) return `${days}d ${hours % 24}h`; - if (hours > 0) return `${hours}h ${minutes % 60}m`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s`; - return `${seconds}s`; - } - - /** - * Format timestamp to relative time string - */ - private formatTimestamp(ts: number): string { - if (!ts || ts === 0) return 'never'; - const ago = Date.now() - ts; - if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`; - if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`; - if (ago < 86400000) return `${Math.floor(ago / 3600000)}h ago`; - return new Date(ts).toLocaleDateString(); - } - - /** - * Format number with thousands separator - */ - private formatNumber(num: number): string { - return num.toLocaleString(); - } - - /** - * Get gauge class based on percentage - */ - private getGaugeClass(rate: number): string { - if (rate >= 80) return 'good'; - if (rate >= 50) return 'warning'; - return 'bad'; - } } // Export singleton getter