diff --git a/package.json b/package.json index 8381f03..fd271f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.11.58", + "version": "0.11.59", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/app.js b/src/public/app.js index f92fcdb..ffc2faf 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -45,6 +45,20 @@ function formatTime(value) { if (!value) return "Just now"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "Recently" : date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); } +function formatRelativeTime(value) { + if (!value) return "Just now"; + const date = new Date(value); if (Number.isNaN(date.getTime())) return "Recently"; + const seconds = Math.round((Date.now() - date.getTime()) / 1000); + if (seconds < 45) return "Just now"; + if (seconds < 90) return "1 minute ago"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} minutes ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.round(hours / 24); + if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`; + return formatTime(value); +} function certificateStatusLabel(status) { return ({ healthy:"Healthy", warning:"Renewal due soon", critical:"Renewal required urgently", expired:"Expired", pending:"Awaiting Caddy / ACME certificate", mismatch:"Certificate does not cover this domain" }[status] || String(status || "Unknown")).replaceAll("-", " "); } function parseHeaderLines(value) { return String(value || "").split("\n").map(line => { const index = line.indexOf(":"); return index > 0 ? { name:line.slice(0,index).trim(), value:line.slice(index+1).trim() } : null; }).filter(Boolean); } function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); } @@ -134,6 +148,12 @@ function renderDashboard() { $("#https-health-copy").textContent = probeCopy(data.services.https, `Ready and responding · ${data.services.https.activeDomains} TLS domain${data.services.https.activeDomains === 1 ? "" : "s"}`, "Not responding", "Not configured · no TLS domains enabled"); $("#storage-health-dot").className = `status-dot ${data.services.storage.healthy ? "running" : "error"}`; $("#storage-health-copy").textContent = data.services.storage.healthy ? "Ready · /data is readable and writable" : "Permission error · check /data"; + const streaming = data.streamingPorts || { total: 0, listening: 0 }; + $("#streaming-health-dot").className = `status-dot ${!streaming.total ? "inactive" : streaming.listening === streaming.total ? "running" : "error"}`; + $("#streaming-health-copy").textContent = !streaming.total ? "No streaming hosts configured" : `${streaming.listening} of ${streaming.total} port${streaming.total === 1 ? "" : "s"} listening`; + const upstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 }; + $("#upstream-health-dot").className = `status-dot ${!upstreams.total ? "inactive" : upstreams.unhealthy > 0 ? "error" : "running"}`; + $("#upstream-health-copy").textContent = !upstreams.total ? "No proxy hosts configured" : `${upstreams.healthy} of ${upstreams.total} healthy`; $("#health-checked").innerHTML = `Last checked ${formatTime(data.checkedAt)}`; updateDashboardUptime(data.system.uptimeSeconds); $("#system-memory").textContent = formatBytes(data.system.memoryBytes); @@ -146,8 +166,9 @@ function renderDashboard() { $("#system-database-detail").textContent = `${formatBytes(data.system.databaseBytes)} configuration database`; $("#system-public-ip").textContent = data.system.publicIp || (data.system.publicIpError ? "Unavailable" : "Checking…"); $("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked"; - $("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="dashboard-list-item issue ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}>${escapeHtml(item.name)}${escapeHtml(item.message)}`).join("") : '

Everything looks good.

'; - $("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `
${item.status === "error" || item.status === "warning" ? "!" : "✓"}${escapeHtml(item.message)}${escapeHtml(formatTime(item.at))}
`).join("") : '

No recent activity.

'; + $("#attention-panel").classList.toggle("is-clear", data.attention.length === 0); + $("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}>${escapeHtml(item.name)}${escapeHtml(item.message)}`).join("") : '
Everything looks good — no issues to review.
'; + $("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `
${item.status === "error" || item.status === "warning" ? "!" : "✓"}${escapeHtml(item.message)}${escapeHtml(formatRelativeTime(item.at))}
`).join("") : '

No recent activity.

'; } setInterval(() => { if (!document.querySelector("#dashboard-view.hidden")) updateDashboardUptime(); }, 1000); diff --git a/src/public/index.html b/src/public/index.html index 9ec09d7..6ad0066 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -94,6 +94,8 @@
HTTP · Port 80Ready and responding
HTTPS · Port 443Not configured
Persistent storageData directory writable
+
Streaming portsNo streaming hosts configured
+
UpstreamsNo proxy hosts configured

Last checked —

@@ -113,9 +115,9 @@
-
+

Action required

Needs attention

-

Everything looks good.

+
Everything looks good — no issues to review.

Recent activity

Recent activity

diff --git a/src/public/styles.css b/src/public/styles.css index 7f38f07..6145a6a 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -22,8 +22,8 @@ main{padding-top:24px}.utility-bar{display:flex;align-items:center;justify-conte .dialog-card input:not([type="checkbox"]):not([type="file"]),.dialog-card select{font:inherit;width:100%;height:44px;min-height:44px;padding:0 12px;line-height:42px;border-width:1px}.aside-footer strong{color:var(--text);font-weight:750} /* Dashboard */ -.mobile-nav{display:none}.dashboard-view{margin-top:38px}.metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:15px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));color:var(--text);text-align:left;position:relative;overflow:hidden}.metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85}.metric-card:focus{outline:none}.metric-card:focus-visible{outline:2px solid var(--green);outline-offset:-2px}.metric-card:is(button){cursor:pointer;transition:.2s}.metric-card:is(button):hover{transform:translateY(-2px);border-color:#3b5275;box-shadow:0 16px 40px rgba(0,0,0,.22)}.metric-card.accent-green{--card-accent:var(--green)}.metric-card.accent-blue{--card-accent:var(--blue)}.metric-card .metric-label,.metric-card>span:last-child{display:block}.metric-card .metric-label{color:var(--muted);font-size:.78rem;font-weight:700}.metric-card .metric-icon{position:absolute;top:16px;right:16px;width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:color-mix(in srgb,var(--card-accent,var(--muted)) 20%,transparent);color:var(--card-accent,var(--muted));font-size:.9rem;font-weight:800}.metric-card strong{display:block;margin:13px 0 7px;font-size:2rem;line-height:1}.metric-card>span:last-child{color:var(--muted);font-size:.76rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.metric-card.attention strong{color:var(--warning)}.metric-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:12px}.metric-chip{display:flex;align-items:center;gap:12px;min-width:0;padding:13px 16px;border:1px solid var(--line);border-radius:13px;background:rgba(16,26,43,.76);color:var(--text);text-align:left;position:relative;overflow:hidden;transition:.2s}.metric-chip::before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--card-accent,var(--muted));opacity:.9}.metric-chip:is(button){cursor:pointer}.metric-chip:is(button):hover{transform:translateY(-1px);border-color:#3b5275}.metric-chip:focus{outline:none}.metric-chip:focus-visible{outline:2px solid var(--green);outline-offset:-2px}.metric-chip .chip-icon{flex:0 0 auto;width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:color-mix(in srgb,var(--card-accent,var(--muted)) 20%,transparent);color:var(--card-accent,var(--muted));font-size:.85rem;font-weight:800}.metric-chip .chip-copy{display:flex;flex-direction:column;gap:2px;min-width:0}.metric-chip .metric-label{color:var(--muted);font-size:.72rem;font-weight:700}.metric-chip strong{font-size:1.3rem;line-height:1.1}.metric-chip small{color:var(--muted);font-size:.68rem}.metric-chip.accent-amber{--card-accent:var(--warning)}.metric-chip.accent-purple{--card-accent:#b59cff}.metric-chip.accent-warning{--card-accent:var(--warning)}.metric-chip.accent-warning strong{color:var(--warning)}.metric-chip.accent-green{--card-accent:var(--green)}.metric-chip.accent-green strong{color:var(--green)}.metric-chip.accent-blue{--card-accent:var(--blue)}.dashboard-columns{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.dashboard-jobs-slot:not(:empty){margin-top:18px}.dashboard-columns.lower{align-items:start}.dashboard-panel{min-width:0;padding:22px;border:1px solid var(--line);border-radius:16px;background:rgba(16,26,43,.76)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:20px}.panel-heading .eyebrow{margin-bottom:5px}.panel-heading h2{margin:0;font-size:1.2rem}.health-badge{padding:6px 9px;border-radius:999px;font-size:.7rem;font-weight:800}.health-badge.healthy{background:rgba(98,230,167,.12);color:var(--green)}.health-badge.warning{background:rgba(255,191,105,.12);color:var(--warning)}.health-badge.error{background:rgba(255,113,133,.12);color:var(--danger)}.health-panel{position:relative;overflow:hidden}.health-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;opacity:.9;transition:background-color .3s ease}.health-panel.status-healthy::before{background:var(--green)}.health-panel.status-warning::before{background:var(--warning)}.health-panel.status-error::before{background:var(--danger)}.health-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.health-tile{display:flex;align-items:center;gap:12px;min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.28);transition:.2s}.health-tile .status-dot{flex:0 0 auto}.health-tile-copy{display:grid;gap:3px;min-width:0}.health-tile-copy strong{font-size:.82rem}.health-tile-copy small{color:var(--muted);font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-list-item span:last-child{display:grid;gap:3px}.dashboard-list-item small{color:var(--muted);font-size:.75rem}.live-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);margin-right:7px;vertical-align:middle;box-shadow:0 0 0 0 rgba(98,230,167,.6);animation:live-pulse 2.2s ease-in-out infinite}.live-dot.checking{background:var(--warning);animation-duration:.9s}@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(98,230,167,.5)}70%{box-shadow:0 0 0 6px rgba(98,230,167,0)}100%{box-shadow:0 0 0 0 rgba(98,230,167,0)}}.system-panel{position:relative;overflow:hidden}.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--blue);opacity:.85}.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.28)}.system-grid dt{color:var(--muted);font-size:.72rem}.system-grid dd{margin:6px 0 0;font-size:.9rem;font-weight:750;overflow:hidden;text-overflow:ellipsis}.dashboard-list{display:grid}.dashboard-list-item{display:flex;align-items:flex-start;gap:12px;padding:13px 3px;border-top:1px solid var(--line)}.dashboard-list-item:first-child{border-top:0}.dashboard-list-item strong{font-size:.82rem}.dashboard-list-item .status-dot{margin-top:4px;flex:0 0 auto}.activity-mark{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:rgba(98,230,167,.12);color:var(--green);font-size:.7rem;font-weight:900}.quiet-state{color:var(--muted);font-size:.82rem;margin:3px 0} -:root[data-theme="light"] .metric-card{background:linear-gradient(145deg,#fff,#f6f9fc)}:root[data-theme="light"] .metric-chip{background:#f6f9fc}:root[data-theme="light"] .dashboard-panel{background:rgba(255,255,255,.82)}:root[data-theme="light"] .health-tile,:root[data-theme="light"] .system-tile{background:#f6f9fc} +.mobile-nav{display:none}.dashboard-view{margin-top:38px}.metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:15px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));color:var(--text);text-align:left;position:relative;overflow:hidden}.metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85}.metric-card:focus{outline:none}.metric-card:focus-visible{outline:2px solid var(--green);outline-offset:-2px}.metric-card:is(button){cursor:pointer;transition:.2s}.metric-card:is(button):hover{transform:translateY(-2px);border-color:#3b5275;box-shadow:0 16px 40px rgba(0,0,0,.22)}.metric-card.accent-green{--card-accent:var(--green)}.metric-card.accent-blue{--card-accent:var(--blue)}.metric-card .metric-label,.metric-card>span:last-child{display:block}.metric-card .metric-label{color:var(--muted);font-size:.78rem;font-weight:700}.metric-card .metric-icon{position:absolute;top:16px;right:16px;width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:color-mix(in srgb,var(--card-accent,var(--muted)) 20%,transparent);color:var(--card-accent,var(--muted));font-size:.9rem;font-weight:800}.metric-card strong{display:block;margin:13px 0 7px;font-size:2rem;line-height:1}.metric-card>span:last-child{color:var(--muted);font-size:.76rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.metric-card.attention strong{color:var(--warning)}.metric-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:12px}.metric-chip{display:flex;align-items:center;gap:12px;min-width:0;padding:13px 16px;border:1px solid var(--line);border-radius:13px;background:rgba(16,26,43,.76);color:var(--text);text-align:left;position:relative;overflow:hidden;transition:.2s}.metric-chip::before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--card-accent,var(--muted));opacity:.9}.metric-chip:is(button){cursor:pointer}.metric-chip:is(button):hover{transform:translateY(-1px);border-color:#3b5275}.metric-chip:focus{outline:none}.metric-chip:focus-visible{outline:2px solid var(--green);outline-offset:-2px}.metric-chip .chip-icon{flex:0 0 auto;width:32px;height:32px;display:grid;place-items:center;border-radius:9px;background:color-mix(in srgb,var(--card-accent,var(--muted)) 20%,transparent);color:var(--card-accent,var(--muted));font-size:.85rem;font-weight:800}.metric-chip .chip-copy{display:flex;flex-direction:column;gap:2px;min-width:0}.metric-chip .metric-label{color:var(--muted);font-size:.72rem;font-weight:700}.metric-chip strong{font-size:1.3rem;line-height:1.1}.metric-chip small{color:var(--muted);font-size:.68rem}.metric-chip.accent-amber{--card-accent:var(--warning)}.metric-chip.accent-purple{--card-accent:#b59cff}.metric-chip.accent-warning{--card-accent:var(--warning)}.metric-chip.accent-warning strong{color:var(--warning)}.metric-chip.accent-green{--card-accent:var(--green)}.metric-chip.accent-green strong{color:var(--green)}.metric-chip.accent-blue{--card-accent:var(--blue)}.dashboard-columns{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.dashboard-jobs-slot:not(:empty){margin-top:18px}.dashboard-columns.lower{align-items:start}.dashboard-panel{min-width:0;padding:22px;border:1px solid var(--line);border-radius:16px;background:rgba(16,26,43,.76)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:20px}.panel-heading .eyebrow{margin-bottom:5px}.panel-heading h2{margin:0;font-size:1.2rem}.health-badge{padding:6px 9px;border-radius:999px;font-size:.7rem;font-weight:800}.health-badge.healthy{background:rgba(98,230,167,.12);color:var(--green)}.health-badge.warning{background:rgba(255,191,105,.12);color:var(--warning)}.health-badge.error{background:rgba(255,113,133,.12);color:var(--danger)}.health-panel{position:relative;overflow:hidden}.health-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;opacity:.9;transition:background-color .3s ease}.health-panel.status-healthy::before{background:var(--green)}.health-panel.status-warning::before{background:var(--warning)}.health-panel.status-error::before{background:var(--danger)}.health-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.health-tile{display:flex;align-items:center;gap:12px;min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.28);transition:.2s}.health-tile .status-dot{flex:0 0 auto}.health-tile-copy{display:grid;gap:3px;min-width:0}.health-tile-copy strong{font-size:.82rem}.health-tile-copy small{color:var(--muted);font-size:.72rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-list-item span:last-child{display:grid;gap:3px}.dashboard-list-item small{color:var(--muted);font-size:.75rem}.live-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);margin-right:7px;vertical-align:middle;box-shadow:0 0 0 0 rgba(98,230,167,.6);animation:live-pulse 2.2s ease-in-out infinite}.live-dot.checking{background:var(--warning);animation-duration:.9s}@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(98,230,167,.5)}70%{box-shadow:0 0 0 6px rgba(98,230,167,0)}100%{box-shadow:0 0 0 0 rgba(98,230,167,0)}}.system-panel{position:relative;overflow:hidden}.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--blue);opacity:.85}.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.28)}.system-grid dt{color:var(--muted);font-size:.72rem}.system-grid dd{margin:6px 0 0;font-size:.9rem;font-weight:750;overflow:hidden;text-overflow:ellipsis}.dashboard-list{display:grid}.dashboard-list-item{display:flex;align-items:flex-start;gap:12px;padding:13px 3px;border-top:1px solid var(--line)}.dashboard-list-item:first-child{border-top:0}.dashboard-list-item strong{font-size:.82rem}.dashboard-list-item .status-dot{margin-top:4px;flex:0 0 auto}.activity-mark{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:rgba(98,230,167,.12);color:var(--green);font-size:.7rem;font-weight:900}.quiet-state{color:var(--muted);font-size:.82rem;margin:3px 0}#attention-list.dashboard-list,#activity-list.dashboard-list{display:grid;gap:10px}.attention-tile{display:flex;align-items:center;gap:12px;min-width:0;padding:13px 14px;border:1px solid var(--line);border-left:3px solid var(--danger);border-radius:12px;background:rgba(8,16,29,.28)}.issue-link.attention-tile{cursor:pointer;transition:.2s}.issue-link.attention-tile:hover{background:rgba(255,113,133,.08);border-color:#3b5275}.attention-copy,.activity-copy{display:grid;gap:3px;min-width:0}.attention-copy strong,.activity-copy strong{font-size:.82rem}.attention-copy small,.activity-copy small{color:var(--muted);font-size:.72rem}.all-clear{display:flex;align-items:center;gap:10px;padding:13px 14px;border:1px solid var(--line);border-radius:12px;background:rgba(98,230,167,.06);font-size:.85rem}.all-clear .status-dot{flex:0 0 auto}.activity-tile{display:flex;align-items:center;gap:12px;min-width:0;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.22)} +:root[data-theme="light"] .metric-card{background:linear-gradient(145deg,#fff,#f6f9fc)}:root[data-theme="light"] .metric-chip{background:#f6f9fc}:root[data-theme="light"] .dashboard-panel{background:rgba(255,255,255,.82)}:root[data-theme="light"] .health-tile,:root[data-theme="light"] .system-tile,:root[data-theme="light"] .attention-tile,:root[data-theme="light"] .activity-tile,:root[data-theme="light"] .all-clear{background:#f6f9fc} @media(max-width:1100px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:1100px){.metric-strip{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:760px){.mobile-nav{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:0 0 30px;padding:4px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.mobile-nav button{justify-content:center;text-align:center;padding:9px 5px;font-size:.72rem}.dashboard-view{margin-top:30px}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric-card{padding:16px}.metric-card strong{font-size:1.65rem}.dashboard-columns{grid-template-columns:1fr}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-strip{grid-template-columns:1fr}.metric-chip{padding:11px 14px}} diff --git a/src/server.js b/src/server.js index c9d4004..30e89b6 100644 --- a/src/server.js +++ b/src/server.js @@ -671,6 +671,8 @@ async function cacheIcon(slug) { async function dashboardSnapshot() { const hosted = sites.map(publicSite); const proxyHosts = proxies.map(publicProxy); + const enabledStreams = streams.filter(item => item.enabled !== false); + const streamingPorts = { total: enabledStreams.length, listening: enabledStreams.filter(item => activeStreams.has(item.id)).length }; const certificates = await certificateInventory(); const tlsDomains = [...sites, ...proxies].filter(item => item.enabled && item.domain && item.tls !== "http").length; const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([ @@ -708,6 +710,7 @@ async function dashboardSnapshot() { tlsDomains, certificates: certificates.summary, upstreams: { total: proxyHosts.filter(item => item.enabled).length, healthy: proxyHosts.filter(item => item.upstream?.status === "healthy").length, unhealthy: proxyHosts.filter(item => item.upstream?.status === "unhealthy").length }, + streamingPorts, throughput: { liveRequests: storage.performanceLiveCount(60) }, attention, system: {