|
|
|
|
@@ -0,0 +1,565 @@
|
|
|
|
|
import { getMetricsCollector, type IServiceWorkerMetrics } from './classes.metrics.js';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Dashboard generator that creates a terminal-like metrics display
|
|
|
|
|
* served directly from the service worker
|
|
|
|
|
*/
|
|
|
|
|
export class DashboardGenerator {
|
|
|
|
|
/**
|
|
|
|
|
* Serves the dashboard HTML page
|
|
|
|
|
*/
|
|
|
|
|
public serveDashboard(): Response {
|
|
|
|
|
return new Response(this.generateDashboardHtml(), {
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'no-store',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Serves the metrics JSON endpoint
|
|
|
|
|
*/
|
|
|
|
|
public serveMetrics(): Response {
|
|
|
|
|
return new Response(this.generateMetricsJson(), {
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Cache-Control': 'no-store',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generates JSON metrics response
|
|
|
|
|
*/
|
|
|
|
|
public generateMetricsJson(): string {
|
|
|
|
|
const metrics = getMetricsCollector();
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
...metrics.getMetrics(),
|
|
|
|
|
cacheHitRate: metrics.getCacheHitRate(),
|
|
|
|
|
networkSuccessRate: metrics.getNetworkSuccessRate(),
|
|
|
|
|
summary: metrics.getSummary(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generates the complete HTML dashboard page with terminal-like styling
|
|
|
|
|
*/
|
|
|
|
|
public generateDashboardHtml(): string {
|
|
|
|
|
const metrics = getMetricsCollector();
|
|
|
|
|
const data = metrics.getMetrics();
|
|
|
|
|
const hitRate = metrics.getCacheHitRate();
|
|
|
|
|
const successRate = metrics.getNetworkSuccessRate();
|
|
|
|
|
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>SW Dashboard</title>
|
|
|
|
|
<style>
|
|
|
|
|
* {
|
|
|
|
|
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: 900px;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.content {
|
|
|
|
|
padding: 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(380px, 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 {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
0%, 100% { opacity: 1; }
|
|
|
|
|
50% { opacity: 0.5; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ascii-bar {
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
letter-spacing: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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 blink {
|
|
|
|
|
50% { opacity: 0; }
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="terminal">
|
|
|
|
|
<div class="header">
|
|
|
|
|
<span class="title">[SW-DASH] Service Worker Metrics</span>
|
|
|
|
|
<span class="uptime" id="uptime">Uptime: ${this.formatDuration(data.uptime)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="content">
|
|
|
|
|
<div class="grid">
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-title">[ CACHE ]</div>
|
|
|
|
|
<div class="gauge">
|
|
|
|
|
<div class="gauge-bar">
|
|
|
|
|
<div class="gauge-fill ${this.getGaugeClass(hitRate)}" style="width: ${hitRate}%"></div>
|
|
|
|
|
<span class="gauge-text">${hitRate}% hit rate</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Hits:</span>
|
|
|
|
|
<span class="value success" id="cache-hits">${this.formatNumber(data.cache.hits)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Misses:</span>
|
|
|
|
|
<span class="value warning" id="cache-misses">${this.formatNumber(data.cache.misses)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Errors:</span>
|
|
|
|
|
<span class="value ${data.cache.errors > 0 ? 'error' : ''}" id="cache-errors">${this.formatNumber(data.cache.errors)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">From Cache:</span>
|
|
|
|
|
<span class="value" id="cache-bytes">${this.formatBytes(data.cache.bytesServedFromCache)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Fetched:</span>
|
|
|
|
|
<span class="value" id="cache-fetched">${this.formatBytes(data.cache.bytesFetched)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Avg Response:</span>
|
|
|
|
|
<span class="value" id="cache-response">${data.cache.averageResponseTime}ms</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-title">[ NETWORK ]</div>
|
|
|
|
|
<div class="gauge">
|
|
|
|
|
<div class="gauge-bar">
|
|
|
|
|
<div class="gauge-fill ${this.getGaugeClass(successRate)}" style="width: ${successRate}%"></div>
|
|
|
|
|
<span class="gauge-text">${successRate}% success</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Total Requests:</span>
|
|
|
|
|
<span class="value" id="net-total">${this.formatNumber(data.network.totalRequests)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Successful:</span>
|
|
|
|
|
<span class="value success" id="net-success">${this.formatNumber(data.network.successfulRequests)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Failed:</span>
|
|
|
|
|
<span class="value ${data.network.failedRequests > 0 ? 'error' : ''}" id="net-failed">${this.formatNumber(data.network.failedRequests)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Timeouts:</span>
|
|
|
|
|
<span class="value ${data.network.timeouts > 0 ? 'warning' : ''}" id="net-timeouts">${this.formatNumber(data.network.timeouts)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Avg Latency:</span>
|
|
|
|
|
<span class="value" id="net-latency">${data.network.averageLatency}ms</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Transferred:</span>
|
|
|
|
|
<span class="value" id="net-bytes">${this.formatBytes(data.network.totalBytesTransferred)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-title">[ UPDATES ]</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Total Checks:</span>
|
|
|
|
|
<span class="value" id="upd-checks">${this.formatNumber(data.update.totalChecks)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Successful:</span>
|
|
|
|
|
<span class="value success" id="upd-success">${this.formatNumber(data.update.successfulChecks)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Failed:</span>
|
|
|
|
|
<span class="value ${data.update.failedChecks > 0 ? 'error' : ''}" id="upd-failed">${this.formatNumber(data.update.failedChecks)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Updates Found:</span>
|
|
|
|
|
<span class="value" id="upd-found">${this.formatNumber(data.update.updatesFound)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Updates Applied:</span>
|
|
|
|
|
<span class="value success" id="upd-applied">${this.formatNumber(data.update.updatesApplied)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Last Check:</span>
|
|
|
|
|
<span class="value" id="upd-last-check">${this.formatTimestamp(data.update.lastCheckTimestamp)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Last Update:</span>
|
|
|
|
|
<span class="value" id="upd-last-update">${this.formatTimestamp(data.update.lastUpdateTimestamp)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="panel-title">[ CONNECTIONS ]</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Active Clients:</span>
|
|
|
|
|
<span class="value success" id="conn-clients">${this.formatNumber(data.connection.connectedClients)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Total Attempts:</span>
|
|
|
|
|
<span class="value" id="conn-attempts">${this.formatNumber(data.connection.totalConnectionAttempts)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Successful:</span>
|
|
|
|
|
<span class="value success" id="conn-success">${this.formatNumber(data.connection.successfulConnections)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<span class="label">Failed:</span>
|
|
|
|
|
<span class="value ${data.connection.failedConnections > 0 ? 'error' : ''}" id="conn-failed">${this.formatNumber(data.connection.failedConnections)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row" style="margin-top: 15px; padding-top: 10px; border-top: 1px dashed #333;">
|
|
|
|
|
<span class="label">Started:</span>
|
|
|
|
|
<span class="value" id="start-time">${this.formatTimestamp(data.startTime)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="footer">
|
|
|
|
|
<span class="refresh-info">
|
|
|
|
|
<span class="prompt">$</span> Last refresh: <span id="last-refresh">${new Date().toLocaleTimeString()}</span><span class="cursor"></span>
|
|
|
|
|
</span>
|
|
|
|
|
<div class="status">
|
|
|
|
|
<span class="status-dot"></span>
|
|
|
|
|
<span>Auto-refresh: 2s</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
function formatNumber(num) {
|
|
|
|
|
return num.toLocaleString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatBytes(bytes) {
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDuration(ms) {
|
|
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTimestamp(ts) {
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getGaugeClass(rate) {
|
|
|
|
|
if (rate >= 80) return 'good';
|
|
|
|
|
if (rate >= 50) return 'warning';
|
|
|
|
|
return 'bad';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateDashboard(data) {
|
|
|
|
|
// Uptime
|
|
|
|
|
document.getElementById('uptime').textContent = 'Uptime: ' + formatDuration(data.uptime);
|
|
|
|
|
|
|
|
|
|
// Cache
|
|
|
|
|
document.getElementById('cache-hits').textContent = formatNumber(data.cache.hits);
|
|
|
|
|
document.getElementById('cache-misses').textContent = formatNumber(data.cache.misses);
|
|
|
|
|
document.getElementById('cache-errors').textContent = formatNumber(data.cache.errors);
|
|
|
|
|
document.getElementById('cache-bytes').textContent = formatBytes(data.cache.bytesServedFromCache);
|
|
|
|
|
document.getElementById('cache-fetched').textContent = formatBytes(data.cache.bytesFetched);
|
|
|
|
|
document.getElementById('cache-response').textContent = data.cache.averageResponseTime + 'ms';
|
|
|
|
|
|
|
|
|
|
// Update cache gauge
|
|
|
|
|
const cacheGauge = document.querySelector('.panel:nth-child(1) .gauge-fill');
|
|
|
|
|
cacheGauge.style.width = data.cacheHitRate + '%';
|
|
|
|
|
cacheGauge.className = 'gauge-fill ' + getGaugeClass(data.cacheHitRate);
|
|
|
|
|
document.querySelector('.panel:nth-child(1) .gauge-text').textContent = data.cacheHitRate + '% hit rate';
|
|
|
|
|
|
|
|
|
|
// Network
|
|
|
|
|
document.getElementById('net-total').textContent = formatNumber(data.network.totalRequests);
|
|
|
|
|
document.getElementById('net-success').textContent = formatNumber(data.network.successfulRequests);
|
|
|
|
|
document.getElementById('net-failed').textContent = formatNumber(data.network.failedRequests);
|
|
|
|
|
document.getElementById('net-timeouts').textContent = formatNumber(data.network.timeouts);
|
|
|
|
|
document.getElementById('net-latency').textContent = data.network.averageLatency + 'ms';
|
|
|
|
|
document.getElementById('net-bytes').textContent = formatBytes(data.network.totalBytesTransferred);
|
|
|
|
|
|
|
|
|
|
// Update network gauge
|
|
|
|
|
const netGauge = document.querySelector('.panel:nth-child(2) .gauge-fill');
|
|
|
|
|
netGauge.style.width = data.networkSuccessRate + '%';
|
|
|
|
|
netGauge.className = 'gauge-fill ' + getGaugeClass(data.networkSuccessRate);
|
|
|
|
|
document.querySelector('.panel:nth-child(2) .gauge-text').textContent = data.networkSuccessRate + '% success';
|
|
|
|
|
|
|
|
|
|
// Updates
|
|
|
|
|
document.getElementById('upd-checks').textContent = formatNumber(data.update.totalChecks);
|
|
|
|
|
document.getElementById('upd-success').textContent = formatNumber(data.update.successfulChecks);
|
|
|
|
|
document.getElementById('upd-failed').textContent = formatNumber(data.update.failedChecks);
|
|
|
|
|
document.getElementById('upd-found').textContent = formatNumber(data.update.updatesFound);
|
|
|
|
|
document.getElementById('upd-applied').textContent = formatNumber(data.update.updatesApplied);
|
|
|
|
|
document.getElementById('upd-last-check').textContent = formatTimestamp(data.update.lastCheckTimestamp);
|
|
|
|
|
document.getElementById('upd-last-update').textContent = formatTimestamp(data.update.lastUpdateTimestamp);
|
|
|
|
|
|
|
|
|
|
// Connections
|
|
|
|
|
document.getElementById('conn-clients').textContent = formatNumber(data.connection.connectedClients);
|
|
|
|
|
document.getElementById('conn-attempts').textContent = formatNumber(data.connection.totalConnectionAttempts);
|
|
|
|
|
document.getElementById('conn-success').textContent = formatNumber(data.connection.successfulConnections);
|
|
|
|
|
document.getElementById('conn-failed').textContent = formatNumber(data.connection.failedConnections);
|
|
|
|
|
document.getElementById('start-time').textContent = formatTimestamp(data.startTime);
|
|
|
|
|
|
|
|
|
|
// Last refresh
|
|
|
|
|
document.getElementById('last-refresh').textContent = new Date().toLocaleTimeString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-refresh every 2 seconds
|
|
|
|
|
setInterval(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/sw-dash/metrics');
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
updateDashboard(data);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch metrics:', err);
|
|
|
|
|
}
|
|
|
|
|
}, 2000);
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
let dashboardInstance: DashboardGenerator | null = null;
|
|
|
|
|
export const getDashboardGenerator = (): DashboardGenerator => {
|
|
|
|
|
if (!dashboardInstance) {
|
|
|
|
|
dashboardInstance = new DashboardGenerator();
|
|
|
|
|
}
|
|
|
|
|
return dashboardInstance;
|
|
|
|
|
};
|