CI / Build & Test (push) Successful in 3m1s
Snapshot checked row IDs before each innerHTML swap in the virtual hosts, service tunnels, and server tunnels tables so that periodic refresh cycles no longer reset multi-selection state.
264 lines
12 KiB
JavaScript
264 lines
12 KiB
JavaScript
/**
|
||
* Service Tunnels page — renders the service tunnels table, manages bulk selection,
|
||
* and wires up add/edit/remove modal events.
|
||
* Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
|
||
* 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';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-render the service tunnels table with the latest state.
|
||
* @param {object} state - Full extension state from `fetchState()`.
|
||
*/
|
||
function updateServiceTunnelsTable(state) {
|
||
const tbody = $('serviceTunnelsTable');
|
||
if (!tbody) return;
|
||
const tunnels = state.serviceTunnels || [];
|
||
|
||
const countEl = $('serviceTunnelCount');
|
||
if (countEl) countEl.textContent = tunnels.length;
|
||
|
||
// Snapshot latency badge values before re-render so they survive the innerHTML swap
|
||
const _latencySnapshot = {};
|
||
tbody.querySelectorAll('.latency-badge[data-ping-port]').forEach(b => {
|
||
_latencySnapshot[b.dataset.pingHost + ':' + b.dataset.pingPort] = { text: b.textContent, color: b.style.color };
|
||
});
|
||
|
||
// Snapshot checked checkboxes so selection survives the innerHTML swap
|
||
const _checkedTunnelIds = new Set();
|
||
tbody.querySelectorAll('.svc-row-cb:checked').forEach(cb => _checkedTunnelIds.add(cb.dataset.tunnelId));
|
||
|
||
if (tunnels.length === 0) {
|
||
tbody.innerHTML = `
|
||
<tr><td colspan="6">
|
||
<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>
|
||
<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>
|
||
</td></tr>`;
|
||
_updateSvcBulkBar();
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = tunnels.map(t => {
|
||
const id = t.id || '';
|
||
const label = t.label || id;
|
||
const hsUrl = t.hsUrl || '';
|
||
const localAddr = t.localPort != null ? `127.0.0.1:${t.localPort}` : '—';
|
||
const safeId = id.replace(/"/g, '"');
|
||
return `
|
||
<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>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span class="mono" title="${escapeHtml(hsUrl)}" style="font-size:11px;color:var(--text3);">${truncate(hsUrl, 28)}</span>
|
||
${hsUrl ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// key">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
<span class="copy-tooltip">Copied!</span>
|
||
</button>` : ''}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span style="font-weight:600;color:var(--cyan);font-family:'JetBrains Mono',monospace;font-size:13px;">${escapeHtml(localAddr)}</span>
|
||
${t.localPort != null ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(localAddr)}" title="Copy local address">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
<span class="copy-tooltip">Copied!</span>
|
||
</button>` : ''}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
${stateTag(t.state)}
|
||
${t.localPort != null && settings.latencyPingEnabled !== false ? `<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>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
${(t.state === 'error' || t.state === 'closed') ? `
|
||
<button class="btn btn-ghost btn-sm" data-reconnect-svc="${safeId}" data-hsurl="${escapeHtml(hsUrl)}" data-label="${escapeHtml(label)}" data-localport="${t.localPort != null ? t.localPort : ''}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||
Reconnect
|
||
</button>` : ''}
|
||
<button class="btn btn-ghost btn-sm" data-edit-service-tunnel="${safeId}" title="Edit tunnel">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
Edit
|
||
</button>
|
||
<button class="btn btn-danger btn-sm" data-remove-service-tunnel="${safeId}">
|
||
<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"/></svg>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
// Restore latency badge values immediately so there's no flash back to "…ms"
|
||
tbody.querySelectorAll('.latency-badge[data-ping-port]').forEach(b => {
|
||
const snap = _latencySnapshot[b.dataset.pingHost + ':' + b.dataset.pingPort];
|
||
if (snap && snap.text && snap.text !== '…ms') {
|
||
b.textContent = snap.text;
|
||
b.style.color = snap.color;
|
||
}
|
||
});
|
||
|
||
tbody.querySelectorAll('.svc-row-cb').forEach(cb => {
|
||
if (_checkedTunnelIds.has(cb.dataset.tunnelId)) cb.checked = true;
|
||
cb.addEventListener('change', _updateSvcBulkBar);
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-reconnect-svc]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tunnelId = btn.dataset.reconnectSvc;
|
||
const hsUrl = btn.dataset.hsurl;
|
||
const label = btn.dataset.label;
|
||
const localPort = parseInt(btn.dataset.localport, 10);
|
||
btn.disabled = true;
|
||
btn.textContent = 'Reconnecting…';
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateServiceTunnel', payload: { tunnelId, hsUrl, label, localPort } } },
|
||
(response) => {
|
||
if (chrome.runtime.lastError) { showToast('Reconnect failed: ' + chrome.runtime.lastError.message, 'error'); return; }
|
||
if (response?.ok) {
|
||
showToast('Service tunnel reconnecting…', 'success');
|
||
} else {
|
||
showToast(response?.error || 'Reconnect failed', 'error');
|
||
}
|
||
refresh();
|
||
}
|
||
);
|
||
});
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-edit-service-tunnel]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tunnelId = btn.dataset.editServiceTunnel;
|
||
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
|
||
if (!tunnel) return;
|
||
$('serviceTunnelEditId').value = tunnelId;
|
||
$('serviceTunnelLabel').value = tunnel.label || '';
|
||
$('serviceTunnelHsUrl').value = tunnel.hsUrl || '';
|
||
$('serviceTunnelLocalPort').value = tunnel.localPort != null ? tunnel.localPort : '';
|
||
const titleEl = $('modal-addServiceTunnel-title');
|
||
if (titleEl) titleEl.textContent = 'Edit Service Tunnel';
|
||
const submitEl = $('serviceTunnelSubmit');
|
||
if (submitEl) submitEl.textContent = 'Save';
|
||
openModal('modal-addServiceTunnel');
|
||
});
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-remove-service-tunnel]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tunnelId = btn.dataset.removeServiceTunnel;
|
||
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
|
||
const nameEl = $('removeServiceTunnelName');
|
||
if (nameEl) nameEl.textContent = (tunnel && tunnel.label) || tunnelId;
|
||
$('removeServiceTunnelConfirm').dataset.tunnelId = tunnelId;
|
||
openModal('modal-removeServiceTunnel');
|
||
});
|
||
});
|
||
|
||
_updateSvcBulkBar();
|
||
}
|
||
|
||
/**
|
||
* Attach all event listeners for the Service Tunnels page.
|
||
* Called once during dashboard initialisation.
|
||
*/
|
||
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', () => {
|
||
$('serviceTunnelEditId').value = '';
|
||
$('serviceTunnelLabel').value = '';
|
||
$('serviceTunnelHsUrl').value = '';
|
||
$('serviceTunnelLocalPort').value = '';
|
||
const titleEl = $('modal-addServiceTunnel-title');
|
||
if (titleEl) titleEl.textContent = 'Add Service Tunnel';
|
||
const submitEl = $('serviceTunnelSubmit');
|
||
if (submitEl) submitEl.textContent = 'Connect';
|
||
openModal('modal-addServiceTunnel');
|
||
});
|
||
|
||
$('serviceTunnelSubmit')?.addEventListener('click', () => {
|
||
const label = ($('serviceTunnelLabel')?.value || '').trim();
|
||
const hsUrl = ($('serviceTunnelHsUrl')?.value || '').trim();
|
||
const localPort = parseInt($('serviceTunnelLocalPort')?.value, 10);
|
||
const editId = ($('serviceTunnelEditId')?.value || '').trim();
|
||
|
||
if (!label) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Label is required'); return; }
|
||
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Enter a valid hs:// key'); return; }
|
||
if (!localPort || localPort < 1 || localPort > 65535) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Local port must be 1–65535'); return; }
|
||
|
||
const btn = $('serviceTunnelSubmit');
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Connecting…'; }
|
||
|
||
const type = editId ? 'updateServiceTunnel' : 'startServiceTunnel';
|
||
const payload = editId ? { tunnelId: editId, label, hsUrl, localPort } : { label, hsUrl, localPort };
|
||
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
|
||
(response) => {
|
||
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save' : 'Connect'; }
|
||
if (response?.ok) {
|
||
closeModal('modal-addServiceTunnel');
|
||
showToast(editId ? 'Tunnel updated' : 'Service tunnel connected', 'success');
|
||
refresh();
|
||
} else {
|
||
showModalError('modal-addServiceTunnel', 'serviceTunnelError', response?.error || 'Failed to connect');
|
||
}
|
||
}
|
||
);
|
||
});
|
||
|
||
$('removeServiceTunnelConfirm')?.addEventListener('click', () => {
|
||
const tunnelId = $('removeServiceTunnelConfirm').dataset.tunnelId;
|
||
if (!tunnelId) return;
|
||
const btn = $('removeServiceTunnelConfirm');
|
||
btn.disabled = true; btn.textContent = 'Removing…';
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
|
||
(response) => {
|
||
btn.disabled = false; btn.textContent = 'Remove';
|
||
closeModal('modal-removeServiceTunnel');
|
||
if (response?.ok) showToast('Service tunnel removed', 'success');
|
||
else showToast(response?.error || 'Failed to remove', 'error');
|
||
refresh();
|
||
}
|
||
);
|
||
});
|
||
}
|