+116
-82
@@ -89,8 +89,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.sidebar-logo-icon svg { width: 18px; height: 18px; color: var(--cyan); }
|
|
||||||
.sidebar-logo-text { line-height: 1.2; }
|
.sidebar-logo-text { line-height: 1.2; }
|
||||||
.sidebar-logo-name { font-size: 15px; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
|
.sidebar-logo-name { font-size: 15px; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
|
||||||
.sidebar-logo-version { font-size: 11px; color: var(--text4); margin-top: 1px; }
|
.sidebar-logo-version { font-size: 11px; color: var(--text4); margin-top: 1px; }
|
||||||
@@ -314,6 +315,42 @@
|
|||||||
.badge-red { background: var(--red-dim); color: var(--red); }
|
.badge-red { background: var(--red-dim); color: var(--red); }
|
||||||
.badge-neutral { background: var(--elevated); color: var(--text3); }
|
.badge-neutral { background: var(--elevated); color: var(--text3); }
|
||||||
|
|
||||||
|
/* ── Overview list cards ────────────────────────── */
|
||||||
|
#page-dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ov-list-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
/* Each card takes equal share of remaining vertical space */
|
||||||
|
}
|
||||||
|
.ov-scroll-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.ov-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--card);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.ov-sentinel { height: 1px; }
|
||||||
|
/* Make the two grid rows share available height evenly */
|
||||||
|
#page-dashboard > div[style*="grid-template-columns:1fr 1fr"] {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
#page-dashboard > div[style*="grid-template-columns:1fr 1fr 1fr"] {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tables ────────────────────────────────────── */
|
/* ── Tables ────────────────────────────────────── */
|
||||||
.table { width: 100%; border-collapse: collapse; }
|
.table { width: 100%; border-collapse: collapse; }
|
||||||
.table th {
|
.table th {
|
||||||
@@ -845,12 +882,7 @@
|
|||||||
<!-- ── Sidebar ──────────────────────────────────── -->
|
<!-- ── Sidebar ──────────────────────────────────── -->
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<div class="sidebar-logo-icon">
|
<div class="sidebar-logo-icon">⛵</div>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
|
||||||
<path d="M3 17l4-8 4 4 4-6 4 10"/>
|
|
||||||
<path d="M3 21h18"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-logo-text">
|
<div class="sidebar-logo-text">
|
||||||
<div class="sidebar-logo-name">Holesail</div>
|
<div class="sidebar-logo-name">Holesail</div>
|
||||||
<div class="sidebar-logo-version">v1.0.0 · hole.sail</div>
|
<div class="sidebar-logo-version">v1.0.0 · hole.sail</div>
|
||||||
@@ -1042,138 +1074,140 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Two-column layout: Virtual Hosts + right column -->
|
<!-- Lists: 2-col top row, 3-col bottom row — each card has fixed height + scroll -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
|
||||||
|
|
||||||
<!-- Virtual Hosts preview -->
|
<!-- Virtual Hosts -->
|
||||||
<div class="card">
|
<div class="card ov-list-card">
|
||||||
<div class="card-header">
|
<div class="card-header" style="padding-bottom:8px;">
|
||||||
<div>
|
<div style="min-width:0;">
|
||||||
<div class="card-title">Virtual Hosts</div>
|
<div class="card-title">Virtual Hosts</div>
|
||||||
<div class="card-subtitle" id="ovVhostSubtitle">No virtual hosts configured</div>
|
<div class="card-subtitle" id="ovVhostSubtitle">—</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" data-page-link="connections">View all →</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body flush">
|
<div style="padding:0 12px 8px;">
|
||||||
<table class="table">
|
<input class="input ov-search" id="ovVhostSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
|
||||||
<thead><tr><th>Hostname</th><th>State</th><th>Open</th></tr></thead>
|
</div>
|
||||||
<tbody id="recentConnections">
|
<div class="ov-scroll-area">
|
||||||
<tr><td colspan="3" class="empty">No virtual hosts yet</td></tr>
|
<table class="table ov-table">
|
||||||
</tbody>
|
<thead><tr><th>Hostname</th><th>State</th><th></th></tr></thead>
|
||||||
|
<tbody id="recentConnections"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div id="ovVhostSentinel" class="ov-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server Tunnels preview -->
|
<!-- Server Tunnels -->
|
||||||
<div class="card">
|
<div class="card ov-list-card">
|
||||||
<div class="card-header">
|
<div class="card-header" style="padding-bottom:8px;">
|
||||||
<div>
|
<div style="min-width:0;">
|
||||||
<div class="card-title">Server Tunnels</div>
|
<div class="card-title">Server Tunnels</div>
|
||||||
<div class="card-subtitle" id="ovServersSubtitle">No server tunnels running</div>
|
<div class="card-subtitle" id="ovServersSubtitle">—</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" data-page-link="swarms">View all →</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body flush">
|
<div style="padding:0 12px 8px;">
|
||||||
<table class="table">
|
<input class="input ov-search" id="ovServersSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
|
||||||
|
</div>
|
||||||
|
<div class="ov-scroll-area">
|
||||||
|
<table class="table ov-table">
|
||||||
<thead><tr><th>Port</th><th>State</th><th>hs:// Key</th></tr></thead>
|
<thead><tr><th>Port</th><th>State</th><th>hs:// Key</th></tr></thead>
|
||||||
<tbody id="ovServersBody">
|
<tbody id="ovServersBody"></tbody>
|
||||||
<tr><td colspan="3" class="empty">No server tunnels yet</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
<div id="ovServersSentinel" class="ov-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Second row: Service Tunnels + SSH + Remote Desktop -->
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:10px;">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:12px;">
|
|
||||||
|
|
||||||
<!-- Service Tunnels preview -->
|
<!-- Service Tunnels -->
|
||||||
<div class="card">
|
<div class="card ov-list-card">
|
||||||
<div class="card-header">
|
<div class="card-header" style="padding-bottom:8px;">
|
||||||
<div>
|
<div style="min-width:0;">
|
||||||
<div class="card-title">Service Tunnels</div>
|
<div class="card-title">Service Tunnels</div>
|
||||||
<div class="card-subtitle" id="ovSvcSubtitle">No service tunnels</div>
|
<div class="card-subtitle" id="ovSvcSubtitle">—</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" data-page-link="service-tunnels">View all →</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body flush">
|
<div style="padding:0 12px 8px;">
|
||||||
<table class="table">
|
<input class="input ov-search" id="ovSvcSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
|
||||||
|
</div>
|
||||||
|
<div class="ov-scroll-area">
|
||||||
|
<table class="table ov-table">
|
||||||
<thead><tr><th>Label</th><th>Port</th><th>State</th></tr></thead>
|
<thead><tr><th>Label</th><th>Port</th><th>State</th></tr></thead>
|
||||||
<tbody id="ovSvcBody">
|
<tbody id="ovSvcBody"></tbody>
|
||||||
<tr><td colspan="3" class="empty">None</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
<div id="ovSvcSentinel" class="ov-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SSH preview -->
|
<!-- SSH Connections -->
|
||||||
<div class="card">
|
<div class="card ov-list-card">
|
||||||
<div class="card-header">
|
<div class="card-header" style="padding-bottom:8px;">
|
||||||
<div>
|
<div style="min-width:0;">
|
||||||
<div class="card-title">SSH Connections</div>
|
<div class="card-title">SSH Connections</div>
|
||||||
<div class="card-subtitle" id="ovSshSubtitle">No SSH connections saved</div>
|
<div class="card-subtitle" id="ovSshSubtitle">—</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" data-page-link="ssh">View all →</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body flush">
|
<div style="padding:0 12px 8px;">
|
||||||
<table class="table">
|
<input class="input ov-search" id="ovSshSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
|
||||||
|
</div>
|
||||||
|
<div class="ov-scroll-area">
|
||||||
|
<table class="table ov-table">
|
||||||
<thead><tr><th>Label</th><th>User</th></tr></thead>
|
<thead><tr><th>Label</th><th>User</th></tr></thead>
|
||||||
<tbody id="ovSshBody">
|
<tbody id="ovSshBody"></tbody>
|
||||||
<tr><td colspan="2" class="empty">None</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
<div id="ovSshSentinel" class="ov-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remote Desktop preview -->
|
<!-- Remote Desktops -->
|
||||||
<div class="card">
|
<div class="card ov-list-card">
|
||||||
<div class="card-header">
|
<div class="card-header" style="padding-bottom:8px;">
|
||||||
<div>
|
<div style="min-width:0;">
|
||||||
<div class="card-title">Remote Desktops</div>
|
<div class="card-title">Remote Desktops</div>
|
||||||
<div class="card-subtitle" id="ovRdpSubtitle">No remote desktops saved</div>
|
<div class="card-subtitle" id="ovRdpSubtitle">—</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost btn-sm" data-page-link="rdp">View all →</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body flush">
|
<div style="padding:0 12px 8px;">
|
||||||
<table class="table">
|
<input class="input ov-search" id="ovRdpSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
|
||||||
|
</div>
|
||||||
|
<div class="ov-scroll-area">
|
||||||
|
<table class="table ov-table">
|
||||||
<thead><tr><th>Label</th><th>Type</th></tr></thead>
|
<thead><tr><th>Label</th><th>Type</th></tr></thead>
|
||||||
<tbody id="ovRdpBody">
|
<tbody id="ovRdpBody"></tbody>
|
||||||
<tr><td colspan="2" class="empty">None</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
<div id="ovRdpSentinel" class="ov-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick actions -->
|
<!-- Quick actions -->
|
||||||
<div class="card">
|
<div class="card" style="flex-shrink:0;">
|
||||||
<div class="card-header">
|
<div class="card-header"><div class="card-title">Quick Actions</div></div>
|
||||||
<div class="card-title">Quick Actions</div>
|
<div class="card-body" style="display:flex;gap:8px;flex-wrap:wrap;padding-top:10px;padding-bottom:10px;">
|
||||||
</div>
|
<button class="btn btn-secondary btn-sm" id="qaAddVhost">
|
||||||
<div class="card-body" style="display:flex;gap:8px;flex-wrap:wrap;">
|
|
||||||
<button class="btn btn-secondary btn-sm" data-page-link="connections" id="qaAddVhost">
|
|
||||||
<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 Virtual Host
|
Add Virtual Host
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" data-page-link="swarms" id="qaAddServer">
|
<button class="btn btn-secondary btn-sm" id="qaAddServer">
|
||||||
<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 Tunnel
|
New Server Tunnel
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" data-page-link="service-tunnels" id="qaAddSvc">
|
<button class="btn btn-secondary btn-sm" id="qaAddSvc">
|
||||||
<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 Service Tunnel
|
Add Service Tunnel
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" data-page-link="ssh" id="qaAddSsh">
|
<button class="btn btn-secondary btn-sm" id="qaAddSsh">
|
||||||
<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 SSH Connection
|
Add SSH Connection
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" data-page-link="backups" id="qaBackup">
|
<button class="btn btn-secondary btn-sm" id="qaAddRdp">
|
||||||
<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>
|
<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>
|
||||||
Create Backup
|
Add Remote Desktop
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" data-page-link="logs" id="qaLogs">
|
<button class="btn btn-secondary btn-sm" id="qaInstallCA">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
View Logs
|
Install Root CA
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+180
-98
@@ -868,12 +868,176 @@ async function fetchState() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview infinite-scroll + debounced filter engine ───────────────────────
|
||||||
|
//
|
||||||
|
// Each list is managed by an OvList instance:
|
||||||
|
// - holds the full dataset and current filter string
|
||||||
|
// - renders PAGE_SIZE rows at a time into a <tbody>
|
||||||
|
// - an IntersectionObserver on a sentinel <div> appends the next page when
|
||||||
|
// the sentinel scrolls into view (infinite scroll)
|
||||||
|
// - a debounced input handler resets the list on every keystroke
|
||||||
|
|
||||||
|
const OV_PAGE = 20; // rows per page
|
||||||
|
|
||||||
|
class OvList {
|
||||||
|
constructor({ bodyId, sentinelId, searchId, subtitleId, rowFn, emptyMsg, cols }) {
|
||||||
|
this.body = $(bodyId);
|
||||||
|
this.sentinel = $(sentinelId);
|
||||||
|
this.searchEl = $(searchId);
|
||||||
|
this.subtitleEl = $(subtitleId);
|
||||||
|
this.rowFn = rowFn; // (item) => HTML string
|
||||||
|
this.emptyMsg = emptyMsg;
|
||||||
|
this.cols = cols; // colspan for empty row
|
||||||
|
this.data = []; // full unfiltered dataset
|
||||||
|
this.filtered = []; // after filter applied
|
||||||
|
this.rendered = 0; // rows currently in DOM
|
||||||
|
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>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
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.hsUrl || '';
|
||||||
|
const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key;
|
||||||
|
return `<tr>
|
||||||
|
<td class="mono" style="font-size:12px;">${escapeHtml(String(s.port || '—'))}</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 → open existing modals directly
|
||||||
|
$('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost'));
|
||||||
|
$('qaAddServer') ?.addEventListener('click', () => openModal('modal-startServer'));
|
||||||
|
$('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel'));
|
||||||
|
$('qaAddSsh') ?.addEventListener('click', () => openModal('modal-addSsh'));
|
||||||
|
$('qaAddRdp') ?.addEventListener('click', () => openModal('modal-addRdp'));
|
||||||
|
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dashboard / Overview ─────────────────────────────────────────────────────
|
// ── Dashboard / Overview ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function updateDashboard(state) {
|
function updateDashboard(state) {
|
||||||
currentState = state;
|
currentState = state;
|
||||||
const servers = state.servers || [];
|
const servers = state.servers || [];
|
||||||
const virtualHosts = state.virtualHosts || [];
|
const virtualHosts = state.virtualHosts || [];
|
||||||
|
const serviceTunnels = state.serviceTunnels || [];
|
||||||
|
|
||||||
// Sidebar status
|
// Sidebar status
|
||||||
const dot = $('sidebarDot');
|
const dot = $('sidebarDot');
|
||||||
@@ -888,17 +1052,15 @@ function updateDashboard(state) {
|
|||||||
if (statusText) statusText.textContent = 'Disconnected';
|
if (statusText) statusText.textContent = 'Disconnected';
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceTunnels = state.serviceTunnels || [];
|
const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; };
|
||||||
|
|
||||||
// ── Stat cards ────────────────────────────────────────────────────────────
|
// ── Stat cards ────────────────────────────────────────────────────────────
|
||||||
const setText = (id, val) => { const el = $( id); if (el) el.textContent = val; };
|
|
||||||
setText('dashConnections', virtualHosts.length);
|
setText('dashConnections', virtualHosts.length);
|
||||||
setText('dashSwarms', servers.length);
|
setText('dashSwarms', servers.length);
|
||||||
setText('dashServiceTunnels', serviceTunnels.length);
|
setText('dashServiceTunnels', serviceTunnels.length);
|
||||||
setText('dashSsh', sshConnections.length);
|
setText('dashSsh', sshConnections.length);
|
||||||
setText('dashRdp', rdpConnections.length);
|
setText('dashRdp', rdpConnections.length);
|
||||||
setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted');
|
setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted');
|
||||||
// Keep legacy dashTabs in sync (used by proxy & CA page)
|
|
||||||
setText('dashTabs', state.proxyPort != null ? state.proxyPort : '—');
|
setText('dashTabs', state.proxyPort != null ? state.proxyPort : '—');
|
||||||
|
|
||||||
const caIcon = $('caStatIcon');
|
const caIcon = $('caStatIcon');
|
||||||
@@ -923,7 +1085,6 @@ function updateDashboard(state) {
|
|||||||
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}` : '—');
|
||||||
|
|
||||||
// Last updated timestamp
|
|
||||||
const lu = $('overviewLastUpdated');
|
const lu = $('overviewLastUpdated');
|
||||||
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||||
|
|
||||||
@@ -935,102 +1096,23 @@ function updateDashboard(state) {
|
|||||||
setText('rdpCount', rdpConnections.length);
|
setText('rdpCount', rdpConnections.length);
|
||||||
setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA');
|
setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA');
|
||||||
|
|
||||||
// ── Virtual Hosts preview ─────────────────────────────────────────────────
|
// ── Initialise list engines on first call ─────────────────────────────────
|
||||||
const recentConnBody = $('recentConnections');
|
if (!_ovVhost) initOvLists();
|
||||||
if (recentConnBody) {
|
|
||||||
const ovVhostSub = $('ovVhostSubtitle');
|
|
||||||
if (virtualHosts.length === 0) {
|
|
||||||
if (ovVhostSub) ovVhostSub.textContent = 'No virtual hosts configured';
|
|
||||||
recentConnBody.innerHTML = `<tr><td colspan="3" class="empty">No virtual hosts yet — <button class="btn btn-ghost btn-sm" id="addVhostFromEmpty" style="display:inline-flex;">Add one</button></td></tr>`;
|
|
||||||
$('addVhostFromEmpty')?.addEventListener('click', () => openModal('modal-addVhost'));
|
|
||||||
} else {
|
|
||||||
const ready = virtualHosts.filter(v => v.state === 'ready').length;
|
|
||||||
if (ovVhostSub) ovVhostSub.textContent = `${virtualHosts.length} host${virtualHosts.length !== 1 ? 's' : ''} — ${ready} ready`;
|
|
||||||
recentConnBody.innerHTML = virtualHosts.slice(0, 6).map(v => {
|
|
||||||
const hostname = v.hostname || v.id || '';
|
|
||||||
return `<tr>
|
|
||||||
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
|
|
||||||
<td>${stateTag(v.state)}</td>
|
|
||||||
<td><a href="https://${escapeHtml(hostname)}" 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>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Server Tunnels preview ────────────────────────────────────────────────
|
// ── Feed data into each list (subtitle auto-computed) ─────────────────────
|
||||||
const ovServersBody = $('ovServersBody');
|
const sub = (n, unit, readyArr) => {
|
||||||
if (ovServersBody) {
|
if (n === 0) return `No ${unit}s`;
|
||||||
const ovSrvSub = $('ovServersSubtitle');
|
const r = readyArr.filter(x => x.state === 'ready').length;
|
||||||
if (servers.length === 0) {
|
return `${n} ${unit}${n !== 1 ? 's' : ''} — ${r} ready`;
|
||||||
if (ovSrvSub) ovSrvSub.textContent = 'No server tunnels running';
|
};
|
||||||
ovServersBody.innerHTML = `<tr><td colspan="3" class="empty">No server tunnels yet</td></tr>`;
|
|
||||||
} else {
|
|
||||||
const ready = servers.filter(s => s.state === 'ready').length;
|
|
||||||
if (ovSrvSub) ovSrvSub.textContent = `${servers.length} tunnel${servers.length !== 1 ? 's' : ''} — ${ready} ready`;
|
|
||||||
ovServersBody.innerHTML = servers.slice(0, 6).map(s => {
|
|
||||||
const key = s.hsUrl || '';
|
|
||||||
const short = key.length > 24 ? key.slice(0, 10) + '…' + key.slice(-8) : key;
|
|
||||||
return `<tr>
|
|
||||||
<td class="mono" style="font-size:12px;">${escapeHtml(String(s.port || '—'))}</td>
|
|
||||||
<td>${stateTag(s.state)}</td>
|
|
||||||
<td class="mono" style="font-size:11px;color:var(--text3);" title="${escapeHtml(key)}">${escapeHtml(short)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Service Tunnels preview ───────────────────────────────────────────────
|
_ovVhost .setData(virtualHosts, sub(virtualHosts.length, 'host', virtualHosts));
|
||||||
const ovSvcBody = $('ovSvcBody');
|
_ovServers.setData(servers, sub(servers.length, 'tunnel', servers));
|
||||||
if (ovSvcBody) {
|
_ovSvc .setData(serviceTunnels, sub(serviceTunnels.length, 'tunnel', serviceTunnels));
|
||||||
const ovSvcSub = $('ovSvcSubtitle');
|
_ovSsh .setData(sshConnections, sshConnections.length === 0
|
||||||
if (serviceTunnels.length === 0) {
|
? 'No SSH connections' : `${sshConnections.length} saved`);
|
||||||
if (ovSvcSub) ovSvcSub.textContent = 'No service tunnels';
|
_ovRdp .setData(rdpConnections, rdpConnections.length === 0
|
||||||
ovSvcBody.innerHTML = `<tr><td colspan="3" class="empty">None</td></tr>`;
|
? 'No remote desktops' : `${rdpConnections.length} saved`);
|
||||||
} else {
|
|
||||||
const ready = serviceTunnels.filter(t => t.state === 'ready').length;
|
|
||||||
if (ovSvcSub) ovSvcSub.textContent = `${serviceTunnels.length} tunnel${serviceTunnels.length !== 1 ? 's' : ''} — ${ready} ready`;
|
|
||||||
ovSvcBody.innerHTML = serviceTunnels.slice(0, 5).map(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>`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SSH preview ───────────────────────────────────────────────────────────
|
|
||||||
const ovSshBody = $('ovSshBody');
|
|
||||||
if (ovSshBody) {
|
|
||||||
const ovSshSub = $('ovSshSubtitle');
|
|
||||||
if (sshConnections.length === 0) {
|
|
||||||
if (ovSshSub) ovSshSub.textContent = 'No SSH connections saved';
|
|
||||||
ovSshBody.innerHTML = `<tr><td colspan="2" class="empty">None</td></tr>`;
|
|
||||||
} else {
|
|
||||||
if (ovSshSub) ovSshSub.textContent = `${sshConnections.length} saved connection${sshConnections.length !== 1 ? 's' : ''}`;
|
|
||||||
ovSshBody.innerHTML = sshConnections.slice(0, 5).map(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>`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Remote Desktop preview ────────────────────────────────────────────────
|
|
||||||
const ovRdpBody = $('ovRdpBody');
|
|
||||||
if (ovRdpBody) {
|
|
||||||
const ovRdpSub = $('ovRdpSubtitle');
|
|
||||||
if (rdpConnections.length === 0) {
|
|
||||||
if (ovRdpSub) ovRdpSub.textContent = 'No remote desktops saved';
|
|
||||||
ovRdpBody.innerHTML = `<tr><td colspan="2" class="empty">None</td></tr>`;
|
|
||||||
} else {
|
|
||||||
if (ovRdpSub) ovRdpSub.textContent = `${rdpConnections.length} saved connection${rdpConnections.length !== 1 ? 's' : ''}`;
|
|
||||||
ovRdpBody.innerHTML = rdpConnections.slice(0, 5).map(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>`).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Virtual Hosts table ──────────────────────────────────────────────────────
|
// ── Virtual Hosts table ──────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user