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
247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
/**
|
|
* Global event wiring for the dashboard.
|
|
* Wires toggle switches (theme, notifications, auto-reconnect), settings buttons,
|
|
* export/import handlers, Holesail Lookup, and calls each page module's setup function.
|
|
* Depends on: all page modules
|
|
*/
|
|
|
|
/**
|
|
* Attach all global dashboard event listeners.
|
|
* Guarded by `document._holesailEventsSetup` to prevent duplicate registration.
|
|
*/
|
|
function setupEvents() {
|
|
// Guard against duplicate listener registration if setupEvents() is called
|
|
// more than once (e.g. after a hot-reload). Without this, anonymous listeners
|
|
// accumulate and each toggle fires N times per click after N calls.
|
|
if (document._holesailEventsSetup) return;
|
|
document._holesailEventsSetup = true;
|
|
|
|
// Restore saved theme before any toggle listeners fire
|
|
const savedTheme = localStorage.getItem('holesail-theme');
|
|
if (savedTheme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
|
|
|
// Toggle switches
|
|
document.querySelectorAll('.toggle').forEach(toggle => {
|
|
toggle.addEventListener('click', () => {
|
|
toggle.classList.toggle('active');
|
|
if (toggle.id === 'toggleTheme') {
|
|
const isLight = toggle.classList.contains('active');
|
|
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
|
|
localStorage.setItem('holesail-theme', isLight ? 'light' : 'dark');
|
|
}
|
|
// Mark settings dirty for any toggle inside the settings page so the
|
|
// refresh cycle doesn't revert the change before the user hits Save
|
|
if (toggle.closest('#page-settings')) _markSettingsDirty();
|
|
});
|
|
});
|
|
|
|
// Mark settings dirty when any number input inside the settings page changes
|
|
document.querySelectorAll('#page-settings input[type="number"]').forEach(input => {
|
|
input.addEventListener('input', _markSettingsDirty);
|
|
});
|
|
|
|
// Save settings
|
|
$('btnSaveSettings')?.addEventListener('click', saveSettings);
|
|
|
|
// Reset settings to defaults
|
|
$('btnResetSettings')?.addEventListener('click', () => {
|
|
settings = { ...SETTINGS_DEFAULTS };
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
|
(response) => {
|
|
if (response && response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
|
|
_clearSettingsDirty();
|
|
updateSettingsUI();
|
|
showToast('Settings reset to defaults', 'success');
|
|
}
|
|
);
|
|
});
|
|
|
|
// Export configuration
|
|
$('btnExportConfig')?.addEventListener('click', () => {
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'getState', payload: {} } },
|
|
(response) => {
|
|
if (chrome.runtime.lastError || !response || !response.ok) {
|
|
showToast('Export failed: could not read state', 'error');
|
|
return;
|
|
}
|
|
const state = response;
|
|
const exportData = {
|
|
exportedAt: new Date().toISOString(),
|
|
version: 1,
|
|
virtualHosts: (state.virtualHosts || []).map(v => ({ hostname: v.hostname, hsUrl: v.hsUrl })),
|
|
serviceTunnels: (state.serviceTunnels || []).map(t => ({ label: t.label, hsUrl: t.hsUrl, localPort: t.localPort })),
|
|
sshConnections: (state.sshConnections || []).map(s => ({ label: s.label, hsUrl: s.hsUrl, username: s.username })),
|
|
rdpConnections: (state.rdpConnections || []).map(r => ({ label: r.label, hsUrl: r.hsUrl, type: r.type, port: r.port, width: r.width, height: r.height, username: r.username }))
|
|
};
|
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'holesail-config-' + new Date().toISOString().slice(0, 10) + '.json';
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
showToast('Configuration exported', 'success');
|
|
}
|
|
);
|
|
});
|
|
|
|
// Import configuration
|
|
$('importConfigFile')?.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
let data;
|
|
try { data = JSON.parse(ev.target.result); } catch (_) { showToast('Invalid JSON file', 'error'); return; }
|
|
if (!data || typeof data !== 'object') { showToast('Invalid config file', 'error'); return; }
|
|
|
|
const vhosts = Array.isArray(data.virtualHosts) ? data.virtualHosts : [];
|
|
const svcTunnels = Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [];
|
|
const sshConns = Array.isArray(data.sshConnections) ? data.sshConnections : [];
|
|
const rdpConns = Array.isArray(data.rdpConnections) ? data.rdpConnections : [];
|
|
|
|
let pending = 0;
|
|
let done = 0;
|
|
function onDone() {
|
|
done++;
|
|
if (done >= pending) {
|
|
showToast('Import complete', 'success');
|
|
e.target.value = '';
|
|
if (typeof refreshAll === 'function') refreshAll();
|
|
}
|
|
}
|
|
|
|
for (const v of vhosts) {
|
|
if (!v.hostname || !v.hsUrl) continue;
|
|
pending++;
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname: v.hostname, hsUrl: v.hsUrl } } },
|
|
() => onDone()
|
|
);
|
|
}
|
|
for (const t of svcTunnels) {
|
|
if (!t.hsUrl) continue;
|
|
pending++;
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'startServiceTunnel', payload: { label: t.label || '', hsUrl: t.hsUrl, localPort: t.localPort } } },
|
|
() => onDone()
|
|
);
|
|
}
|
|
|
|
if (sshConns.length || rdpConns.length) {
|
|
pending++;
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'getState', payload: {} } },
|
|
(response) => {
|
|
void chrome.runtime.lastError;
|
|
const existing = response || {};
|
|
const mergedSsh = [...(existing.sshConnections || [])];
|
|
for (const s of sshConns) {
|
|
if (!s.hsUrl) continue;
|
|
if (!mergedSsh.find(x => x.hsUrl === s.hsUrl && x.username === s.username)) {
|
|
mergedSsh.push({ id: 'ssh-' + Date.now() + '-' + Math.random().toString(36).slice(2), label: s.label || '', hsUrl: s.hsUrl, username: s.username || '' });
|
|
}
|
|
}
|
|
const mergedRdp = [...(existing.rdpConnections || [])];
|
|
for (const r of rdpConns) {
|
|
if (!r.hsUrl) continue;
|
|
if (!mergedRdp.find(x => x.hsUrl === r.hsUrl)) {
|
|
mergedRdp.push({ id: 'rdp-' + Date.now() + '-' + Math.random().toString(36).slice(2), label: r.label || '', hsUrl: r.hsUrl, type: r.type || 'vnc', port: r.port || 5900, width: r.width || 1280, height: r.height || 720, username: r.username || '' });
|
|
}
|
|
}
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'setSshConnections', payload: { connections: mergedSsh } } },
|
|
() => {
|
|
void chrome.runtime.lastError;
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'setRdpConnections', payload: { connections: mergedRdp } } },
|
|
() => { void chrome.runtime.lastError; onDone(); }
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
if (pending === 0) {
|
|
showToast('Nothing to import', 'default');
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
// Holesail Lookup
|
|
function runPeerLookup() {
|
|
const input = $('peerLookupInput');
|
|
if (!input) return;
|
|
const key = input.value.trim();
|
|
if (!key) { showToast('Enter an hs:// key', 'default'); return; }
|
|
|
|
const body = $('holesailLookupBody');
|
|
if (body) body.innerHTML = '<div style="text-align:center;padding:12px 0;color:var(--text3);">Looking up…</div>';
|
|
openModal('modal-holesailLookup');
|
|
|
|
chrome.runtime.sendMessage(
|
|
{ target: 'holesail-native', action: 'send', payload: { type: 'lookup', payload: { hsUrl: key } } },
|
|
(response) => {
|
|
void chrome.runtime.lastError;
|
|
if (!body) return;
|
|
if (!response || !response.ok) {
|
|
const errMsg = (response && response.error) || (chrome.runtime.lastError && chrome.runtime.lastError.message) || 'No response';
|
|
body.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:10px;color:var(--red);">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px;height:20px;flex-shrink:0;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
<span>${escapeHtml(errMsg)}</span>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const host = response.host || null;
|
|
const port = response.port || null;
|
|
const protocol = response.protocol ? response.protocol.toUpperCase() : null;
|
|
const isPrivate = response.secure === true;
|
|
|
|
if (!host && !port && !protocol) {
|
|
body.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:10px;color:var(--amber);">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px;height:20px;flex-shrink:0;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
<span>No record found for the provided key.</span>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
function row(label, value, valueColor) {
|
|
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);">
|
|
<span style="color:var(--text2);font-size:12.5px;">${label}</span>
|
|
<span style="font-family:'JetBrains Mono',monospace;font-size:12.5px;color:${valueColor || 'var(--text1)'};font-weight:500;">${escapeHtml(String(value))}</span>
|
|
</div>`;
|
|
}
|
|
|
|
body.innerHTML = `
|
|
<div style="margin-bottom:4px;">
|
|
${row('Host', host || 'N/A', host ? 'var(--cyan)' : 'var(--text3)')}
|
|
${row('Port', port || 'N/A', port ? 'var(--cyan)' : 'var(--text3)')}
|
|
${row('Protocol', protocol || 'N/A', protocol ? 'var(--green)' : 'var(--text3)')}
|
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;">
|
|
<span style="color:var(--text2);font-size:12.5px;">Private</span>
|
|
<span style="font-size:12.5px;font-weight:500;color:${isPrivate ? 'var(--amber)' : 'var(--green)'};">
|
|
${isPrivate ? '🔒 Yes' : '🌐 No'}
|
|
</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
);
|
|
}
|
|
$('btnPeerLookup')?.addEventListener('click', runPeerLookup);
|
|
$('peerLookupInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') runPeerLookup(); });
|
|
|
|
setupVirtualHostEvents();
|
|
setupServerEvents();
|
|
setupServiceTunnelEvents();
|
|
setupLogsEvents();
|
|
setupStatsEvents();
|
|
}
|