CI / Build & Test (push) Successful in 3m19s
- Add unit tests (hostname-validator, TLDs, payload-schemas) and integration tests for message handler registry - Refactor native host message router into handler registry (handlers/state, tunnels, ssh, rdp, backup, ca, connections) - Add ESLint config and npm test + lint steps in CI - Dashboard: visibility-based refresh pause, configurable refresh interval (2s/5s/10s/paused) - Accessibility: ARIA on nav and modals, focus trap and restore, prefers-reduced-motion - Empty states: primary action buttons for virtual hosts, servers, service tunnels - Native host rate limiting for backup and CA operations; update SECURITY.md - CONTRIBUTING: "Adding a new dashboard page", dev workflow; add npm run dev script
239 lines
10 KiB
JavaScript
239 lines
10 KiB
JavaScript
/**
|
||
* Server Tunnels page — renders the server table, manages bulk selection,
|
||
* and wires up start/stop/edit modal events.
|
||
* Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard),
|
||
* 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';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-render the server tunnels table with the latest state.
|
||
* @param {object} state - Full extension state from `fetchState()`.
|
||
*/
|
||
function updateSwarmsTable(state) {
|
||
const tbody = $('swarmsTable');
|
||
if (!tbody) return;
|
||
const servers = state.servers || [];
|
||
|
||
if (servers.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"><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-desc">Expose a local port to the Holesail network as an hs:// key.</div>
|
||
<button type="button" class="btn btn-primary empty-state-btn" data-open-modal="modal-startServer">Add your first server tunnel</button>
|
||
</div>
|
||
</td></tr>`;
|
||
_updateServerBulkBar();
|
||
return;
|
||
}
|
||
|
||
// Snapshot checked checkboxes so selection survives the innerHTML swap
|
||
const _checkedServerIds = new Set();
|
||
tbody.querySelectorAll('.server-row-cb:checked').forEach(cb => _checkedServerIds.add(cb.dataset.serverId));
|
||
|
||
tbody.innerHTML = servers.map(s => {
|
||
const id = s.id || s.serverId || '';
|
||
const url = s.url || s.hsUrl || '';
|
||
const safeId = id.replace(/"/g, '"');
|
||
const label = s.label || '';
|
||
return `
|
||
<tr>
|
||
<td style="width:32px;"><input type="checkbox" class="server-row-cb" data-server-id="${safeId}"></td>
|
||
<td>
|
||
${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>
|
||
</td>
|
||
<td style="font-weight:600;color:var(--text);">${s.port ?? '—'}</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span class="mono" title="${escapeHtml(url)}" style="font-size:11px;color:var(--cyan);">${truncate(url, 30)}</span>
|
||
${url ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(url)}" title="Copy hs:// URL">
|
||
<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>
|
||
<span class="badge badge-neutral" style="margin-right:4px;">${s.udp ? 'UDP' : 'TCP'}</span>${s.secure ? `<span class="badge badge-green">secure</span>` : `<span class="badge badge-neutral">plain</span>`}
|
||
</td>
|
||
<td>
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<button class="btn btn-ghost btn-sm" data-edit-server="${safeId}" title="Edit server">
|
||
<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-stop-server="${safeId}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||
Stop
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
tbody.querySelectorAll('.server-row-cb').forEach(cb => {
|
||
if (_checkedServerIds.has(cb.dataset.serverId)) cb.checked = true;
|
||
cb.addEventListener('change', _updateServerBulkBar);
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-copy]').forEach(btn => {
|
||
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-edit-server]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const serverId = btn.dataset.editServer;
|
||
const server = (state.servers || []).find(s => (s.id || s.serverId) === serverId);
|
||
if (!server) return;
|
||
const serverEditIdEl = $('serverEditId');
|
||
if (serverEditIdEl) serverEditIdEl.value = serverId;
|
||
const labelEl = $('startServerLabel');
|
||
const portEl = $('startServerPort');
|
||
const hostEl = $('startServerHost');
|
||
const secureEl = $('startServerSecure');
|
||
if (labelEl) labelEl.value = server.label || '';
|
||
if (portEl) portEl.value = server.port ?? 3000;
|
||
if (hostEl) hostEl.value = server.host ?? '127.0.0.1';
|
||
if (secureEl) secureEl.checked = server.secure !== false;
|
||
const udpEl = document.querySelector('input[name="startServerProtocol"][value="' + (server.udp ? 'udp' : 'tcp') + '"]');
|
||
if (udpEl) udpEl.checked = true;
|
||
const titleEl = $('modal-startServer-title');
|
||
if (titleEl) titleEl.textContent = 'Edit Server Tunnel';
|
||
const submitEl = $('startServerSubmit');
|
||
if (submitEl) submitEl.textContent = 'Save Changes';
|
||
openModal('modal-startServer');
|
||
});
|
||
});
|
||
|
||
tbody.querySelectorAll('[data-stop-server]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const serverId = btn.dataset.stopServer;
|
||
const nameEl = $('stopServerName');
|
||
if (nameEl) nameEl.textContent = serverId;
|
||
$('stopServerConfirm').dataset.serverId = serverId;
|
||
openModal('modal-stopServer');
|
||
});
|
||
});
|
||
|
||
_updateServerBulkBar();
|
||
}
|
||
|
||
/**
|
||
* Attach all event listeners for the Server Tunnels page.
|
||
* Called once during dashboard initialisation.
|
||
*/
|
||
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', () => {
|
||
const editIdEl = $('serverEditId');
|
||
if (editIdEl) editIdEl.value = '';
|
||
const labelEl = $('startServerLabel');
|
||
if (labelEl) labelEl.value = '';
|
||
const portEl = $('startServerPort');
|
||
if (portEl) portEl.value = '3000';
|
||
const hostEl = $('startServerHost');
|
||
if (hostEl) hostEl.value = '127.0.0.1';
|
||
const secureEl = $('startServerSecure');
|
||
if (secureEl) secureEl.checked = true;
|
||
const tcpEl = $('startServerProtocolTcp');
|
||
if (tcpEl) tcpEl.checked = true;
|
||
const titleEl = $('modal-startServer-title');
|
||
if (titleEl) titleEl.textContent = 'Start Server Tunnel';
|
||
const submitEl = $('startServerSubmit');
|
||
if (submitEl) submitEl.textContent = 'Start Server';
|
||
openModal('modal-startServer');
|
||
});
|
||
|
||
$('startServerSubmit')?.addEventListener('click', () => {
|
||
const portEl = $('startServerPort');
|
||
const hostEl = $('startServerHost');
|
||
const secureEl = $('startServerSecure');
|
||
const labelEl = $('startServerLabel');
|
||
const editIdEl = $('serverEditId');
|
||
const port = parseInt(portEl?.value, 10) || 3000;
|
||
const host = (hostEl?.value || '127.0.0.1').trim();
|
||
const secure = secureEl?.checked !== false;
|
||
const udp = document.querySelector('input[name="startServerProtocol"]:checked')?.value === 'udp';
|
||
const label = (labelEl?.value || '').trim();
|
||
const editId = (editIdEl?.value || '').trim();
|
||
if (!port || port < 1 || port > 65535) { showModalError('modal-startServer', 'startServerError', 'Port must be 1–65535'); return; }
|
||
const btn = $('startServerSubmit');
|
||
if (btn) { btn.disabled = true; btn.textContent = editId ? 'Saving…' : 'Starting…'; }
|
||
|
||
const doStart = () => {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'startServer', payload: { port, host, secure, udp, label } } },
|
||
(response) => {
|
||
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save Changes' : 'Start Server'; }
|
||
if (response?.ok) {
|
||
if (editIdEl) editIdEl.value = '';
|
||
closeModal('modal-startServer');
|
||
showToast(editId ? 'Server updated' : 'Server started', 'success');
|
||
refresh();
|
||
} else {
|
||
showModalError('modal-startServer', 'startServerError', response?.error || 'Failed to start');
|
||
}
|
||
}
|
||
);
|
||
};
|
||
|
||
if (editId) {
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId: editId } } },
|
||
() => doStart()
|
||
);
|
||
} else {
|
||
doStart();
|
||
}
|
||
});
|
||
|
||
$('stopServerConfirm')?.addEventListener('click', () => {
|
||
const serverId = $('stopServerConfirm').dataset.serverId;
|
||
if (!serverId) return;
|
||
const btn = $('stopServerConfirm');
|
||
btn.disabled = true; btn.textContent = 'Stopping…';
|
||
chrome.runtime.sendMessage(
|
||
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId } } },
|
||
(response) => {
|
||
btn.disabled = false; btn.textContent = 'Stop Server';
|
||
closeModal('modal-stopServer');
|
||
if (response?.ok) showToast('Server stopped', 'success');
|
||
else showToast(response?.error || 'Failed to stop', 'error');
|
||
refresh();
|
||
}
|
||
);
|
||
});
|
||
}
|