dd Statistics tab with real-time native host monitoring
CI / Build & Test (push) Successful in 2m55s
CI / Build & Test (push) Successful in 2m55s
- Add getProcessStats message handler to native host returning process memory (rss, heap used/total, external), PID, uptime, OS memory, CPU load averages, core count, platform/arch, and tunnel state counts - Add stats-tracker.js to background SW tracking cumulative connections, reconnect count, last disconnect time, and per-minute connection rate ring buffer (30 buckets) for sparkline display - Include statsHistory and activeConnections array in all getState responses - Add Statistics dashboard page with six sections: Native Host Process, System Resources, Tunnel Health, Proxy Traffic, Peer Connections (with SVG sparkline), and Extension & Proxy Info - Preserve checkbox selection across table re-renders on all bulk-action tables (virtual hosts, servers, service tunnels) - Add theme-aware custom checkbox styling for bulk selection rows using design system tokens, with white checkmark override for light theme
This commit is contained in:
@@ -16,6 +16,7 @@ importScripts(
|
|||||||
'background/logs.js',
|
'background/logs.js',
|
||||||
'background/state.js',
|
'background/state.js',
|
||||||
'background/proxy.js',
|
'background/proxy.js',
|
||||||
|
'background/stats-tracker.js',
|
||||||
'background/native-messaging.js',
|
'background/native-messaging.js',
|
||||||
'background/tab-lifecycle.js',
|
'background/tab-lifecycle.js',
|
||||||
'background/message-router.js'
|
'background/message-router.js'
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
sshConnections: payload.sshConnections || [],
|
sshConnections: payload.sshConnections || [],
|
||||||
rdpConnections: payload.rdpConnections || [],
|
rdpConnections: payload.rdpConnections || [],
|
||||||
trafficStats: payload.trafficStats || null,
|
trafficStats: payload.trafficStats || null,
|
||||||
stats: { ...extensionState.stats, ...payload.stats }
|
stats: { ...extensionState.stats, ...payload.stats },
|
||||||
|
statsHistory: getStatsHistory(),
|
||||||
|
activeConnections: Array.from(activeConnections.values()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -139,7 +141,9 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
proxyPort: null,
|
proxyPort: null,
|
||||||
connectProxyPort: null,
|
connectProxyPort: null,
|
||||||
caInstalled: false,
|
caInstalled: false,
|
||||||
stats: extensionState.stats
|
stats: extensionState.stats,
|
||||||
|
statsHistory: getStatsHistory(),
|
||||||
|
activeConnections: Array.from(activeConnections.values()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -158,7 +162,9 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
proxyPort: null,
|
proxyPort: null,
|
||||||
connectProxyPort: null,
|
connectProxyPort: null,
|
||||||
caInstalled: false,
|
caInstalled: false,
|
||||||
stats: extensionState.stats
|
stats: extensionState.stats,
|
||||||
|
statsHistory: getStatsHistory(),
|
||||||
|
activeConnections: Array.from(activeConnections.values()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -176,7 +182,9 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
proxyPort: null,
|
proxyPort: null,
|
||||||
connectProxyPort: null,
|
connectProxyPort: null,
|
||||||
caInstalled: false,
|
caInstalled: false,
|
||||||
stats: extensionState.stats
|
stats: extensionState.stats,
|
||||||
|
statsHistory: getStatsHistory(),
|
||||||
|
activeConnections: Array.from(activeConnections.values()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function connect() {
|
|||||||
peerKey: peerInfo.publicKey || '',
|
peerKey: peerInfo.publicKey || '',
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
});
|
});
|
||||||
|
onConnection();
|
||||||
updateExtensionState();
|
updateExtensionState();
|
||||||
} else if (msg.event === 'error') {
|
} else if (msg.event === 'error') {
|
||||||
const connId = payload.connId;
|
const connId = payload.connId;
|
||||||
@@ -229,6 +230,7 @@ function connect() {
|
|||||||
log('Native host disconnected');
|
log('Native host disconnected');
|
||||||
extensionState.hostConnected = false;
|
extensionState.hostConnected = false;
|
||||||
activeConnections.clear();
|
activeConnections.clear();
|
||||||
|
onDisconnect();
|
||||||
updateExtensionState();
|
updateExtensionState();
|
||||||
clearProxy();
|
clearProxy();
|
||||||
port = null;
|
port = null;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Extension-side statistics tracker.
|
||||||
|
* Maintains a cumulative connection counter, reconnect history, and a
|
||||||
|
* per-minute connection-rate ring buffer (last 30 buckets) for sparkline display.
|
||||||
|
* Depends on nothing — must be loaded before native-messaging.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BUCKET_INTERVAL_MS = 60 * 1000; // 1 minute
|
||||||
|
const MAX_BUCKETS = 30;
|
||||||
|
|
||||||
|
const _statsHistory = {
|
||||||
|
totalConnectionsEver: 0,
|
||||||
|
reconnectCount: 0,
|
||||||
|
lastDisconnectAt: null,
|
||||||
|
connectionRateHistory: [], // [{t: epochMs, count: number}] newest last
|
||||||
|
_bucketCount: 0,
|
||||||
|
_bucketStart: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function _flushBucket() {
|
||||||
|
const now = Date.now();
|
||||||
|
// How many full minutes have elapsed since the bucket started?
|
||||||
|
const elapsed = now - _statsHistory._bucketStart;
|
||||||
|
const fullBuckets = Math.floor(elapsed / BUCKET_INTERVAL_MS);
|
||||||
|
if (fullBuckets === 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < fullBuckets; i++) {
|
||||||
|
const t = _statsHistory._bucketStart + i * BUCKET_INTERVAL_MS;
|
||||||
|
// Only the first bucket gets the accumulated count; the rest are zero (idle minutes)
|
||||||
|
_statsHistory.connectionRateHistory.push({ t, count: i === 0 ? _statsHistory._bucketCount : 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim to last MAX_BUCKETS entries
|
||||||
|
if (_statsHistory.connectionRateHistory.length > MAX_BUCKETS) {
|
||||||
|
_statsHistory.connectionRateHistory = _statsHistory.connectionRateHistory.slice(-MAX_BUCKETS);
|
||||||
|
}
|
||||||
|
|
||||||
|
_statsHistory._bucketCount = 0;
|
||||||
|
_statsHistory._bucketStart = _statsHistory._bucketStart + fullBuckets * BUCKET_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConnection() {
|
||||||
|
_flushBucket();
|
||||||
|
_statsHistory._bucketCount++;
|
||||||
|
_statsHistory.totalConnectionsEver++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisconnect() {
|
||||||
|
_statsHistory.lastDisconnectAt = Date.now();
|
||||||
|
_statsHistory.reconnectCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatsHistory() {
|
||||||
|
_flushBucket();
|
||||||
|
return {
|
||||||
|
totalConnectionsEver: _statsHistory.totalConnectionsEver,
|
||||||
|
reconnectCount: _statsHistory.reconnectCount,
|
||||||
|
lastDisconnectAt: _statsHistory.lastDisconnectAt,
|
||||||
|
connectionRateHistory: _statsHistory.connectionRateHistory.slice(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const PAGE_TITLES = {
|
|||||||
rdp: 'Remote Desktop',
|
rdp: 'Remote Desktop',
|
||||||
backups: 'Backups',
|
backups: 'Backups',
|
||||||
logs: 'Logs',
|
logs: 'Logs',
|
||||||
|
stats: 'Statistics',
|
||||||
settings: 'Settings'
|
settings: 'Settings'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Logs
|
Logs
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" data-page="stats">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||||
|
</svg>
|
||||||
|
Statistics
|
||||||
|
</div>
|
||||||
<div class="nav-item" data-page="settings">
|
<div class="nav-item" data-page="settings">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
@@ -552,6 +560,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Statistics page ────────────────────────── -->
|
||||||
|
<div class="page" id="page-stats">
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Settings page ──────────────────────────── -->
|
<!-- ── Settings page ──────────────────────────── -->
|
||||||
<div class="page" id="page-settings">
|
<div class="page" id="page-settings">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -1286,6 +1298,7 @@
|
|||||||
<script src="pages/logs.js"></script>
|
<script src="pages/logs.js"></script>
|
||||||
<script src="pages/ssh.js"></script>
|
<script src="pages/ssh.js"></script>
|
||||||
<script src="pages/rdp.js"></script>
|
<script src="pages/rdp.js"></script>
|
||||||
|
<script src="pages/stats.js"></script>
|
||||||
|
|
||||||
<!-- Orchestration -->
|
<!-- Orchestration -->
|
||||||
<script src="refresh.js"></script>
|
<script src="refresh.js"></script>
|
||||||
|
|||||||
@@ -242,4 +242,5 @@ function setupEvents() {
|
|||||||
setupServerEvents();
|
setupServerEvents();
|
||||||
setupServiceTunnelEvents();
|
setupServiceTunnelEvents();
|
||||||
setupLogsEvents();
|
setupLogsEvents();
|
||||||
|
setupStatsEvents();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* Statistics page — real-time native host process metrics, system resources,
|
||||||
|
* tunnel health, traffic stats, and peer connection history.
|
||||||
|
* Depends on: core/utils.js ($, escapeHtml, truncate), core/messaging.js (sendToNative),
|
||||||
|
* ui/toast.js (showToast)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _fmtBytes(n) {
|
||||||
|
if (n == null || isNaN(n)) return '—';
|
||||||
|
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB';
|
||||||
|
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
|
||||||
|
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||||
|
return n + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtUptime(ms) {
|
||||||
|
if (!ms || ms < 0) return '—';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${sec}s`;
|
||||||
|
if (m > 0) return `${m}m ${sec}s`;
|
||||||
|
return `${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtTime(epochMs) {
|
||||||
|
if (!epochMs) return '—';
|
||||||
|
return new Date(epochMs).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmtDuration(createdAt) {
|
||||||
|
if (!createdAt) return '—';
|
||||||
|
return _fmtUptime(Date.now() - createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _memBar(used, total) {
|
||||||
|
const pct = total ? Math.min(100, Math.round((used / total) * 100)) : 0;
|
||||||
|
const color = pct > 85 ? 'var(--red)' : pct > 65 ? 'var(--amber)' : 'var(--cyan)';
|
||||||
|
return `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||||
|
<div style="flex:1;height:6px;background:var(--border);border-radius:99px;overflow:hidden;">
|
||||||
|
<div style="width:${pct}%;height:100%;background:${color};border-radius:99px;transition:width 0.4s ease;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:11px;color:var(--text3);min-width:32px;text-align:right;">${pct}%</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sparkline(history) {
|
||||||
|
if (!history || history.length < 2) {
|
||||||
|
return `<span style="font-size:11px;color:var(--text4);">Not enough data yet</span>`;
|
||||||
|
}
|
||||||
|
const counts = history.map(b => b.count);
|
||||||
|
const w = 240, h = 44;
|
||||||
|
const max = Math.max(...counts, 1);
|
||||||
|
const pts = counts.map((v, i) => {
|
||||||
|
const x = (i / (counts.length - 1)) * w;
|
||||||
|
const y = h - 4 - (v / max) * (h - 8);
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}).join(' ');
|
||||||
|
const fillPts = `0,${h} ` + pts + ` ${w},${h}`;
|
||||||
|
return `
|
||||||
|
<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" style="overflow:visible;display:block;">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sparkGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="var(--cyan)" stop-opacity="0.25"/>
|
||||||
|
<stop offset="100%" stop-color="var(--cyan)" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<polygon points="${fillPts}" fill="url(#sparkGrad)"/>
|
||||||
|
<polyline points="${pts}" fill="none" stroke="var(--cyan)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _statChip(label, value, color) {
|
||||||
|
return `
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 14px;background:var(--elevated);border:1px solid var(--border);border-radius:var(--radius-sm);min-width:72px;">
|
||||||
|
<span style="font-size:15px;font-weight:700;color:${color || 'var(--text)'};">${value}</span>
|
||||||
|
<span style="font-size:10px;color:var(--text4);text-transform:uppercase;letter-spacing:0.06em;">${label}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sectionCard(title, iconSvg, content) {
|
||||||
|
return `
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div class="card-header" style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="color:var(--cyan);display:flex;">${iconSvg}</span>
|
||||||
|
<span class="card-title">${title}</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 16px 16px;">${content}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section renderers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderHostProcess(state, ps) {
|
||||||
|
const connected = state.hostConnected;
|
||||||
|
const sh = state.statsHistory || {};
|
||||||
|
const swUptime = state.stats?.uptime ? _fmtUptime(Date.now() - state.stats.uptime) : '—';
|
||||||
|
|
||||||
|
let processRows = '';
|
||||||
|
if (connected && ps && ps.process) {
|
||||||
|
const mem = ps.process.memoryUsage;
|
||||||
|
processRows = `
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:var(--text3);margin-bottom:4px;">Heap Used / Total</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:13px;font-weight:600;color:var(--text);">${_fmtBytes(mem.heapUsed)} / ${_fmtBytes(mem.heapTotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;">${_memBar(mem.heapUsed, mem.heapTotal)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:var(--text3);margin-bottom:4px;">RSS (Physical)</div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--text);">${_fmtBytes(mem.rss)}</div>
|
||||||
|
<div style="font-size:11px;color:var(--text4);margin-top:2px;">External: ${_fmtBytes(mem.external)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:4px;">
|
||||||
|
${_statChip('PID', ps.process.pid, 'var(--text2)')}
|
||||||
|
${_statChip('Uptime', _fmtUptime(ps.process.uptimeMs), 'var(--cyan)')}
|
||||||
|
${_statChip('SW Uptime', swUptime, 'var(--text2)')}
|
||||||
|
</div>`;
|
||||||
|
} else if (!connected) {
|
||||||
|
processRows = `<div style="color:var(--text4);font-size:13px;padding:8px 0;">Native host is not connected.</div>`;
|
||||||
|
} else {
|
||||||
|
processRows = `<div style="color:var(--text4);font-size:13px;padding:8px 0;">Loading process stats…</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnectInfo = `
|
||||||
|
<div style="display:flex;gap:16px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:var(--text3);">Reconnects</div>
|
||||||
|
<div style="font-size:14px;font-weight:600;color:${(sh.reconnectCount || 0) > 0 ? 'var(--amber)' : 'var(--text)'};">${sh.reconnectCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:var(--text3);">Last Disconnect</div>
|
||||||
|
<div style="font-size:13px;color:var(--text2);">${_fmtTime(sh.lastDisconnectAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const statusBadge = connected
|
||||||
|
? `<span class="badge badge-green" style="margin-left:auto;">Connected</span>`
|
||||||
|
: `<span class="badge" style="margin-left:auto;background:var(--red-dim);color:var(--red);">Disconnected</span>`;
|
||||||
|
|
||||||
|
const icon = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="6" height="6" rx="1"/><rect x="16" y="3" width="6" height="6" rx="1"/><rect x="9" y="15" width="6" height="6" rx="1"/><line x1="5" y1="9" x2="12" y2="15"/><line x1="19" y1="9" x2="12" y2="15"/></svg>`;
|
||||||
|
|
||||||
|
return _sectionCard(
|
||||||
|
`<span style="display:flex;align-items:center;gap:8px;width:100%;">Native Host Process ${statusBadge}</span>`,
|
||||||
|
icon,
|
||||||
|
processRows + reconnectInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderSystemResources(ps) {
|
||||||
|
if (!ps || !ps.os) {
|
||||||
|
return _sectionCard('System Resources',
|
||||||
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`,
|
||||||
|
`<div style="color:var(--text4);font-size:13px;padding:8px 0;">Waiting for native host…</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const o = ps.os;
|
||||||
|
const usedMem = o.totalMemory - o.freeMemory;
|
||||||
|
const loadColors = o.loadAvg.map(l => l > (o.cpuCount * 0.8) ? 'var(--red)' : l > (o.cpuCount * 0.5) ? 'var(--amber)' : 'var(--green)');
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
||||||
|
<span style="font-size:12px;color:var(--text3);">System Memory</span>
|
||||||
|
<span style="font-size:12px;color:var(--text2);">${_fmtBytes(usedMem)} / ${_fmtBytes(o.totalMemory)}</span>
|
||||||
|
</div>
|
||||||
|
${_memBar(usedMem, o.totalMemory)}
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<div style="font-size:12px;color:var(--text3);margin-bottom:8px;">CPU Load Average</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
${_statChip('1 min', o.loadAvg[0]?.toFixed(2) ?? '—', loadColors[0])}
|
||||||
|
${_statChip('5 min', o.loadAvg[1]?.toFixed(2) ?? '—', loadColors[1])}
|
||||||
|
${_statChip('15 min', o.loadAvg[2]?.toFixed(2) ?? '—', loadColors[2])}
|
||||||
|
${_statChip('Cores', o.cpuCount, 'var(--text2)')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:16px;font-size:12px;color:var(--text3);">
|
||||||
|
<span>Platform: <strong style="color:var(--text2);">${escapeHtml(o.platform || '—')}</strong></span>
|
||||||
|
<span>Arch: <strong style="color:var(--text2);">${escapeHtml(o.arch || '—')}</strong></span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return _sectionCard('System Resources',
|
||||||
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderTunnelHealth(state, ps) {
|
||||||
|
const servers = state.servers || [];
|
||||||
|
const vhosts = state.virtualHosts || [];
|
||||||
|
const svcTunnels = state.serviceTunnels || [];
|
||||||
|
|
||||||
|
function stateCounts(arr) {
|
||||||
|
const c = { ready: 0, error: 0, closed: 0, connecting: 0 };
|
||||||
|
for (const t of arr) {
|
||||||
|
const s = t.state || 'connecting';
|
||||||
|
if (s in c) c[s]++; else c.connecting++;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateRow(label, arr) {
|
||||||
|
const c = stateCounts(arr);
|
||||||
|
const total = arr.length;
|
||||||
|
if (total === 0) return `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);">
|
||||||
|
<span style="font-size:13px;color:var(--text2);">${label}</span>
|
||||||
|
<span style="font-size:12px;color:var(--text4);">None</span>
|
||||||
|
</div>`;
|
||||||
|
return `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);gap:8px;flex-wrap:wrap;">
|
||||||
|
<span style="font-size:13px;font-weight:500;color:var(--text);">${label} <span style="color:var(--text3);font-weight:400;">(${total})</span></span>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
${c.ready ? `<span class="badge badge-green">${c.ready} ready</span>` : ''}
|
||||||
|
${c.connecting ? `<span class="badge" style="background:var(--amber-dim);color:var(--amber);">${c.connecting} connecting</span>` : ''}
|
||||||
|
${c.error ? `<span class="badge" style="background:var(--red-dim);color:var(--red);">${c.error} error</span>` : ''}
|
||||||
|
${c.closed ? `<span class="badge badge-neutral">${c.closed} closed</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate byState from native host if available
|
||||||
|
let aggregateRow = '';
|
||||||
|
if (ps && ps.tunnels && ps.tunnels.byState) {
|
||||||
|
const b = ps.tunnels.byState;
|
||||||
|
const total = (ps.tunnels.serverCount || 0) + (ps.tunnels.virtualHostCount || 0) + (ps.tunnels.serviceTunnelCount || 0);
|
||||||
|
aggregateRow = `
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0 0;flex-wrap:wrap;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:var(--text3);">All tunnels (${total})</span>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
|
${b.ready ? `<span class="badge badge-green">${b.ready} ready</span>` : ''}
|
||||||
|
${b.connecting ? `<span class="badge" style="background:var(--amber-dim);color:var(--amber);">${b.connecting} connecting</span>` : ''}
|
||||||
|
${b.error ? `<span class="badge" style="background:var(--red-dim);color:var(--red);">${b.error} error</span>` : ''}
|
||||||
|
${b.closed ? `<span class="badge badge-neutral">${b.closed} closed</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
${stateRow('Server Tunnels', servers)}
|
||||||
|
${stateRow('Virtual Hosts', vhosts)}
|
||||||
|
${stateRow('Service Tunnels', svcTunnels)}
|
||||||
|
${aggregateRow}`;
|
||||||
|
|
||||||
|
return _sectionCard('Tunnel Health',
|
||||||
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>`,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderTraffic(state) {
|
||||||
|
const ts = state.trafficStats;
|
||||||
|
if (!ts) return '';
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
${_statChip('Bytes In', _fmtBytes(ts.bytesIn), 'var(--green)')}
|
||||||
|
${_statChip('Bytes Out', _fmtBytes(ts.bytesOut), 'var(--cyan)')}
|
||||||
|
${_statChip('Requests', (ts.requests ?? 0).toLocaleString(), 'var(--text2)')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return _sectionCard('Proxy Traffic',
|
||||||
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>`,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderConnections(state) {
|
||||||
|
const sh = state.statsHistory || {};
|
||||||
|
const active = state.activeConnections || [];
|
||||||
|
const liveCount = state.stats?.totalConnections ?? active.length;
|
||||||
|
|
||||||
|
const sparkHtml = _sparkline(sh.connectionRateHistory);
|
||||||
|
|
||||||
|
let tableHtml = '';
|
||||||
|
if (active.length > 0) {
|
||||||
|
const rows = active.map(c => `
|
||||||
|
<tr>
|
||||||
|
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(c.swarmId || '—', 18))}</td>
|
||||||
|
<td class="mono" style="font-size:11px;color:var(--cyan);">${escapeHtml(truncate(c.peerKey || '—', 18))}</td>
|
||||||
|
<td style="font-size:12px;color:var(--text2);">${_fmtDuration(c.createdAt)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
tableHtml = `
|
||||||
|
<div style="margin-top:14px;overflow-x:auto;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="font-size:11px;color:var(--text4);text-transform:uppercase;letter-spacing:0.06em;">
|
||||||
|
<th style="text-align:left;padding:4px 8px 6px 0;border-bottom:1px solid var(--border);">Swarm ID</th>
|
||||||
|
<th style="text-align:left;padding:4px 8px 6px 0;border-bottom:1px solid var(--border);">Peer Key</th>
|
||||||
|
<th style="text-align:left;padding:4px 0 6px 0;border-bottom:1px solid var(--border);">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
tableHtml = `<div style="font-size:12px;color:var(--text4);margin-top:12px;">No active peer connections.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
|
||||||
|
${_statChip('Active', liveCount, liveCount > 0 ? 'var(--green)' : 'var(--text2)')}
|
||||||
|
${_statChip('Total Ever', sh.totalConnectionsEver || 0, 'var(--text2)')}
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:4px;">
|
||||||
|
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;">Connections / minute (last 30 min)</div>
|
||||||
|
${sparkHtml}
|
||||||
|
</div>
|
||||||
|
${tableHtml}`;
|
||||||
|
|
||||||
|
return _sectionCard('Peer Connections',
|
||||||
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/><line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/></svg>`,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderProxyInfo(state) {
|
||||||
|
let version = '—';
|
||||||
|
try { version = 'v' + chrome.runtime.getManifest().version; } catch (_) {}
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
${_statChip('Extension', version, 'var(--text2)')}
|
||||||
|
${_statChip('HTTPS Port', state.proxyPort ?? '—', 'var(--cyan)')}
|
||||||
|
${_statChip('CONNECT Port', state.connectProxyPort ?? '—', 'var(--cyan)')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return _sectionCard('Extension & Proxy',
|
||||||
|
`<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _lastProcessStats = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-render the Statistics page with the latest state.
|
||||||
|
* Also fires a getProcessStats request to the native host for live CPU/memory.
|
||||||
|
* @param {object} state - Full extension state from fetchState().
|
||||||
|
*/
|
||||||
|
function updateStatsPage(state) {
|
||||||
|
const el = $('page-stats');
|
||||||
|
if (!el || !el.classList.contains('active')) return;
|
||||||
|
|
||||||
|
// Fire async request for process stats; re-render when it arrives
|
||||||
|
if (state.hostConnected) {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ target: 'holesail-native', action: 'send', payload: { type: 'getProcessStats', payload: {} } },
|
||||||
|
(response) => {
|
||||||
|
if (chrome.runtime.lastError) return;
|
||||||
|
if (response && response.ok) {
|
||||||
|
_lastProcessStats = response;
|
||||||
|
_renderStatsPage(state, _lastProcessStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_lastProcessStats = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStatsPage(state, _lastProcessStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderStatsPage(state, ps) {
|
||||||
|
const el = $('page-stats');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="max-width:860px;">
|
||||||
|
${_renderHostProcess(state, ps)}
|
||||||
|
${_renderSystemResources(ps)}
|
||||||
|
${_renderTunnelHealth(state, ps)}
|
||||||
|
${_renderTraffic(state)}
|
||||||
|
${_renderConnections(state)}
|
||||||
|
${_renderProxyInfo(state)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach event listeners for the Statistics page.
|
||||||
|
* Called once during dashboard initialisation.
|
||||||
|
*/
|
||||||
|
function setupStatsEvents() {
|
||||||
|
// No modals or interactive controls on this page — all data is read-only.
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@ async function refresh() {
|
|||||||
updateTabsTable(state);
|
updateTabsTable(state);
|
||||||
updateServiceTunnelsTable(state);
|
updateServiceTunnelsTable(state);
|
||||||
updateSettingsUI();
|
updateSettingsUI();
|
||||||
|
updateStatsPage(state);
|
||||||
}
|
}
|
||||||
const sshCountEl = $('sshCount');
|
const sshCountEl = $('sshCount');
|
||||||
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ const { STORAGE_PATH } = require('./paths.js');
|
|||||||
const { log, debugLog } = require('./logger.js');
|
const { log, debugLog } = require('./logger.js');
|
||||||
const { initStartup, getProxiesReadyPromise, getTunnelsRestoredPromise, setTunnelsRestoredPromise, restorePersistedTunnels } = require('./startup.js');
|
const { initStartup, getProxiesReadyPromise, getTunnelsRestoredPromise, setTunnelsRestoredPromise, restorePersistedTunnels } = require('./startup.js');
|
||||||
|
|
||||||
|
const os = require('os');
|
||||||
|
const _hostStartTime = Date.now();
|
||||||
|
|
||||||
|
function _computeTunnelStateCounts(manager) {
|
||||||
|
const counts = { ready: 0, error: 0, closed: 0, connecting: 0 };
|
||||||
|
const all = [
|
||||||
|
...(manager.getServers() || []),
|
||||||
|
...(manager.getVirtualHosts() || []),
|
||||||
|
...(manager.getServiceTunnels() || []),
|
||||||
|
];
|
||||||
|
for (const t of all) {
|
||||||
|
const s = t.state || 'connecting';
|
||||||
|
if (s in counts) counts[s]++;
|
||||||
|
else counts.connecting++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
const holesailManager = require('../holesail-manager.js');
|
const holesailManager = require('../holesail-manager.js');
|
||||||
holesailManager.setStoragePath(STORAGE_PATH);
|
holesailManager.setStoragePath(STORAGE_PATH);
|
||||||
|
|
||||||
@@ -330,6 +348,50 @@ async function handleMessageAsync(send, msg) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'getProcessStats': {
|
||||||
|
const mem = process.memoryUsage();
|
||||||
|
let loadAvg = [0, 0, 0];
|
||||||
|
let cpuCount = 1;
|
||||||
|
let platform = '';
|
||||||
|
let arch = '';
|
||||||
|
let totalMemory = 0;
|
||||||
|
let freeMemory = 0;
|
||||||
|
try { loadAvg = os.loadavg(); } catch (_) {}
|
||||||
|
try { cpuCount = os.cpus().length; } catch (_) {}
|
||||||
|
try { platform = os.platform(); } catch (_) {}
|
||||||
|
try { arch = os.arch(); } catch (_) {}
|
||||||
|
try { totalMemory = os.totalmem(); } catch (_) {}
|
||||||
|
try { freeMemory = os.freemem(); } catch (_) {}
|
||||||
|
reply({
|
||||||
|
ok: true,
|
||||||
|
process: {
|
||||||
|
memoryUsage: {
|
||||||
|
rss: mem.rss || 0,
|
||||||
|
heapTotal: mem.heapTotal || 0,
|
||||||
|
heapUsed: mem.heapUsed || 0,
|
||||||
|
external: mem.external || 0,
|
||||||
|
},
|
||||||
|
uptimeMs: Date.now() - _hostStartTime,
|
||||||
|
pid: process.pid,
|
||||||
|
},
|
||||||
|
os: {
|
||||||
|
totalMemory,
|
||||||
|
freeMemory,
|
||||||
|
cpuCount,
|
||||||
|
loadAvg,
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
},
|
||||||
|
tunnels: {
|
||||||
|
serverCount: holesailManager.getServers().length,
|
||||||
|
virtualHostCount: holesailManager.getVirtualHosts().length,
|
||||||
|
serviceTunnelCount: holesailManager.getServiceTunnels().length,
|
||||||
|
byState: _computeTunnelStateCounts(holesailManager),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
reply({ ok: false, error: `Unknown command: ${type}` });
|
reply({ ok: false, error: `Unknown command: ${type}` });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user