"use strict";
// ── Service detail modal ──────────────────────────────────────────
function _renderCredsHtml(credentials, unit) {
var html = "";
for (var i = 0; i < credentials.length; i++) {
var cred = credentials[i];
var id = "cred-" + Math.random().toString(36).substring(2, 8);
var displayValue = linkify(cred.value);
var qrBlock = "";
if (cred.qrcode) {
qrBlock = '
Scan with Zeus app on your phone
';
}
html += '
' + escHtml(cred.label) + '
' + qrBlock + '
' + displayValue + '
';
}
return html;
}
function _attachCopyHandlers(container) {
container.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
var target = document.getElementById(btn.dataset.target);
if (!target) return;
var text = target.textContent;
function onSuccess() {
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500);
}
function fallbackCopy() {
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
onSuccess();
} catch (e) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy);
} else {
fallbackCopy();
}
});
});
}
async function openServiceDetailModal(unit, name, icon) {
if (!$credsModal) return;
if ($credsTitle) $credsTitle.textContent = name;
if ($credsBody) $credsBody.innerHTML = '
Loading…
';
$credsModal.classList.add("open");
try {
var url = "/api/service-detail/" + encodeURIComponent(unit);
if (icon) url += "?icon=" + encodeURIComponent(icon);
var data = await apiFetch(url);
var html = "";
// Section A: Description
if (data.description) {
html += '
' +
'
' + escHtml(data.description) + '
' +
'
';
}
// Section B: Status
// When a feature override is present, use the feature's enabled state so the
// modal matches what the dashboard tile shows (feature toggle is authoritative).
var effectiveEnabled = data.feature ? data.feature.enabled : data.enabled;
var effectiveHealth = data.feature && !data.feature.enabled
? "disabled"
: (data.health || data.status);
var sc = statusClass(effectiveHealth);
var st = statusText(effectiveHealth, effectiveEnabled);
html += '
' +
'
Status
' +
'
' +
'' +
'' + escHtml(st) + '' +
'
' +
'
';
// Section C: Ports (only if service has port_requirements)
if (data.port_statuses && data.port_statuses.length > 0) {
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
var portTableRows = "";
data.port_statuses.forEach(function(p) {
var statusIcon, statusClass2;
if (p.status === "listening") {
statusIcon = "✅ Open";
statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") {
statusIcon = "🟡 Firewall open";
statusClass2 = "port-status-open";
} else if (p.status === "closed") {
statusIcon = "🔴 Closed";
statusClass2 = "port-status-closed";
} else {
statusIcon = "— Unknown";
statusClass2 = "port-status-unknown";
}
var desc = p.description;
var portNum = parseInt(p.port, 10);
if (portNum === 80 || portNum === 443) {
desc += " (shared — all services)";
}
portTableRows += '
' +
'
' + escHtml(p.port) + '
' +
'
' + escHtml(p.protocol) + '
' +
'
' + escHtml(desc) + '
' +
'
' + statusIcon + '
' +
'
';
});
var troubleshootHtml = "";
if (anyPortClosed) {
var sharedPorts = [];
var specificPorts = [];
data.port_statuses.forEach(function(p) {
if (p.status === "closed") {
var portNum = parseInt(p.port, 10);
if (portNum === 80 || portNum === 443) {
sharedPorts.push(p);
} else {
specificPorts.push(p);
}
}
});
var troubleParts = [];
if (sharedPorts.length > 0) {
troubleParts.push(
'⚠️ Ports 80 and 443 need to be forwarded on your router.' +
'
These are shared system ports — you only need to set them up once and they cover all your domain-based services ' +
'(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).
' +
'
If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:
' +
'' +
'
Log into your router\'s admin panel (usually http://192.168.1.1)
' +
'
Find the Port Forwarding section
' +
'
Forward port 80 (TCP) and port 443 (TCP) to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
' +
'
Save your router settings
' +
'' +
'
💡 Once these two ports are forwarded, you won\'t see this warning on any service again.
'
);
}
if (specificPorts.length > 0) {
var portList = specificPorts.map(function(p) {
return '' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ') — ' + escHtml(p.description);
}).join(' ');
troubleParts.push(
'⚠️ This service requires additional ports to be forwarded:' +
'
' + portList + '
' +
'' +
'
Log into your router\'s admin panel
' +
'
Forward each port listed above to your machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
' +
'
Save your router settings
' +
''
);
}
troubleshootHtml = '
' + troubleParts.join('') + '
';
}
html += '
' +
'
Port Status
' +
'
' +
'
' +
'
Port
Protocol
Description
Status
' +
'
' +
'' + portTableRows + '' +
'
' +
troubleshootHtml +
'
';
}
// Section D: Domain (only if service needs_domain)
if (data.needs_domain) {
var domainStatusHtml = "";
var ds = data.domain_status || {};
var domainBadge = "";
if (data.domain) {
if (ds.status === "connected") {
domainBadge = '✓ ' + escHtml(data.domain) + '';
} else if (ds.status === "dns_mismatch") {
domainBadge = '⚠ ' + escHtml(data.domain) + ' (IP mismatch)';
domainStatusHtml = '
' +
'⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.' +
'
This usually means the DNS record needs to be updated: