CI / Build & Test (push) Successful in 2m55s
Add ov-empty CSS class to .ov-scroll-area when an overview list has no items — disables overflow scrolling and uses flexbox to vertically and horizontally center the empty message. Class is removed automatically when data is present. Keeps the normal scrollable layout intact for populated lists.
277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
/**
|
|
* 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), pages/ssh.js (openAddSshModal),
|
|
* pages/rdp.js (openAddRdpModal), core/state.js (sshConnections, rdpConnections)
|
|
*/
|
|
|
|
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 = `<tr><td colspan="${this.cols}" class="empty">${this.emptyMsg}</td></tr>`;
|
|
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;
|
|
|
|
/**
|
|
* 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 `<tr>
|
|
<td style="font-size:12px;"><span class="mono-chip">${escapeHtml(h)}</span></td>
|
|
<td>${stateTag(v.state)}</td>
|
|
<td><a href="https://${escapeHtml(h)}" target="_blank" rel="noopener"
|
|
class="btn btn-ghost btn-sm" style="padding:3px 8px;">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
style="width:11px;height:11px;">
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
<polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/>
|
|
</svg>Open</a></td>
|
|
</tr>`;
|
|
}
|
|
});
|
|
|
|
_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) : `<span class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(String(s.port || '—'))}</span>`;
|
|
return `<tr>
|
|
<td style="font-size:12px;">${display}</td>
|
|
<td>${stateTag(s.state)}</td>
|
|
<td class="mono" style="font-size:11px;color:var(--text3);"
|
|
title="${escapeHtml(key)}">${escapeHtml(short)}</td>
|
|
</tr>`;
|
|
}
|
|
});
|
|
|
|
_ovSvc = new OvList({
|
|
bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel',
|
|
searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle',
|
|
cols: 3, emptyMsg: 'No service tunnels',
|
|
rowFn: t => `<tr>
|
|
<td style="font-size:12px;">${escapeHtml(t.label || '—')}</td>
|
|
<td class="mono" style="font-size:12px;">${escapeHtml(String(t.localPort || '—'))}</td>
|
|
<td>${stateTag(t.state)}</td>
|
|
</tr>`
|
|
});
|
|
|
|
_ovSsh = new OvList({
|
|
bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel',
|
|
searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle',
|
|
cols: 2, emptyMsg: 'No SSH connections',
|
|
rowFn: c => `<tr>
|
|
<td style="font-size:12px;">${escapeHtml(c.label || c.hsUrl || '—')}</td>
|
|
<td class="mono" style="font-size:12px;color:var(--text3);">${escapeHtml(c.username || '—')}</td>
|
|
</tr>`
|
|
});
|
|
|
|
_ovRdp = new OvList({
|
|
bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel',
|
|
searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle',
|
|
cols: 2, emptyMsg: 'No remote desktops',
|
|
rowFn: c => `<tr>
|
|
<td style="font-size:12px;">${escapeHtml(c.label || '—')}</td>
|
|
<td><span class="badge badge-neutral" style="font-size:10px;padding:2px 6px;">
|
|
${escapeHtml((c.type || 'vnc').toUpperCase())}</span></td>
|
|
</tr>`
|
|
});
|
|
|
|
// 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'));
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
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`);
|
|
}
|