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

- 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:
Raven Scott
2026-02-28 23:51:14 -05:00
parent 31f30b2974
commit 849897f324
24 changed files with 726 additions and 47 deletions
@@ -18,16 +18,39 @@ function debugLog(...args) {
if (process.stderr) process.stderr.write(msg + '\n');
}
const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt }
const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt, reconnectTimer, reconnectDelay }
let nextServiceTunnelId = 0;
let _saveState = null;
let _emit = null;
let _getReadyTimeoutMs = null;
let _getAutoReconnect = null;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn) {
const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 120000;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn, getAutoReconnectFn) {
_saveState = saveStateFn;
_emit = emitFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn;
_getAutoReconnect = getAutoReconnectFn;
}
function _scheduleSvcReconnect(tunnelId) {
const t = serviceTunnels.get(tunnelId);
if (!t) return;
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
const autoReconnect = _getAutoReconnect ? _getAutoReconnect() : false;
if (!autoReconnect) return;
const delay = t.reconnectDelay || RECONNECT_BASE_MS;
t.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS);
if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' reconnecting in ' + Math.round(delay / 1000) + 's\n');
t.reconnectTimer = setTimeout(async () => {
t.reconnectTimer = null;
const cur = serviceTunnels.get(tunnelId);
if (!cur || cur.state === 'ready') return;
if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' auto-reconnect attempt\n');
await startServiceTunnel({ tunnelId, label: cur.label, hsUrl: cur.hsUrl, localPort: cur.localPort });
}, delay);
}
function applyLoaded(loadedNextServiceTunnelId) {
@@ -67,17 +90,17 @@ async function startServiceTunnel(payload) {
hs.on('error', (err) => {
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
const t = serviceTunnels.get(tunnelId);
if (t && t.holesail === hs) t.state = 'error';
if (t && t.holesail === hs) { t.state = 'error'; _scheduleSvcReconnect(tunnelId); }
if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
});
hs.on('close', () => {
const t = serviceTunnels.get(tunnelId);
if (t && t.holesail === hs) t.state = 'closed';
if (t && t.holesail === hs) { t.state = 'closed'; _scheduleSvcReconnect(tunnelId); }
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label });
});
}
await readyWithTimeout(hs, 'svc:' + tunnelId);
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now() });
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
if (_emit) _emit('serviceTunnelReady', { tunnelId, label, localPort });
if (_saveState) _saveState();
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
@@ -96,6 +119,7 @@ async function stopServiceTunnel(payload) {
debugLog('stopServiceTunnel: id=', tunnelId);
const entry = serviceTunnels.get(tunnelId);
if (!entry) return { ok: false, error: 'Service tunnel not found' };
if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; }
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
serviceTunnels.delete(tunnelId);
if (_saveState) _saveState();
@@ -115,6 +139,7 @@ function getNextServiceTunnelId() { return nextServiceTunnelId; }
async function cleanupServiceTunnels() {
for (const [, t] of serviceTunnels) {
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
if (t.holesail) { try { await t.holesail.close(); } catch (_) {} }
}
serviceTunnels.clear();