/** * Overview page — summary stats, quick-action cards, and virtualized connection lists. * Uses the OvList class for infinite-scroll, searchable tables of connections and servers. * Depends on: core/utils.js ($, escapeHtml), ui/state-tag.js (stateTag), * ui/modal.js (openModal), ui/toast.js (copyToClipboard, showToast), * pages/ssh.js (openAddSshModal), pages/rdp.js (openAddRdpModal), * core/state.js (sshConnections, rdpConnections), data/install-commands.js (getInstallCommand, detectPlatform, INSTALL_SCRIPT_BASE_URL) */ const OV_PAGE = 20; // rows per page /** * Virtualized, searchable, infinite-scroll list for the Overview page. * Renders rows in pages of OV_PAGE, loading more when the sentinel element scrolls into view. */ class OvList { constructor({ bodyId, sentinelId, searchId, subtitleId, rowFn, emptyMsg, cols }) { this.body = $(bodyId); this.sentinel = $(sentinelId); this.searchEl = $(searchId); this.subtitleEl = $(subtitleId); this.scrollArea = this.body?.closest('.ov-scroll-area') || null; this.rowFn = rowFn; this.emptyMsg = emptyMsg; this.cols = cols; this.data = []; this.filtered = []; this.rendered = 0; this._debounce = null; this._observer = null; this._initObserver(); this._initSearch(); } _initObserver() { if (!this.sentinel) return; this._observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting) this._appendPage(); }, { threshold: 0 }); this._observer.observe(this.sentinel); } _initSearch() { if (!this.searchEl) return; this.searchEl.addEventListener('input', () => { clearTimeout(this._debounce); this._debounce = setTimeout(() => this._applyFilter(), 180); }); } setData(data, subtitleText) { this.data = data; if (this.subtitleEl) this.subtitleEl.textContent = subtitleText; this._applyFilter(); } _applyFilter() { const q = (this.searchEl?.value || '').trim().toLowerCase(); this.filtered = q ? this.data.filter(item => JSON.stringify(item).toLowerCase().includes(q)) : this.data.slice(); this._reset(); } _reset() { if (!this.body) return; this.rendered = 0; this.body.innerHTML = ''; if (this.filtered.length === 0) { this.body.innerHTML = `${this.emptyMsg}`; this.scrollArea?.classList.add('ov-empty'); return; } this.scrollArea?.classList.remove('ov-empty'); this._appendPage(); } _appendPage() { if (!this.body || this.rendered >= this.filtered.length) return; const next = this.filtered.slice(this.rendered, this.rendered + OV_PAGE); this.body.insertAdjacentHTML('beforeend', next.map(this.rowFn).join('')); this.rendered += next.length; } } // Instances — created once, reused on every updateDashboard call let _ovVhost = null; let _ovServers = null; let _ovSvc = null; let _ovSsh = null; let _ovRdp = null; /** Current platform for onboarding card (win32 | macos | linux). Set by detectPlatform or switcher. */ let _onboardingPlatform = null; /** * Update the onboarding card command and label for the given platform. * @param {'macos'|'linux'|'win32'} platform */ function _updateOnboardingCommand(platform) { if (typeof getInstallCommand === 'undefined' || typeof INSTALL_SCRIPT_BASE_URL === 'undefined') return; const { label, command } = getInstallCommand(INSTALL_SCRIPT_BASE_URL, platform); const labelEl = $('onboardingPlatformLabel'); const cmdEl = $('onboardingCommand'); if (labelEl) labelEl.textContent = label; if (cmdEl) cmdEl.textContent = command; } /** * Create and wire up the OvList instances for virtual hosts and server tunnels. * Called once during dashboard initialisation. */ function initOvLists() { _ovVhost = new OvList({ bodyId: 'recentConnections', sentinelId: 'ovVhostSentinel', searchId: 'ovVhostSearch', subtitleId: 'ovVhostSubtitle', cols: 3, emptyMsg: 'No virtual hosts', rowFn: v => { const h = v.hostname || v.id || ''; return ` ${escapeHtml(h)} ${stateTag(v.state)} Open `; } }); _ovServers = new OvList({ bodyId: 'ovServersBody', sentinelId: 'ovServersSentinel', searchId: 'ovServersSearch', subtitleId: 'ovServersSubtitle', cols: 3, emptyMsg: 'No server tunnels', rowFn: s => { const key = s.url || s.hsUrl || ''; const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key; const display = s.label ? escapeHtml(s.label) : `${escapeHtml(String(s.port || '—'))}`; return ` ${display} ${stateTag(s.state)} ${escapeHtml(short)} `; } }); _ovSvc = new OvList({ bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel', searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle', cols: 3, emptyMsg: 'No service tunnels', rowFn: t => ` ${escapeHtml(t.label || '—')} ${escapeHtml(String(t.localPort || '—'))} ${stateTag(t.state)} ` }); _ovSsh = new OvList({ bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel', searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle', cols: 2, emptyMsg: 'No SSH connections', rowFn: c => ` ${escapeHtml(c.label || c.hsUrl || '—')} ${escapeHtml(c.username || '—')} ` }); _ovRdp = new OvList({ bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel', searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle', cols: 2, emptyMsg: 'No remote desktops', rowFn: c => ` ${escapeHtml(c.label || '—')} ${escapeHtml((c.type || 'vnc').toUpperCase())} ` }); // Quick action buttons $('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost')); $('qaAddServer')?.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'); }); $('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel')); $('qaAddSsh') ?.addEventListener('click', () => openAddSshModal(null)); $('qaAddRdp') ?.addEventListener('click', () => openAddRdpModal(null)); $('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA')); // Onboarding card (native host not found): Copy, Check again, platform switcher const copyBtn = $('onboardingCopyBtn'); if (copyBtn) { copyBtn.addEventListener('click', () => { const cmdEl = $('onboardingCommand'); if (cmdEl && typeof copyToClipboard === 'function') copyToClipboard(cmdEl.textContent, copyBtn); }); } const checkAgainBtn = $('onboardingCheckAgainBtn'); if (checkAgainBtn) { checkAgainBtn.addEventListener('click', async () => { if (typeof refresh !== 'function') return; const state = await refresh(); if (state?.hostConnected) { if (typeof showToast === 'function') showToast('Native host connected', 'success'); } else { if (typeof showToast === 'function') showToast('Still disconnected. Install the native host using the command above.', 'default'); } }); } const switchMacLinux = $('onboardingSwitchMacLinux'); if (switchMacLinux) { switchMacLinux.addEventListener('click', () => { _onboardingPlatform = 'macos'; _updateOnboardingCommand('macos'); }); } const switchWin = $('onboardingSwitchWin'); if (switchWin) { switchWin.addEventListener('click', () => { _onboardingPlatform = 'win32'; _updateOnboardingCommand('win32'); }); } } /** * Render the Overview page with the latest extension state. * Updates summary stat cards, quick-action buttons, and the connection/server lists. * @param {object} state - Full extension state from `fetchState()`. */ function updateDashboard(state) { currentState = state; const servers = state.servers || []; const virtualHosts = state.virtualHosts || []; const serviceTunnels = state.serviceTunnels || []; const dot = $('sidebarDot'); const statusText = $('sidebarStatus'); if (state.hostConnected) { dot?.classList.add('connected'); dot?.classList.remove('disconnected'); if (statusText) statusText.textContent = 'Connected'; } else { dot?.classList.remove('connected'); dot?.classList.add('disconnected'); if (statusText) statusText.textContent = 'Disconnected'; } const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; }; setText('dashConnections', virtualHosts.length); setText('dashSwarms', servers.length); setText('dashServiceTunnels', serviceTunnels.length); setText('dashSsh', sshConnections.length); setText('dashRdp', rdpConnections.length); setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted'); const caIcon = $('caStatIcon'); if (caIcon) { caIcon.style.background = state.caInstalled ? 'var(--green-dim)' : 'var(--red-dim)'; caIcon.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)'; } const ovHostDot = $('ovHostDot'); if (ovHostDot) { ovHostDot.classList.toggle('connected', !!state.hostConnected); ovHostDot.classList.toggle('disconnected', !state.hostConnected); } setText('ovHostLabel', state.hostConnected ? 'Connected' : 'Disconnected'); const ovCaDot = $('ovCaDot'); if (ovCaDot) { ovCaDot.style.background = state.caInstalled ? 'var(--green)' : 'var(--red)'; ovCaDot.style.boxShadow = state.caInstalled ? '0 0 5px var(--green)' : 'none'; } setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed'); setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—'); 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'); if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString(); // Onboarding card: show when native host is disconnected const onboardingEl = $('onboardingNativeHost'); if (onboardingEl) { if (!state.hostConnected) { onboardingEl.style.display = 'block'; if (_onboardingPlatform == null && typeof detectPlatform === 'function') _onboardingPlatform = detectPlatform(); _updateOnboardingCommand(_onboardingPlatform || 'linux'); } else { onboardingEl.style.display = 'none'; _onboardingPlatform = null; } } setText('swarmCount', servers.length); setText('connCount', virtualHosts.length); setText('serviceTunnelCount', serviceTunnels.length); setText('sshCount', sshConnections.length); setText('rdpCount', rdpConnections.length); setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA'); if (!_ovVhost) initOvLists(); const sub = (n, unit, readyArr) => { if (n === 0) return `No ${unit}s`; const r = readyArr.filter(x => x.state === 'ready').length; return `${n} ${unit}${n !== 1 ? 's' : ''} — ${r} ready`; }; _ovVhost .setData(virtualHosts, sub(virtualHosts.length, 'host', virtualHosts)); _ovServers.setData(servers, sub(servers.length, 'tunnel', servers)); _ovSvc .setData(serviceTunnels, sub(serviceTunnels.length, 'tunnel', serviceTunnels)); _ovSsh .setData(sshConnections, sshConnections.length === 0 ? 'No SSH connections' : `${sshConnections.length} saved`); _ovRdp .setData(rdpConnections, rdpConnections.length === 0 ? 'No remote desktops' : `${rdpConnections.length} saved`); }