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:
@@ -11,7 +11,13 @@ function log(...args) {
|
|||||||
console.log('[Holesail-bg]', ...args);
|
console.log('[Holesail-bg]', ...args);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||||
logs.push({ timestamp, message, level: 'info' });
|
const lower = message.toLowerCase();
|
||||||
|
const level = (lower.includes('error') || lower.includes('fail') || lower.includes('err:'))
|
||||||
|
? 'error'
|
||||||
|
: (lower.includes('warn') || lower.includes('warning'))
|
||||||
|
? 'warn'
|
||||||
|
: 'info';
|
||||||
|
logs.push({ timestamp, message, level });
|
||||||
if (logs.length > MAX_LOGS) logs.shift();
|
if (logs.length > MAX_LOGS) logs.shift();
|
||||||
broadcastLogs();
|
broadcastLogs();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
||||||
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
||||||
}
|
}
|
||||||
|
if (payload.settings && typeof payload.settings.notifyOnTunnelError === 'boolean') {
|
||||||
|
notifyOnTunnelError = payload.settings.notifyOnTunnelError;
|
||||||
|
}
|
||||||
if (!pacConfirmedActive) {
|
if (!pacConfirmedActive) {
|
||||||
log('getState: PAC not confirmed active, re-applying');
|
log('getState: PAC not confirmed active, re-applying');
|
||||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||||
@@ -121,6 +124,7 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
settings: payload.settings || {},
|
settings: payload.settings || {},
|
||||||
sshConnections: payload.sshConnections || [],
|
sshConnections: payload.sshConnections || [],
|
||||||
rdpConnections: payload.rdpConnections || [],
|
rdpConnections: payload.rdpConnections || [],
|
||||||
|
trafficStats: payload.trafficStats || null,
|
||||||
stats: { ...extensionState.stats, ...payload.stats }
|
stats: { ...extensionState.stats, ...payload.stats }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ function connect() {
|
|||||||
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
|
||||||
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
|
||||||
}
|
}
|
||||||
|
if (payload.settings && typeof payload.settings.notifyOnTunnelError === 'boolean') {
|
||||||
|
notifyOnTunnelError = payload.settings.notifyOnTunnelError;
|
||||||
|
}
|
||||||
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
applyPAC(getActiveTlds(extensionState.virtualHosts));
|
||||||
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
|
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
|
||||||
} else {
|
} else {
|
||||||
@@ -177,6 +180,16 @@ function connect() {
|
|||||||
for (const tabId of subscribedTabs) {
|
for (const tabId of subscribedTabs) {
|
||||||
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
|
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (msg.event === 'tunnelError' && notifyOnTunnelError && browser.notifications) {
|
||||||
|
const label = p.hostname || p.label || p.swarmId || 'tunnel';
|
||||||
|
const errMsg = p.error || p.message || 'Tunnel error';
|
||||||
|
browser.notifications.create('holesail-tunnel-error-' + Date.now(), {
|
||||||
|
type: 'basic',
|
||||||
|
title: 'Holesail — Tunnel Error',
|
||||||
|
message: label + ': ' + errMsg,
|
||||||
|
iconUrl: browser.runtime.getURL('icons/48.png'),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const swarmRefCount = new Map();
|
|||||||
const activeConnections = new Map();
|
const activeConnections = new Map();
|
||||||
|
|
||||||
let notifyOnDisconnect = false;
|
let notifyOnDisconnect = false;
|
||||||
|
let notifyOnTunnelError = true;
|
||||||
|
|
||||||
// True once proxy.settings.get confirms mode=pac_script controlled_by_this_extension
|
// True once proxy.settings.get confirms mode=pac_script controlled_by_this_extension
|
||||||
let pacConfirmedActive = false;
|
let pacConfirmedActive = false;
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
const SETTINGS_DEFAULTS = {
|
const SETTINGS_DEFAULTS = {
|
||||||
proxyPort: 8443,
|
proxyPort: 8443,
|
||||||
connectProxyPort: 8442,
|
connectProxyPort: 8442,
|
||||||
readyTimeoutMs: 0,
|
readyTimeoutMs: 30000,
|
||||||
notifyOnDisconnect: true,
|
notifyOnDisconnect: true,
|
||||||
|
notifyOnTunnelError: true,
|
||||||
debug: false,
|
debug: false,
|
||||||
disableOnFileUrls: false,
|
disableOnFileUrls: false,
|
||||||
backupRetention: 5
|
backupRetention: 5,
|
||||||
|
backupIntervalHours: 0,
|
||||||
|
tunnelAutoReconnect: false
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentState = null;
|
let currentState = null;
|
||||||
|
|||||||
@@ -28,6 +28,29 @@
|
|||||||
--transition: 0.15s ease;
|
--transition: 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f4f4f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--elevated: #f9f9fb;
|
||||||
|
--border: #e4e4e7;
|
||||||
|
--border2: #d4d4d8;
|
||||||
|
--text: #18181b;
|
||||||
|
--text2: #3f3f46;
|
||||||
|
--text3: #71717a;
|
||||||
|
--text4: #a1a1aa;
|
||||||
|
--cyan: #0891b2;
|
||||||
|
--cyan-dim: rgba(8,145,178,.10);
|
||||||
|
--cyan-mid: rgba(8,145,178,.20);
|
||||||
|
--green: #16a34a;
|
||||||
|
--green-dim: rgba(22,163,74,.10);
|
||||||
|
--amber: #d97706;
|
||||||
|
--amber-dim: rgba(217,119,6,.10);
|
||||||
|
--red: #e11d48;
|
||||||
|
--red-dim: rgba(225,29,72,.10);
|
||||||
|
--red-mid: rgba(225,29,72,.20);
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|||||||
@@ -162,6 +162,12 @@
|
|||||||
<span style="font-size:12.5px;font-weight:500;color:var(--text2);">CONNECT Proxy</span>
|
<span style="font-size:12.5px;font-weight:500;color:var(--text2);">CONNECT Proxy</span>
|
||||||
<span id="ovConnectLabel" style="font-size:11.5px;color:var(--text3);">—</span>
|
<span id="ovConnectLabel" style="font-size:11.5px;color:var(--text3);">—</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="width:1px;height:18px;background:var(--border);flex-shrink:0;"></div>
|
||||||
|
<div style="display:flex;align-items:center;gap:7px;flex:1;min-width:180px;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;color:var(--text4);flex-shrink:0;"><polyline points="16,3 21,3 21,8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21,16 21,21 16,21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
|
||||||
|
<span style="font-size:12.5px;font-weight:500;color:var(--text2);">Traffic</span>
|
||||||
|
<span id="ovTrafficLabel" style="font-size:11.5px;color:var(--text3);">—</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stat cards — 6 across -->
|
<!-- Stat cards — 6 across -->
|
||||||
@@ -354,11 +360,29 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer Lookup -->
|
||||||
|
<div class="card" style="flex-shrink:0;">
|
||||||
|
<div class="card-header"><div class="card-title">Peer Lookup</div><div class="card-subtitle">Resolve an hs:// key to check if the peer is reachable on the DHT</div></div>
|
||||||
|
<div class="card-body" style="display:flex;gap:8px;align-items:center;padding-top:10px;padding-bottom:10px;flex-wrap:wrap;">
|
||||||
|
<input type="text" id="peerLookupInput" class="input mono" placeholder="hs://s000…" autocomplete="off" spellcheck="false" style="flex:1;min-width:220px;">
|
||||||
|
<button class="btn btn-primary btn-sm" id="btnPeerLookup">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
Lookup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="peerLookupResult" style="padding:0 16px 12px;font-size:12.5px;display:none;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Virtual Hosts page ──────────────────────── -->
|
<!-- ── Virtual Hosts page ──────────────────────── -->
|
||||||
<div class="page" id="page-connections">
|
<div class="page" id="page-connections">
|
||||||
<div style="margin-bottom:16px;display:flex;justify-content:flex-end;">
|
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||||
|
<div id="vhostBulkBar" style="display:none;align-items:center;gap:8px;">
|
||||||
|
<span id="vhostBulkCount" style="font-size:12.5px;color:var(--text2);"></span>
|
||||||
|
<button class="btn btn-danger btn-sm" id="btnVhostBulkRemove">Remove Selected</button>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
<button class="btn btn-primary" id="addVhostBtn">
|
<button class="btn btn-primary" id="addVhostBtn">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
Add Host
|
Add Host
|
||||||
@@ -369,6 +393,7 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:32px;"><input type="checkbox" id="vhostSelectAll" title="Select all"></th>
|
||||||
<th>Hostname</th>
|
<th>Hostname</th>
|
||||||
<th>hs:// URL</th>
|
<th>hs:// URL</th>
|
||||||
<th>Backend</th>
|
<th>Backend</th>
|
||||||
@@ -377,7 +402,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="connectionsTable">
|
<tbody id="connectionsTable">
|
||||||
<tr><td colspan="5" class="empty">No virtual hosts. Click "Add Host" to get started.</td></tr>
|
<tr><td colspan="6" class="empty">No virtual hosts. Click "Add Host" to get started.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +411,12 @@
|
|||||||
|
|
||||||
<!-- ── Servers page ────────────────────────────── -->
|
<!-- ── Servers page ────────────────────────────── -->
|
||||||
<div class="page" id="page-swarms">
|
<div class="page" id="page-swarms">
|
||||||
<div style="margin-bottom:16px;display:flex;justify-content:flex-end;">
|
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||||
|
<div id="serverBulkBar" style="display:none;align-items:center;gap:8px;">
|
||||||
|
<span id="serverBulkCount" style="font-size:12.5px;color:var(--text2);"></span>
|
||||||
|
<button class="btn btn-danger btn-sm" id="btnServerBulkStop">Stop Selected</button>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
<button class="btn btn-primary" id="startServerBtn">
|
<button class="btn btn-primary" id="startServerBtn">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
New Server
|
New Server
|
||||||
@@ -397,6 +427,7 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:32px;"><input type="checkbox" id="serverSelectAll" title="Select all"></th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Port</th>
|
<th>Port</th>
|
||||||
<th>hs:// URL</th>
|
<th>hs:// URL</th>
|
||||||
@@ -405,7 +436,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="swarmsTable">
|
<tbody id="swarmsTable">
|
||||||
<tr><td colspan="5" class="empty">No server tunnels. Click "New Server" to start one.</td></tr>
|
<tr><td colspan="6" class="empty">No server tunnels. Click "New Server" to start one.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -525,6 +556,17 @@
|
|||||||
<div class="page" id="page-settings">
|
<div class="page" id="page-settings">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div class="section-heading">Appearance</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Light Theme</div>
|
||||||
|
<div class="setting-desc">Switch between dark (default) and light color scheme</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="toggle" id="toggleTheme"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-heading">Notifications</div>
|
<div class="section-heading">Notifications</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -535,6 +577,24 @@
|
|||||||
<div class="toggle" id="toggleNotify" data-setting="notifyOnDisconnect"></div>
|
<div class="toggle" id="toggleNotify" data-setting="notifyOnDisconnect"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Tunnel Error Alert</div>
|
||||||
|
<div class="setting-desc">Show a notification when a virtual host or service tunnel enters an error state</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="toggle" id="toggleNotifyTunnelError" data-setting="notifyOnTunnelError"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Tunnel Auto-Reconnect</div>
|
||||||
|
<div class="setting-desc">Automatically reconnect virtual hosts and service tunnels on error or disconnect (exponential backoff)</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="toggle" id="toggleTunnelAutoReconnect" data-setting="tunnelAutoReconnect"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<div class="setting-label">Debug Mode</div>
|
<div class="setting-label">Debug Mode</div>
|
||||||
@@ -584,6 +644,42 @@
|
|||||||
<input type="number" id="backupRetention" min="1" max="100" class="input" style="width:90px;" placeholder="5">
|
<input type="number" id="backupRetention" min="1" max="100" class="input" style="width:90px;" placeholder="5">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Auto-Backup Interval (hours)</div>
|
||||||
|
<div class="setting-desc">Automatically create a backup every N hours. Set to 0 to disable.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<input type="number" id="backupIntervalHours" min="0" max="720" class="input" style="width:90px;" placeholder="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-heading">Import / Export</div>
|
||||||
|
<div class="setting-row" style="align-items:flex-start;">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Export Configuration</div>
|
||||||
|
<div class="setting-desc">Download virtual hosts, service tunnels, SSH & RDP connections as JSON. CA private key is never included.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btnExportConfig">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row" style="align-items:flex-start;">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">Import Configuration</div>
|
||||||
|
<div class="setting-desc">Merge connections from a previously exported JSON file. Existing entries are not overwritten.</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="btn btn-secondary btn-sm" style="cursor:pointer;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
Import
|
||||||
|
<input type="file" id="importConfigFile" accept=".json" style="display:none;">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:20px;display:flex;gap:8px;">
|
<div style="margin-top:20px;display:flex;gap:8px;">
|
||||||
<button class="btn btn-primary" id="btnSaveSettings">Save Settings</button>
|
<button class="btn btn-primary" id="btnSaveSettings">Save Settings</button>
|
||||||
@@ -596,7 +692,12 @@
|
|||||||
|
|
||||||
<!-- ── Service Tunnels page ──────────────────────── -->
|
<!-- ── Service Tunnels page ──────────────────────── -->
|
||||||
<div class="page" id="page-service-tunnels">
|
<div class="page" id="page-service-tunnels">
|
||||||
<div style="margin-bottom:16px;display:flex;justify-content:flex-end;">
|
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||||
|
<div id="svcBulkBar" style="display:none;align-items:center;gap:8px;">
|
||||||
|
<span id="svcBulkCount" style="font-size:12.5px;color:var(--text2);"></span>
|
||||||
|
<button class="btn btn-danger btn-sm" id="btnSvcBulkRemove">Remove Selected</button>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
<button class="btn btn-primary" id="addServiceTunnelBtn">
|
<button class="btn btn-primary" id="addServiceTunnelBtn">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
Add Tunnel
|
Add Tunnel
|
||||||
@@ -607,6 +708,7 @@
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:32px;"><input type="checkbox" id="svcSelectAll" title="Select all"></th>
|
||||||
<th>Label</th>
|
<th>Label</th>
|
||||||
<th>hs:// Key</th>
|
<th>hs:// Key</th>
|
||||||
<th>Local Port</th>
|
<th>Local Port</th>
|
||||||
@@ -615,7 +717,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="serviceTunnelsTable">
|
<tbody id="serviceTunnelsTable">
|
||||||
<tr><td colspan="5" class="empty">No service tunnels. Click "Add Tunnel" to connect a remote service.</td></tr>
|
<tr><td colspan="6" class="empty">No service tunnels. Click "Add Tunnel" to connect a remote service.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -643,10 +745,20 @@
|
|||||||
<div class="page" id="page-logs">
|
<div class="page" id="page-logs">
|
||||||
<div class="logs-toolbar">
|
<div class="logs-toolbar">
|
||||||
<input type="text" class="input logs-filter" id="logsFilter" placeholder="Filter logs…">
|
<input type="text" class="input logs-filter" id="logsFilter" placeholder="Filter logs…">
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<button class="btn btn-secondary btn-sm log-level-btn active" data-level="all">All</button>
|
||||||
|
<button class="btn btn-secondary btn-sm log-level-btn" data-level="info">Info</button>
|
||||||
|
<button class="btn btn-secondary btn-sm log-level-btn" data-level="warn" style="color:var(--amber);">Warn</button>
|
||||||
|
<button class="btn btn-secondary btn-sm log-level-btn" data-level="error" style="color:var(--red);">Error</button>
|
||||||
|
</div>
|
||||||
<button class="btn btn-secondary" id="btnAutoScroll">
|
<button class="btn btn-secondary" id="btnAutoScroll">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7,13 12,18 17,13"/><polyline points="7,6 12,11 17,6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7,13 12,18 17,13"/><polyline points="7,6 12,11 17,6"/></svg>
|
||||||
Auto-scroll: ON
|
Auto-scroll: ON
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" id="btnDownloadLogs">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
<button class="btn btn-ghost" id="btnClearLogs">
|
<button class="btn btn-ghost" id="btnClearLogs">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/><path d="M10,11v6"/><path d="M14,11v6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/><path d="M10,11v6"/><path d="M14,11v6"/></svg>
|
||||||
Clear
|
Clear
|
||||||
@@ -893,6 +1005,10 @@
|
|||||||
<label class="form-label" for="sshConnPassword">Password <span style="color:var(--text4);font-weight:400;">(optional — saved, or leave blank to type in terminal)</span></label>
|
<label class="form-label" for="sshConnPassword">Password <span style="color:var(--text4);font-weight:400;">(optional — saved, or leave blank to type in terminal)</span></label>
|
||||||
<input type="password" id="sshConnPassword" class="input" placeholder="Leave blank to enter manually" autocomplete="new-password">
|
<input type="password" id="sshConnPassword" class="input" placeholder="Leave blank to enter manually" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
|
<label class="checkbox-row" style="margin-top:8px;">
|
||||||
|
<input type="checkbox" id="sshConnAutoReconnect">
|
||||||
|
<label for="sshConnAutoReconnect">Auto-reconnect on disconnect</label>
|
||||||
|
</label>
|
||||||
<input type="hidden" id="sshConnEditId">
|
<input type="hidden" id="sshConnEditId">
|
||||||
<div class="modal-error" id="sshConnError"></div>
|
<div class="modal-error" id="sshConnError"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,20 @@ function setupEvents() {
|
|||||||
if (document._holesailEventsSetup) return;
|
if (document._holesailEventsSetup) return;
|
||||||
document._holesailEventsSetup = true;
|
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
|
// Toggle switches
|
||||||
document.querySelectorAll('.toggle').forEach(toggle => {
|
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
|
// 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();
|
setupVirtualHostEvents();
|
||||||
setupServerEvents();
|
setupServerEvents();
|
||||||
setupServiceTunnelEvents();
|
setupServiceTunnelEvents();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ function setupLogsEvents() {
|
|||||||
let logs = [];
|
let logs = [];
|
||||||
let autoScroll = true;
|
let autoScroll = true;
|
||||||
let logFilter = '';
|
let logFilter = '';
|
||||||
|
let logLevel = 'all'; // 'all' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
{ target: 'holesail-native', action: 'registerDashboard' },
|
{ target: 'holesail-native', action: 'registerDashboard' },
|
||||||
@@ -19,31 +20,34 @@ function setupLogsEvents() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function getLogClass(msg) {
|
function getLogClass(level) {
|
||||||
const m = (msg || '').toLowerCase();
|
if (level === 'error') return 'is-error';
|
||||||
if (m.includes('error') || m.includes('fail') || m.includes('err:')) return 'is-error';
|
if (level === 'warn') return 'is-warn';
|
||||||
if (m.includes('warn') || m.includes('warning')) return 'is-warn';
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLogsDisplay() {
|
function updateLogsDisplay() {
|
||||||
const container = $('logsContainer');
|
const container = $('logsContainer');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const filtered = logFilter
|
let filtered = logs;
|
||||||
? logs.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase()))
|
if (logFilter) {
|
||||||
: logs;
|
filtered = filtered.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (logLevel !== 'all') {
|
||||||
|
filtered = filtered.filter(e => (e.level || 'info') === logLevel);
|
||||||
|
}
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
||||||
<div class="empty-state-title">${logFilter ? 'No matching logs' : 'No logs yet'}</div>
|
<div class="empty-state-title">${(logFilter || logLevel !== 'all') ? 'No matching logs' : 'No logs yet'}</div>
|
||||||
<div class="empty-state-desc">${logFilter ? 'Try a different filter' : 'Logs will appear here as the extension runs'}</div>
|
<div class="empty-state-desc">${(logFilter || logLevel !== 'all') ? 'Try a different filter' : 'Logs will appear here as the extension runs'}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = filtered.map(entry => {
|
container.innerHTML = filtered.map(entry => {
|
||||||
const time = new Date(entry.timestamp).toLocaleTimeString();
|
const time = new Date(entry.timestamp).toLocaleTimeString();
|
||||||
const cls = getLogClass(entry.message);
|
const cls = getLogClass(entry.level || 'info');
|
||||||
return `<div class="log-entry">
|
return `<div class="log-entry">
|
||||||
<span class="log-time">${time}</span>
|
<span class="log-time">${time}</span>
|
||||||
<span class="log-msg ${cls}">${escapeHtml(entry.message)}</span>
|
<span class="log-msg ${cls}">${escapeHtml(entry.message)}</span>
|
||||||
@@ -68,6 +72,33 @@ function setupLogsEvents() {
|
|||||||
updateLogsDisplay();
|
updateLogsDisplay();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Severity filter buttons
|
||||||
|
document.querySelectorAll('.log-level-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.log-level-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
logLevel = btn.dataset.level;
|
||||||
|
updateLogsDisplay();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download logs as .txt
|
||||||
|
$('btnDownloadLogs')?.addEventListener('click', () => {
|
||||||
|
if (!logs.length) { showToast('No logs to download', 'default'); return; }
|
||||||
|
const lines = logs.map(e => {
|
||||||
|
const time = new Date(e.timestamp).toISOString();
|
||||||
|
return `[${time}] ${e.message}`;
|
||||||
|
}).join('\n');
|
||||||
|
const blob = new Blob([lines], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'holesail-logs-' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.txt';
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
showToast('Logs downloaded', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
chrome.runtime.sendMessage({ target: 'holesail-native', action: 'unregisterDashboard' }, () => {});
|
chrome.runtime.sendMessage({ target: 'holesail-native', action: 'unregisterDashboard' }, () => {});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ function updateDashboard(state) {
|
|||||||
setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed');
|
setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed');
|
||||||
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
|
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
|
||||||
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
|
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
|
||||||
|
if (state.trafficStats) {
|
||||||
|
const { bytesIn, bytesOut, requests } = state.trafficStats;
|
||||||
|
const fmt = (n) => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B';
|
||||||
|
setText('ovTrafficLabel', `↑ ${fmt(bytesIn)} ↓ ${fmt(bytesOut)} (${requests} req)`);
|
||||||
|
}
|
||||||
|
|
||||||
const lu = $('overviewLastUpdated');
|
const lu = $('overviewLastUpdated');
|
||||||
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard),
|
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard),
|
||||||
// ui/modal.js (openModal, closeModal, showModalError)
|
// ui/modal.js (openModal, closeModal, showModalError)
|
||||||
|
|
||||||
|
function _updateServerBulkBar() {
|
||||||
|
const checked = document.querySelectorAll('#swarmsTable input[type="checkbox"]:checked');
|
||||||
|
const bar = $('serverBulkBar');
|
||||||
|
const countEl = $('serverBulkCount');
|
||||||
|
if (!bar) return;
|
||||||
|
if (checked.length > 0) {
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
if (countEl) countEl.textContent = checked.length + ' selected';
|
||||||
|
} else {
|
||||||
|
bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateSwarmsTable(state) {
|
function updateSwarmsTable(state) {
|
||||||
const tbody = $('swarmsTable');
|
const tbody = $('swarmsTable');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
@@ -8,13 +21,14 @@ function updateSwarmsTable(state) {
|
|||||||
|
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr><td colspan="5">
|
<tr><td colspan="6">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
|
||||||
<div class="empty-state-title">No server tunnels</div>
|
<div class="empty-state-title">No server tunnels</div>
|
||||||
<div class="empty-state-desc">Click "New Server" to expose a local port as an hs:// tunnel</div>
|
<div class="empty-state-desc">Click "New Server" to expose a local port as an hs:// tunnel</div>
|
||||||
</div>
|
</div>
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
|
_updateServerBulkBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +39,7 @@ function updateSwarmsTable(state) {
|
|||||||
const label = s.label || '';
|
const label = s.label || '';
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
|
<td style="width:32px;"><input type="checkbox" class="server-row-cb" data-server-id="${safeId}"></td>
|
||||||
<td>
|
<td>
|
||||||
${label ? `<div style="font-weight:600;color:var(--text);margin-bottom:2px;">${escapeHtml(label)}</div>` : ''}
|
${label ? `<div style="font-weight:600;color:var(--text);margin-bottom:2px;">${escapeHtml(label)}</div>` : ''}
|
||||||
<div class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(id, 20))}</div>
|
<div class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(id, 20))}</div>
|
||||||
@@ -57,6 +72,10 @@ function updateSwarmsTable(state) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.server-row-cb').forEach(cb => {
|
||||||
|
cb.addEventListener('change', _updateServerBulkBar);
|
||||||
|
});
|
||||||
|
|
||||||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||||||
});
|
});
|
||||||
@@ -94,9 +113,30 @@ function updateSwarmsTable(state) {
|
|||||||
openModal('modal-stopServer');
|
openModal('modal-stopServer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_updateServerBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupServerEvents() {
|
function setupServerEvents() {
|
||||||
|
$('serverSelectAll')?.addEventListener('change', (e) => {
|
||||||
|
document.querySelectorAll('#swarmsTable .server-row-cb').forEach(cb => { cb.checked = e.target.checked; });
|
||||||
|
_updateServerBulkBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btnServerBulkStop')?.addEventListener('click', () => {
|
||||||
|
const checked = Array.from(document.querySelectorAll('#swarmsTable .server-row-cb:checked'));
|
||||||
|
if (!checked.length) return;
|
||||||
|
const ids = checked.map(cb => cb.dataset.serverId);
|
||||||
|
if (!confirm('Stop ' + ids.length + ' server(s)?')) return;
|
||||||
|
let done = 0;
|
||||||
|
for (const serverId of ids) {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId } } },
|
||||||
|
() => { done++; if (done === ids.length) { showToast(ids.length + ' server(s) stopped', 'success'); refresh(); } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('startServerBtn')?.addEventListener('click', () => {
|
$('startServerBtn')?.addEventListener('click', () => {
|
||||||
const editIdEl = $('serverEditId');
|
const editIdEl = $('serverEditId');
|
||||||
if (editIdEl) editIdEl.value = '';
|
if (editIdEl) editIdEl.value = '';
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
|
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
|
||||||
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
|
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
|
||||||
|
|
||||||
|
function _updateSvcBulkBar() {
|
||||||
|
const checked = document.querySelectorAll('#serviceTunnelsTable input[type="checkbox"]:checked');
|
||||||
|
const bar = $('svcBulkBar');
|
||||||
|
const countEl = $('svcBulkCount');
|
||||||
|
if (!bar) return;
|
||||||
|
if (checked.length > 0) {
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
if (countEl) countEl.textContent = checked.length + ' selected';
|
||||||
|
} else {
|
||||||
|
bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateServiceTunnelsTable(state) {
|
function updateServiceTunnelsTable(state) {
|
||||||
const tbody = $('serviceTunnelsTable');
|
const tbody = $('serviceTunnelsTable');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
@@ -11,13 +24,14 @@ function updateServiceTunnelsTable(state) {
|
|||||||
|
|
||||||
if (tunnels.length === 0) {
|
if (tunnels.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr><td colspan="5">
|
<tr><td colspan="6">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
|
||||||
<div class="empty-state-title">No service tunnels</div>
|
<div class="empty-state-title">No service tunnels</div>
|
||||||
<div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div>
|
<div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div>
|
||||||
</div>
|
</div>
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
|
_updateSvcBulkBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +43,7 @@ function updateServiceTunnelsTable(state) {
|
|||||||
const safeId = id.replace(/"/g, '"');
|
const safeId = id.replace(/"/g, '"');
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
|
<td style="width:32px;"><input type="checkbox" class="svc-row-cb" data-tunnel-id="${safeId}"></td>
|
||||||
<td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td>
|
<td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:6px;">
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
@@ -48,7 +63,10 @@ function updateServiceTunnelsTable(state) {
|
|||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>${stateTag(t.state)}</td>
|
<td>
|
||||||
|
${stateTag(t.state)}
|
||||||
|
${t.localPort != null ? `<span class="latency-badge" data-ping-host="127.0.0.1" data-ping-port="${t.localPort}" style="margin-left:4px;font-size:10px;color:var(--text4);">…ms</span>` : ''}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:6px;">
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
${(t.state === 'error' || t.state === 'closed') ? `
|
${(t.state === 'error' || t.state === 'closed') ? `
|
||||||
@@ -69,6 +87,10 @@ function updateServiceTunnelsTable(state) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.svc-row-cb').forEach(cb => {
|
||||||
|
cb.addEventListener('change', _updateSvcBulkBar);
|
||||||
|
});
|
||||||
|
|
||||||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||||||
});
|
});
|
||||||
@@ -123,9 +145,30 @@ function updateServiceTunnelsTable(state) {
|
|||||||
openModal('modal-removeServiceTunnel');
|
openModal('modal-removeServiceTunnel');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_updateSvcBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupServiceTunnelEvents() {
|
function setupServiceTunnelEvents() {
|
||||||
|
$('svcSelectAll')?.addEventListener('change', (e) => {
|
||||||
|
document.querySelectorAll('#serviceTunnelsTable .svc-row-cb').forEach(cb => { cb.checked = e.target.checked; });
|
||||||
|
_updateSvcBulkBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btnSvcBulkRemove')?.addEventListener('click', () => {
|
||||||
|
const checked = Array.from(document.querySelectorAll('#serviceTunnelsTable .svc-row-cb:checked'));
|
||||||
|
if (!checked.length) return;
|
||||||
|
const ids = checked.map(cb => cb.dataset.tunnelId);
|
||||||
|
if (!confirm('Remove ' + ids.length + ' service tunnel(s)?')) return;
|
||||||
|
let done = 0;
|
||||||
|
for (const tunnelId of ids) {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
|
||||||
|
() => { done++; if (done === ids.length) { showToast(ids.length + ' tunnel(s) removed', 'success'); refresh(); } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('addServiceTunnelBtn')?.addEventListener('click', () => {
|
$('addServiceTunnelBtn')?.addEventListener('click', () => {
|
||||||
$('serviceTunnelEditId').value = '';
|
$('serviceTunnelEditId').value = '';
|
||||||
$('serviceTunnelLabel').value = '';
|
$('serviceTunnelLabel').value = '';
|
||||||
|
|||||||
@@ -2,23 +2,31 @@
|
|||||||
|
|
||||||
function updateSettingsUI() {
|
function updateSettingsUI() {
|
||||||
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
|
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
|
||||||
|
$('toggleNotifyTunnelError')?.classList.toggle('active', settings.notifyOnTunnelError !== false);
|
||||||
|
$('toggleTunnelAutoReconnect')?.classList.toggle('active', settings.tunnelAutoReconnect === true);
|
||||||
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
|
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
|
||||||
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
|
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
|
||||||
|
$('toggleTheme')?.classList.toggle('active', document.documentElement.getAttribute('data-theme') === 'light');
|
||||||
const proxyPortEl = $('proxyPort');
|
const proxyPortEl = $('proxyPort');
|
||||||
const readyTimeoutMsEl = $('readyTimeoutMs');
|
const readyTimeoutMsEl = $('readyTimeoutMs');
|
||||||
const backupRetentionEl = $('backupRetention');
|
const backupRetentionEl = $('backupRetention');
|
||||||
|
const backupIntervalEl = $('backupIntervalHours');
|
||||||
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
|
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
|
||||||
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
|
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
|
||||||
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
|
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
|
||||||
|
if (backupIntervalEl) backupIntervalEl.value = settings.backupIntervalHours ?? SETTINGS_DEFAULTS.backupIntervalHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
settings.notifyOnDisconnect = $('toggleNotify')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnDisconnect;
|
settings.notifyOnDisconnect = $('toggleNotify')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnDisconnect;
|
||||||
|
settings.notifyOnTunnelError = $('toggleNotifyTunnelError')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnTunnelError;
|
||||||
|
settings.tunnelAutoReconnect = $('toggleTunnelAutoReconnect')?.classList.contains('active') ?? SETTINGS_DEFAULTS.tunnelAutoReconnect;
|
||||||
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
|
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
|
||||||
settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls;
|
settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls;
|
||||||
settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort;
|
settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort;
|
||||||
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
|
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
|
||||||
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
|
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
|
||||||
|
settings.backupIntervalHours = Math.max(0, parseInt($('backupIntervalHours')?.value, 10) || 0);
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
||||||
(response) => {
|
(response) => {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
let sshConnections = [];
|
let sshConnections = [];
|
||||||
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable }
|
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable }
|
||||||
|
let _sshReconnectTimer = null;
|
||||||
|
const SSH_RECONNECT_BASE_MS = 3000;
|
||||||
|
const SSH_RECONNECT_MAX_MS = 60000;
|
||||||
|
|
||||||
function generateSshId() {
|
function generateSshId() {
|
||||||
return 'ssh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
|
return 'ssh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
|
||||||
@@ -70,6 +73,7 @@ function renderSshGrid() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="ssh-conn-info">
|
<div class="ssh-conn-info">
|
||||||
<div class="ssh-conn-label">${escapeHtml(conn.label || conn.username + '@ssh')}</div>
|
<div class="ssh-conn-label">${escapeHtml(conn.label || conn.username + '@ssh')}</div>
|
||||||
|
${conn.autoReconnect ? '<div style="font-size:10.5px;color:var(--cyan);margin-top:2px;">Auto-reconnect enabled</div>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="ssh-conn-actions">
|
<div class="ssh-conn-actions">
|
||||||
<button class="btn btn-primary" data-ssh-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
|
<button class="btn btn-primary" data-ssh-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
|
||||||
@@ -120,6 +124,8 @@ function openAddSshModal(conn) {
|
|||||||
$('sshConnHsUrl').value = conn ? conn.hsUrl : '';
|
$('sshConnHsUrl').value = conn ? conn.hsUrl : '';
|
||||||
$('sshConnUsername').value = conn ? conn.username : '';
|
$('sshConnUsername').value = conn ? conn.username : '';
|
||||||
$('sshConnPassword').value = conn ? (conn.password || '') : '';
|
$('sshConnPassword').value = conn ? (conn.password || '') : '';
|
||||||
|
const arEl = $('sshConnAutoReconnect');
|
||||||
|
if (arEl) arEl.checked = conn ? !!conn.autoReconnect : false;
|
||||||
$('sshConnEditId').value = conn ? conn.id : '';
|
$('sshConnEditId').value = conn ? conn.id : '';
|
||||||
$('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
|
$('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
|
||||||
openModal('modal-addSsh');
|
openModal('modal-addSsh');
|
||||||
@@ -261,11 +267,24 @@ async function connectSsh(conn) {
|
|||||||
term.write(data);
|
term.write(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let _reconnectDelay = SSH_RECONNECT_BASE_MS;
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
$('termStatusDot').className = 'terminal-status-dot disconnected';
|
$('termStatusDot').className = 'terminal-status-dot disconnected';
|
||||||
$('termStateDisplay').textContent = 'Disconnected';
|
$('termStateDisplay').textContent = 'Disconnected';
|
||||||
$('termStateDisplay').style.color = 'var(--text3)';
|
$('termStateDisplay').style.color = 'var(--text3)';
|
||||||
term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m');
|
term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m');
|
||||||
|
if (conn.autoReconnect && activeSshSession) {
|
||||||
|
const delay = _reconnectDelay;
|
||||||
|
_reconnectDelay = Math.min(_reconnectDelay * 2, SSH_RECONNECT_MAX_MS);
|
||||||
|
term.writeln('\x1b[33mAuto-reconnect in ' + Math.round(delay / 1000) + 's…\x1b[0m');
|
||||||
|
$('termStateDisplay').textContent = 'Reconnecting…';
|
||||||
|
$('termStateDisplay').style.color = 'var(--amber)';
|
||||||
|
if (_sshReconnectTimer) clearTimeout(_sshReconnectTimer);
|
||||||
|
_sshReconnectTimer = setTimeout(() => {
|
||||||
|
_sshReconnectTimer = null;
|
||||||
|
if (activeSshSession && conn.autoReconnect) connectSsh(conn);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
@@ -299,6 +318,7 @@ async function disconnectSsh() {
|
|||||||
if (!activeSshSession) return;
|
if (!activeSshSession) return;
|
||||||
const { sessionId, ws, term, fitAddon, resizeObserver, dataDisposable } = activeSshSession;
|
const { sessionId, ws, term, fitAddon, resizeObserver, dataDisposable } = activeSshSession;
|
||||||
activeSshSession = null;
|
activeSshSession = null;
|
||||||
|
if (_sshReconnectTimer) { clearTimeout(_sshReconnectTimer); _sshReconnectTimer = null; }
|
||||||
|
|
||||||
if (resizeObserver) resizeObserver.disconnect();
|
if (resizeObserver) resizeObserver.disconnect();
|
||||||
if (dataDisposable) dataDisposable.dispose();
|
if (dataDisposable) dataDisposable.dispose();
|
||||||
@@ -323,13 +343,14 @@ function setupSshEvents() {
|
|||||||
if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; }
|
if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; }
|
||||||
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; }
|
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; }
|
||||||
|
|
||||||
|
const autoReconnect = !!$('sshConnAutoReconnect')?.checked;
|
||||||
if (editId) {
|
if (editId) {
|
||||||
const idx = sshConnections.findIndex(c => c.id === editId);
|
const idx = sshConnections.findIndex(c => c.id === editId);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password };
|
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password, autoReconnect };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password });
|
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password, autoReconnect });
|
||||||
}
|
}
|
||||||
saveSshConnections();
|
saveSshConnections();
|
||||||
closeModal('modal-addSsh');
|
closeModal('modal-addSsh');
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal),
|
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal),
|
||||||
// data/hostname-validator.js (isValidVhostHostname)
|
// data/hostname-validator.js (isValidVhostHostname)
|
||||||
|
|
||||||
|
function _updateVhostBulkBar() {
|
||||||
|
const checked = document.querySelectorAll('#connectionsTable input[type="checkbox"]:checked');
|
||||||
|
const bar = $('vhostBulkBar');
|
||||||
|
const countEl = $('vhostBulkCount');
|
||||||
|
if (!bar) return;
|
||||||
|
if (checked.length > 0) {
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
if (countEl) countEl.textContent = checked.length + ' selected';
|
||||||
|
} else {
|
||||||
|
bar.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateConnectionsTable(state) {
|
function updateConnectionsTable(state) {
|
||||||
const tbody = $('connectionsTable');
|
const tbody = $('connectionsTable');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
@@ -9,13 +22,14 @@ function updateConnectionsTable(state) {
|
|||||||
|
|
||||||
if (virtualHosts.length === 0) {
|
if (virtualHosts.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr><td colspan="5">
|
<tr><td colspan="6">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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>
|
||||||
<div class="empty-state-title">No virtual hosts</div>
|
<div class="empty-state-title">No virtual hosts</div>
|
||||||
<div class="empty-state-desc">Click "Add Host" to assign a hostname to an hs:// tunnel</div>
|
<div class="empty-state-desc">Click "Add Host" to assign a hostname to an hs:// tunnel</div>
|
||||||
</div>
|
</div>
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
|
_updateVhostBulkBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +42,7 @@ function updateConnectionsTable(state) {
|
|||||||
const needsReconnect = v.state === 'error' || v.state === 'closed';
|
const needsReconnect = v.state === 'error' || v.state === 'closed';
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
|
<td style="width:32px;"><input type="checkbox" class="vhost-row-cb" data-hostname="${safeHostname}"></td>
|
||||||
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:6px;">
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
@@ -39,7 +54,10 @@ function updateConnectionsTable(state) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
|
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
|
||||||
<td>${stateTag(v.state)}</td>
|
<td>
|
||||||
|
${stateTag(v.state)}
|
||||||
|
${v.localPort != null ? `<span class="latency-badge" data-ping-host="127.0.0.1" data-ping-port="${v.localPort}" style="margin-left:4px;font-size:10px;color:var(--text4);">…ms</span>` : ''}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:6px;">
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
<a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
|
<a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
|
||||||
@@ -59,6 +77,10 @@ function updateConnectionsTable(state) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.vhost-row-cb').forEach(cb => {
|
||||||
|
cb.addEventListener('change', _updateVhostBulkBar);
|
||||||
|
});
|
||||||
|
|
||||||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||||||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||||||
});
|
});
|
||||||
@@ -92,9 +114,30 @@ function updateConnectionsTable(state) {
|
|||||||
openModal('modal-removeVhost');
|
openModal('modal-removeVhost');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_updateVhostBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupVirtualHostEvents() {
|
function setupVirtualHostEvents() {
|
||||||
|
$('vhostSelectAll')?.addEventListener('change', (e) => {
|
||||||
|
document.querySelectorAll('#connectionsTable .vhost-row-cb').forEach(cb => { cb.checked = e.target.checked; });
|
||||||
|
_updateVhostBulkBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('btnVhostBulkRemove')?.addEventListener('click', () => {
|
||||||
|
const checked = Array.from(document.querySelectorAll('#connectionsTable .vhost-row-cb:checked'));
|
||||||
|
if (!checked.length) return;
|
||||||
|
const hostnames = checked.map(cb => cb.dataset.hostname);
|
||||||
|
if (!confirm('Remove ' + hostnames.length + ' virtual host(s)?')) return;
|
||||||
|
let done = 0;
|
||||||
|
for (const hostname of hostnames) {
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
|
||||||
|
() => { done++; if (done === hostnames.length) { showToast(hostnames.length + ' host(s) removed', 'success'); refresh(); } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
|
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
|
||||||
|
|
||||||
$('addVhostSubmit')?.addEventListener('click', () => {
|
$('addVhostSubmit')?.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -10,6 +10,31 @@
|
|||||||
// pages/settings.js (updateSettingsUI),
|
// pages/settings.js (updateSettingsUI),
|
||||||
// pages/backups.js (refreshBackups)
|
// pages/backups.js (refreshBackups)
|
||||||
|
|
||||||
|
function _pingLatencyBadges() {
|
||||||
|
const badges = document.querySelectorAll('.latency-badge[data-ping-port]');
|
||||||
|
badges.forEach(badge => {
|
||||||
|
const port = parseInt(badge.dataset.pingPort, 10);
|
||||||
|
const host = badge.dataset.pingHost || '127.0.0.1';
|
||||||
|
if (!port) return;
|
||||||
|
const t0 = Date.now();
|
||||||
|
chrome.runtime.sendMessage(
|
||||||
|
{ target: 'holesail-native', action: 'send', payload: { type: 'pingTunnel', payload: { host, port } } },
|
||||||
|
(response) => {
|
||||||
|
if (!badge.isConnected) return;
|
||||||
|
if (response && response.ok) {
|
||||||
|
const ms = response.latencyMs;
|
||||||
|
const color = ms < 50 ? 'var(--green)' : ms < 200 ? 'var(--amber)' : 'var(--red)';
|
||||||
|
badge.style.color = color;
|
||||||
|
badge.textContent = ms + 'ms';
|
||||||
|
} else {
|
||||||
|
badge.style.color = 'var(--text4)';
|
||||||
|
badge.textContent = '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const state = await fetchState();
|
const state = await fetchState();
|
||||||
if (state) {
|
if (state) {
|
||||||
@@ -51,4 +76,6 @@ async function refresh() {
|
|||||||
const sshCountEl = $('sshCount');
|
const sshCountEl = $('sshCount');
|
||||||
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
||||||
refreshBackups();
|
refreshBackups();
|
||||||
|
// Ping latency badges after a short delay so the DOM has been updated
|
||||||
|
setTimeout(_pingLatencyBadges, 200);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
"declarativeNetRequest",
|
"declarativeNetRequest",
|
||||||
"proxy"
|
"proxy"
|
||||||
],
|
],
|
||||||
|
"commands": {
|
||||||
|
"_execute_action": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Alt+Shift+H",
|
||||||
|
"mac": "Alt+Shift+H"
|
||||||
|
},
|
||||||
|
"description": "Open Holesail Dashboard"
|
||||||
|
}
|
||||||
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_title": "Holesail Dashboard",
|
"default_title": "Holesail Dashboard",
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ function saveState() {
|
|||||||
settingsModule.init(saveState);
|
settingsModule.init(saveState);
|
||||||
connectionsModule.init(saveState);
|
connectionsModule.init(saveState);
|
||||||
serversModule.init(saveState, settingsModule.getReadyTimeoutMs);
|
serversModule.init(saveState, settingsModule.getReadyTimeoutMs);
|
||||||
vhostsModule.init(saveState, emit, settingsModule.getReadyTimeoutMs);
|
vhostsModule.init(saveState, emit, settingsModule.getReadyTimeoutMs, settingsModule.getTunnelAutoReconnect);
|
||||||
svcModule.init(saveState, emit, settingsModule.getReadyTimeoutMs);
|
svcModule.init(saveState, emit, settingsModule.getReadyTimeoutMs, settingsModule.getTunnelAutoReconnect);
|
||||||
|
|
||||||
// ── Storage path ──────────────────────────────────────────────────────────────
|
// ── Storage path ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,39 @@ function debugLog(...args) {
|
|||||||
if (process.stderr) process.stderr.write(msg + '\n');
|
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 nextServiceTunnelId = 0;
|
||||||
let _saveState = null;
|
let _saveState = null;
|
||||||
let _emit = null;
|
let _emit = null;
|
||||||
let _getReadyTimeoutMs = 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;
|
_saveState = saveStateFn;
|
||||||
_emit = emitFn;
|
_emit = emitFn;
|
||||||
_getReadyTimeoutMs = getReadyTimeoutMsFn;
|
_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) {
|
function applyLoaded(loadedNextServiceTunnelId) {
|
||||||
@@ -67,17 +90,17 @@ async function startServiceTunnel(payload) {
|
|||||||
hs.on('error', (err) => {
|
hs.on('error', (err) => {
|
||||||
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
|
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
|
||||||
const t = serviceTunnels.get(tunnelId);
|
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 });
|
if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
|
||||||
});
|
});
|
||||||
hs.on('close', () => {
|
hs.on('close', () => {
|
||||||
const t = serviceTunnels.get(tunnelId);
|
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 });
|
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await readyWithTimeout(hs, 'svc:' + tunnelId);
|
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 (_emit) _emit('serviceTunnelReady', { tunnelId, label, localPort });
|
||||||
if (_saveState) _saveState();
|
if (_saveState) _saveState();
|
||||||
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
|
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
|
||||||
@@ -96,6 +119,7 @@ async function stopServiceTunnel(payload) {
|
|||||||
debugLog('stopServiceTunnel: id=', tunnelId);
|
debugLog('stopServiceTunnel: id=', tunnelId);
|
||||||
const entry = serviceTunnels.get(tunnelId);
|
const entry = serviceTunnels.get(tunnelId);
|
||||||
if (!entry) return { ok: false, error: 'Service tunnel not found' };
|
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 (_) {} }
|
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
|
||||||
serviceTunnels.delete(tunnelId);
|
serviceTunnels.delete(tunnelId);
|
||||||
if (_saveState) _saveState();
|
if (_saveState) _saveState();
|
||||||
@@ -115,6 +139,7 @@ function getNextServiceTunnelId() { return nextServiceTunnelId; }
|
|||||||
|
|
||||||
async function cleanupServiceTunnels() {
|
async function cleanupServiceTunnels() {
|
||||||
for (const [, t] of serviceTunnels) {
|
for (const [, t] of serviceTunnels) {
|
||||||
|
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
|
||||||
if (t.holesail) { try { await t.holesail.close(); } catch (_) {} }
|
if (t.holesail) { try { await t.holesail.close(); } catch (_) {} }
|
||||||
}
|
}
|
||||||
serviceTunnels.clear();
|
serviceTunnels.clear();
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ function updateSettings(patch) {
|
|||||||
}
|
}
|
||||||
if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs;
|
if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs;
|
||||||
if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect;
|
if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect;
|
||||||
|
if (typeof patch.notifyOnTunnelError === 'boolean') currentSettings.notifyOnTunnelError = patch.notifyOnTunnelError;
|
||||||
if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug;
|
if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug;
|
||||||
if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls;
|
if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls;
|
||||||
if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention;
|
if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention;
|
||||||
|
if (typeof patch.backupIntervalHours === 'number') currentSettings.backupIntervalHours = patch.backupIntervalHours;
|
||||||
|
if (typeof patch.tunnelAutoReconnect === 'boolean') currentSettings.tunnelAutoReconnect = patch.tunnelAutoReconnect;
|
||||||
if (_saveState) _saveState();
|
if (_saveState) _saveState();
|
||||||
return { requiresRestart };
|
return { requiresRestart };
|
||||||
}
|
}
|
||||||
@@ -49,5 +52,6 @@ function setProxyPort(port) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getReadyTimeoutMs() { return currentSettings.readyTimeoutMs; }
|
function getReadyTimeoutMs() { return currentSettings.readyTimeoutMs; }
|
||||||
|
function getTunnelAutoReconnect() { return !!currentSettings.tunnelAutoReconnect; }
|
||||||
|
|
||||||
module.exports = { init, applyLoaded, getSettings, updateSettings, getProxyPort, setProxyPort, getReadyTimeoutMs };
|
module.exports = { init, applyLoaded, getSettings, updateSettings, getProxyPort, setProxyPort, getReadyTimeoutMs, getTunnelAutoReconnect };
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ const LEGACY_PERSIST_FILENAME = 'holesail-persist.json';
|
|||||||
const SETTINGS_DEFAULTS = {
|
const SETTINGS_DEFAULTS = {
|
||||||
proxyPort: 8443,
|
proxyPort: 8443,
|
||||||
connectProxyPort: 8442,
|
connectProxyPort: 8442,
|
||||||
readyTimeoutMs: 0,
|
readyTimeoutMs: 30000,
|
||||||
notifyOnDisconnect: true,
|
notifyOnDisconnect: true,
|
||||||
|
notifyOnTunnelError: true,
|
||||||
debug: false,
|
debug: false,
|
||||||
disableOnFileUrls: false,
|
disableOnFileUrls: false,
|
||||||
backupRetention: 5
|
backupRetention: 5,
|
||||||
|
backupIntervalHours: 0,
|
||||||
|
tunnelAutoReconnect: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
|
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
|
||||||
|
|||||||
@@ -19,15 +19,38 @@ function debugLog(...args) {
|
|||||||
if (process.stderr) process.stderr.write(msg + '\n');
|
if (process.stderr) process.stderr.write(msg + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt }
|
const virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt, reconnectTimer, reconnectDelay }
|
||||||
let _saveState = null;
|
let _saveState = null;
|
||||||
let _emit = null;
|
let _emit = null;
|
||||||
let _getReadyTimeoutMs = 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;
|
_saveState = saveStateFn;
|
||||||
_emit = emitFn;
|
_emit = emitFn;
|
||||||
_getReadyTimeoutMs = getReadyTimeoutMsFn;
|
_getReadyTimeoutMs = getReadyTimeoutMsFn;
|
||||||
|
_getAutoReconnect = getAutoReconnectFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scheduleVhostReconnect(hostname) {
|
||||||
|
const v = virtualHosts.get(hostname);
|
||||||
|
if (!v) return;
|
||||||
|
if (v.reconnectTimer) { clearTimeout(v.reconnectTimer); v.reconnectTimer = null; }
|
||||||
|
const autoReconnect = _getAutoReconnect ? _getAutoReconnect() : false;
|
||||||
|
if (!autoReconnect) return;
|
||||||
|
const delay = v.reconnectDelay || RECONNECT_BASE_MS;
|
||||||
|
v.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS);
|
||||||
|
if (process.stderr) process.stderr.write('[holesail-manager] vhost ' + hostname + ' reconnecting in ' + Math.round(delay / 1000) + 's\n');
|
||||||
|
v.reconnectTimer = setTimeout(async () => {
|
||||||
|
v.reconnectTimer = null;
|
||||||
|
const cur = virtualHosts.get(hostname);
|
||||||
|
if (!cur || cur.state === 'ready') return;
|
||||||
|
if (process.stderr) process.stderr.write('[holesail-manager] vhost ' + hostname + ' auto-reconnect attempt\n');
|
||||||
|
await setVirtualHost({ hostname, hsUrl: cur.hsUrl });
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readyWithTimeout(hs, label) {
|
function readyWithTimeout(hs, label) {
|
||||||
@@ -61,17 +84,18 @@ async function setVirtualHost(payload) {
|
|||||||
hs.on('error', (err) => {
|
hs.on('error', (err) => {
|
||||||
if (process.stderr) process.stderr.write('[holesail-manager] tunnel error ' + hostname + ': ' + (err && err.message) + '\n');
|
if (process.stderr) process.stderr.write('[holesail-manager] tunnel error ' + hostname + ': ' + (err && err.message) + '\n');
|
||||||
const v = virtualHosts.get(hostname);
|
const v = virtualHosts.get(hostname);
|
||||||
if (v && v.holesail === hs) v.state = 'error';
|
if (v && v.holesail === hs) { v.state = 'error'; _scheduleVhostReconnect(hostname); }
|
||||||
if (_emit) _emit('tunnelError', { hostname, error: err && err.message });
|
if (_emit) _emit('tunnelError', { hostname, error: err && err.message });
|
||||||
});
|
});
|
||||||
hs.on('close', () => {
|
hs.on('close', () => {
|
||||||
const v = virtualHosts.get(hostname);
|
const v = virtualHosts.get(hostname);
|
||||||
if (v && v.holesail === hs) v.state = 'closed';
|
if (v && v.holesail === hs) { v.state = 'closed'; _scheduleVhostReconnect(hostname); }
|
||||||
if (_emit) _emit('tunnelClosed', { hostname });
|
if (_emit) _emit('tunnelClosed', { hostname });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await readyWithTimeout(hs, 'vhost:' + hostname);
|
await readyWithTimeout(hs, 'vhost:' + hostname);
|
||||||
virtualHosts.set(hostname, { hsUrl, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now() });
|
const prevReconnectDelay = existing ? existing.reconnectDelay : undefined;
|
||||||
|
virtualHosts.set(hostname, { hsUrl, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
|
||||||
if (_emit) _emit('tunnelReady', { hostname, hsUrl, localHost: TUNNEL_HOST, localPort });
|
if (_emit) _emit('tunnelReady', { hostname, hsUrl, localHost: TUNNEL_HOST, localPort });
|
||||||
if (_saveState) _saveState();
|
if (_saveState) _saveState();
|
||||||
debugLog('setVirtualHost: ok hostname=', hostname, 'localPort=', localPort);
|
debugLog('setVirtualHost: ok hostname=', hostname, 'localPort=', localPort);
|
||||||
@@ -91,6 +115,7 @@ async function removeVirtualHost(payload) {
|
|||||||
debugLog('removeVirtualHost: hostname=', hostname);
|
debugLog('removeVirtualHost: hostname=', hostname);
|
||||||
const entry = virtualHosts.get(hostname);
|
const entry = virtualHosts.get(hostname);
|
||||||
if (!entry) return { ok: false, error: 'Virtual host not found' };
|
if (!entry) return { ok: false, error: 'Virtual host not found' };
|
||||||
|
if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; }
|
||||||
if (entry.localPort) releaseTunnelPort(entry.localPort);
|
if (entry.localPort) releaseTunnelPort(entry.localPort);
|
||||||
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
|
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
|
||||||
virtualHosts.delete(hostname);
|
virtualHosts.delete(hostname);
|
||||||
@@ -129,9 +154,10 @@ function getVirtualHostMap() {
|
|||||||
|
|
||||||
async function cleanupVirtualHosts() {
|
async function cleanupVirtualHosts() {
|
||||||
for (const [, v] of virtualHosts) {
|
for (const [, v] of virtualHosts) {
|
||||||
|
if (v.reconnectTimer) { clearTimeout(v.reconnectTimer); v.reconnectTimer = null; }
|
||||||
if (v.holesail) { try { await v.holesail.close(); } catch (_) {} }
|
if (v.holesail) { try { await v.holesail.close(); } catch (_) {} }
|
||||||
}
|
}
|
||||||
virtualHosts.clear();
|
virtualHosts.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, setVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts };
|
module.exports = { init, setVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts, RECONNECT_BASE_MS, RECONNECT_MAX_MS };
|
||||||
|
|||||||
@@ -25,6 +25,37 @@ httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hos
|
|||||||
|
|
||||||
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
|
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
|
||||||
|
|
||||||
|
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
|
||||||
|
let _scheduledBackupTimer = null;
|
||||||
|
function scheduleNextAutoBackup() {
|
||||||
|
if (_scheduledBackupTimer) { clearTimeout(_scheduledBackupTimer); _scheduledBackupTimer = null; }
|
||||||
|
const settings = holesailManager.getSettings();
|
||||||
|
const hours = typeof settings.backupIntervalHours === 'number' ? settings.backupIntervalHours : 0;
|
||||||
|
if (hours <= 0) return;
|
||||||
|
const ms = hours * 60 * 60 * 1000;
|
||||||
|
_scheduledBackupTimer = setTimeout(async () => {
|
||||||
|
_scheduledBackupTimer = null;
|
||||||
|
log('Scheduled auto-backup starting (interval=' + hours + 'h)');
|
||||||
|
const result = await backupManager.createBackup().catch((e) => ({ ok: false, error: e.message }));
|
||||||
|
if (result.ok) {
|
||||||
|
const retention = (holesailManager.getSettings().backupRetention) || backupManager.DEFAULT_RETENTION;
|
||||||
|
backupManager.pruneOldBackups(retention);
|
||||||
|
log('Scheduled auto-backup complete:', result.filename);
|
||||||
|
} else {
|
||||||
|
log('Scheduled auto-backup failed:', result.error);
|
||||||
|
}
|
||||||
|
scheduleNextAutoBackup();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the auto-backup schedule once proxies are ready.
|
||||||
|
const _proxiesReady = getProxiesReadyPromise();
|
||||||
|
if (_proxiesReady) {
|
||||||
|
_proxiesReady.then(() => scheduleNextAutoBackup()).catch(() => {});
|
||||||
|
} else {
|
||||||
|
scheduleNextAutoBackup();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Function} send - messenger.send(msg)
|
* @param {Function} send - messenger.send(msg)
|
||||||
* @param {object} msg - { id, type, payload }
|
* @param {object} msg - { id, type, payload }
|
||||||
@@ -78,7 +109,8 @@ async function handleMessageAsync(send, msg) {
|
|||||||
caInstalled,
|
caInstalled,
|
||||||
settings,
|
settings,
|
||||||
sshConnections,
|
sshConnections,
|
||||||
rdpConnections
|
rdpConnections,
|
||||||
|
trafficStats: httpsProxy.getTrafficStats()
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -89,6 +121,7 @@ async function handleMessageAsync(send, msg) {
|
|||||||
case 'updateSettings': {
|
case 'updateSettings': {
|
||||||
const { requiresRestart } = holesailManager.updateSettings(payload);
|
const { requiresRestart } = holesailManager.updateSettings(payload);
|
||||||
debugLog('updateSettings: applied', JSON.stringify(payload), 'requiresRestart=', requiresRestart);
|
debugLog('updateSettings: applied', JSON.stringify(payload), 'requiresRestart=', requiresRestart);
|
||||||
|
scheduleNextAutoBackup();
|
||||||
reply({ ok: true, settings: holesailManager.getSettings(), requiresRestart });
|
reply({ ok: true, settings: holesailManager.getSettings(), requiresRestart });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -264,6 +297,32 @@ async function handleMessageAsync(send, msg) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'pingTunnel': {
|
||||||
|
// Measure TCP connect latency to a local tunnel port.
|
||||||
|
// payload: { host, port }
|
||||||
|
const pingHost = payload.host || '127.0.0.1';
|
||||||
|
const pingPort = typeof payload.port === 'number' ? payload.port : null;
|
||||||
|
if (!pingPort) { reply({ ok: false, error: 'port required' }); break; }
|
||||||
|
const tcp = require('bare-tcp');
|
||||||
|
const t0 = Date.now();
|
||||||
|
const sock = tcp.connect(pingPort, pingHost);
|
||||||
|
const pingTimeout = setTimeout(() => {
|
||||||
|
try { sock.destroy(); } catch (_) {}
|
||||||
|
reply({ ok: false, error: 'timeout' });
|
||||||
|
}, 3000);
|
||||||
|
sock.on('connect', () => {
|
||||||
|
clearTimeout(pingTimeout);
|
||||||
|
const latencyMs = Date.now() - t0;
|
||||||
|
try { sock.destroy(); } catch (_) {}
|
||||||
|
reply({ ok: true, latencyMs });
|
||||||
|
});
|
||||||
|
sock.on('error', (err) => {
|
||||||
|
clearTimeout(pingTimeout);
|
||||||
|
reply({ ok: false, error: err.message });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
reply({ ok: false, error: `Unknown command: ${type}` });
|
reply({ ok: false, error: `Unknown command: ${type}` });
|
||||||
}
|
}
|
||||||
@@ -276,6 +335,7 @@ async function handleMessageAsync(send, msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
|
if (_scheduledBackupTimer) { clearTimeout(_scheduledBackupTimer); _scheduledBackupTimer = null; }
|
||||||
httpsProxy.stop(() => {});
|
httpsProxy.stop(() => {});
|
||||||
connectProxy.stop(() => {});
|
connectProxy.stop(() => {});
|
||||||
sshManager.cleanup();
|
sshManager.cleanup();
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ let proxyServer = null;
|
|||||||
let proxyPort = null;
|
let proxyPort = null;
|
||||||
let proxyCertsDirOrCA = null;
|
let proxyCertsDirOrCA = null;
|
||||||
|
|
||||||
|
const trafficStats = { bytesIn: 0, bytesOut: 0, requests: 0 };
|
||||||
|
|
||||||
|
function getTrafficStats() { return { ...trafficStats }; }
|
||||||
|
function resetTrafficStats() { trafficStats.bytesIn = 0; trafficStats.bytesOut = 0; trafficStats.requests = 0; }
|
||||||
|
|
||||||
/** Resolver: hostname -> { host, port } | port | null */
|
/** Resolver: hostname -> { host, port } | port | null */
|
||||||
let getBackendForHostname = null;
|
let getBackendForHostname = null;
|
||||||
|
|
||||||
@@ -313,7 +318,7 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
|
|||||||
try { res.setHeader(k, proxyRes.headers[k]); } catch (_) {}
|
try { res.setHeader(k, proxyRes.headers[k]); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxyRes.on('data', (chunk) => !timedOut && res.write(chunk));
|
proxyRes.on('data', (chunk) => { if (!timedOut) { trafficStats.bytesOut += chunk.length; res.write(chunk); } });
|
||||||
proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end());
|
proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end());
|
||||||
// Destroy the client socket on a mid-stream backend error so the browser
|
// Destroy the client socket on a mid-stream backend error so the browser
|
||||||
// receives a connection reset rather than a silently truncated 200 body.
|
// receives a connection reset rather than a silently truncated 200 body.
|
||||||
@@ -327,7 +332,8 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
|
|||||||
res.end('Proxy error: ' + (timedOut ? 'timeout' : err.message));
|
res.end('Proxy error: ' + (timedOut ? 'timeout' : err.message));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
req.on('data', (chunk) => proxyReq.write(chunk));
|
trafficStats.requests++;
|
||||||
|
req.on('data', (chunk) => { trafficStats.bytesIn += chunk.length; proxyReq.write(chunk); });
|
||||||
req.on('end', () => proxyReq.end());
|
req.on('end', () => proxyReq.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +373,8 @@ function onUpgrade (req, socket, head) {
|
|||||||
const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
|
const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
|
||||||
upstream.write(requestLine + headers + '\r\n\r\n');
|
upstream.write(requestLine + headers + '\r\n\r\n');
|
||||||
if (head && head.length > 0) upstream.write(head);
|
if (head && head.length > 0) upstream.write(head);
|
||||||
|
socket.on('data', (c) => { trafficStats.bytesIn += c.length; });
|
||||||
|
upstream.on('data', (c) => { trafficStats.bytesOut += c.length; });
|
||||||
socket.pipe(upstream);
|
socket.pipe(upstream);
|
||||||
upstream.pipe(socket);
|
upstream.pipe(socket);
|
||||||
});
|
});
|
||||||
@@ -595,5 +603,7 @@ module.exports = {
|
|||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
restart,
|
restart,
|
||||||
getPort
|
getPort,
|
||||||
|
getTrafficStats,
|
||||||
|
resetTrafficStats
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user