feat: implement 13 features — auto-reconnect, notifications, bulk actions, latency, traffic, theme, export/import, scheduled backups, peer lookup, SSH auto-reconnect, log filters, keyboard shortcut, and readyTimeout default
CI / Build & Test (push) Successful in 2m44s
CI / Build & Test (push) Successful in 2m44s
- Change readyTimeoutMs default from 0 to 30000 in both state files - Register Alt+Shift+H keyboard shortcut via manifest _execute_action command - Add severity filter buttons (All/Info/Warn/Error) and Download .txt to Logs page; background logs.js now tags entries with proper level field - Fire browser notifications on tunnelError events (notifyOnTunnelError setting) - Add exponential-backoff auto-reconnect for virtual hosts and service tunnels (tunnelAutoReconnect setting, 5s–120s backoff) - Add backupIntervalHours setting and scheduled auto-backup timer in message-router.js - Add TCP connect latency badges to Virtual Hosts and Service Tunnels tables via new pingTunnel native message - Add bytesIn/bytesOut/requests counters to https-proxy.js; expose in Overview status bar via getState - Add Export/Import connection configs (JSON, no CA key) in Settings - Add autoReconnect flag and exponential-backoff reconnect to SSH connection cards - Add light theme CSS variables and theme toggle in Settings (persisted to localStorage) - Add checkbox column and bulk Stop/Remove actions to Virtual Hosts, Service Tunnels, and Servers tables - Add Peer Lookup UI card on Overview page using existing lookup message handler
This commit is contained in:
@@ -8,9 +8,20 @@ function setupEvents() {
|
||||
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'));
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save settings
|
||||
@@ -29,6 +40,153 @@ function setupEvents() {
|
||||
);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'getState', payload: {} } },
|
||||
(response) => {
|
||||
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 || '' });
|
||||
}
|
||||
}
|
||||
pending++;
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setSshConnections', payload: { connections: mergedSsh } } },
|
||||
() => {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setRdpConnections', payload: { connections: mergedRdp } } },
|
||||
() => onDone()
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (pending === 0) {
|
||||
showToast('Nothing to import', 'default');
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
// Peer Lookup
|
||||
function runPeerLookup() {
|
||||
const input = $('peerLookupInput');
|
||||
const resultEl = $('peerLookupResult');
|
||||
if (!input || !resultEl) return;
|
||||
const key = input.value.trim();
|
||||
if (!key) { showToast('Enter an hs:// key', 'default'); return; }
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.innerHTML = '<span style="color:var(--text3);">Looking up…</span>';
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'lookup', payload: { hsUrl: key } } },
|
||||
(response) => {
|
||||
if (chrome.runtime.lastError || !response) {
|
||||
resultEl.innerHTML = '<span style="color:var(--red);">Lookup failed: ' + (chrome.runtime.lastError?.message || 'No response') + '</span>';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + escapeHtml(response.error || 'Unknown error') + '</span>';
|
||||
return;
|
||||
}
|
||||
const peers = response.peers || [];
|
||||
if (peers.length === 0) {
|
||||
resultEl.innerHTML = '<span style="color:var(--amber);">No peers found for this key.</span>';
|
||||
} else {
|
||||
resultEl.innerHTML = '<span style="color:var(--green);">Found ' + peers.length + ' peer(s):</span> ' +
|
||||
peers.map(p => '<code style="font-family:\'JetBrains Mono\',monospace;font-size:11px;color:var(--cyan);">' + escapeHtml(String(p)) + '</code>').join(', ');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
$('btnPeerLookup')?.addEventListener('click', runPeerLookup);
|
||||
$('peerLookupInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') runPeerLookup(); });
|
||||
|
||||
setupVirtualHostEvents();
|
||||
setupServerEvents();
|
||||
setupServiceTunnelEvents();
|
||||
|
||||
Reference in New Issue
Block a user