"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 + '
';
}
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.innerHTML = '';
if (icon) {
var iconImg = document.createElement("img");
iconImg.className = "creds-title-icon";
iconImg.src = "/static/icons/" + escHtml(icon) + ".svg";
iconImg.alt = name;
iconImg.onerror = function() { this.style.display = "none"; };
$credsTitle.appendChild(iconImg);
}
var nameSpan = document.createElement("span");
nameSpan.textContent = name;
$credsTitle.appendChild(nameSpan);
}
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: Domain diagnostics (domain services)
if (data.needs_domain) {
var steps = data.domain_check_steps || [];
var stepsHtml = "";
steps.forEach(function(step) {
var iconLabel = "—";
if (step.status === "ok") iconLabel = "✅";
else if (step.status === "error") iconLabel = "❌";
else if (step.status === "warning") iconLabel = "⚠️";
else if (step.status === "skipped") iconLabel = "⏭️";
var detail = escHtml(step.detail || "").replace(/\n/g, " ");
stepsHtml += '' +
'
' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + ' ' +
(detail ? '
' + detail + '
' : '') +
'
';
});
var domainActionHtml = "";
var ds = data.domain_status || {};
if (!data.domain && data.domain_name) {
domainActionHtml = '🌐 Configure Domain ';
} else if (data.domain && (ds.status === "dns_mismatch" || ds.status === "unresolvable")) {
domainActionHtml = '🔄 Reconfigure Domain ';
}
html += '' +
'
Domain Diagnostic Checklist
' +
stepsHtml +
domainActionHtml +
'
';
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
var extraRows = "";
data.extra_ports.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";
}
extraRows += '' +
'' + escHtml(p.port) + ' ' +
'' + escHtml(p.protocol) + ' ' +
'' + escHtml(p.description || "") + ' ' +
'' + statusIcon + ' ' +
' ';
});
html += '' +
'
Step 4: Additional Ports
' +
'
' +
'Port Protocol Description Status ' +
'' + extraRows + ' ' +
'
' +
'
';
}
} else if (data.port_statuses && data.port_statuses.length > 0) {
// Non-domain services (SSH) keep local single-port checks.
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";
}
portTableRows += '' +
'' + escHtml(p.port) + ' ' +
'' + escHtml(p.protocol) + ' ' +
'' + escHtml(p.description || "") + ' ' +
'' + statusIcon + ' ' +
' ';
});
html += '' +
'
Port Status
' +
'
' +
'Port Protocol Description Status ' +
'' + portTableRows + ' ' +
'
' +
'
';
}
// Section E: Credentials & Links
if (data.has_credentials && data.credentials && data.credentials.length > 0) {
html += '' +
'
Credentials & Access
' +
_renderCredsHtml(data.credentials, unit) +
(unit === "matrix-synapse.service" ?
'
' +
'➕ Add New User ' +
'🔑 Change Password ' +
'
' : "") +
(unit === "root-password-setup.service" ?
'
' +
'🔑 Change Free Account Password ' +
'
' : "") +
'
';
} else if (!data.enabled && !data.feature) {
html += '' +
'
This service is not enabled in your configuration.
' +
'
';
}
// Section F: Addon Feature toggle
if (data.feature) {
var feat = data.feature;
// Sync this feature into _featuresData so handleFeatureToggle can look up conflicts / ssl state
if (!_featuresData) {
_featuresData = { features: [feat], ssl_email_configured: false };
} else {
var fidx = _featuresData.features.findIndex(function(f) { return f.id === feat.id; });
if (fidx >= 0) { _featuresData.features[fidx] = feat; }
else { _featuresData.features.push(feat); }
}
var addonStatusLabel = feat.enabled ? "Enabled \u2713" : "Disabled";
var addonStatusCls = feat.enabled ? "addon-status--on" : "addon-status--off";
var addonBtnLabel = feat.enabled ? "Disable Feature" : "Enable Feature";
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature";
// Description: prefer the feature's own description over a generic fallback
var addonDesc = feat.description
? feat.description
: "This is an optional addon feature. You can enable or disable it at any time.";
// Conflicts warning: list mutually-exclusive feature names when present
var conflictsHtml = "";
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
var conflictNames = feat.conflicts_with.map(function(cid) {
if (_featuresData && Array.isArray(_featuresData.features)) {
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
if (cf) return cf.name;
}
return cid;
});
conflictsHtml = '\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '
';
}
html += '' +
'
' + addonSectionTitle + '
' +
'
' + escHtml(addonDesc) + '
' +
conflictsHtml +
'
' +
'' + addonStatusLabel + ' ' +
'' + escHtml(addonBtnLabel) + ' ' +
'
' +
'
';
}
$credsBody.innerHTML = html;
_attachCopyHandlers($credsBody);
if (unit === "matrix-synapse.service") {
var addBtn = document.getElementById("matrix-add-user-btn");
var changePwBtn = document.getElementById("matrix-change-pw-btn");
if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name, icon); });
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name, icon); });
}
if (unit === "root-password-setup.service") {
var sysPwBtn = document.getElementById("sys-change-pw-btn");
if (sysPwBtn) sysPwBtn.addEventListener("click", function() { openSystemChangePasswordModal(unit, name, icon); });
}
if (data.feature) {
var addonBtn = document.getElementById("svc-detail-addon-btn");
if (addonBtn) {
var addonFeat = data.feature;
addonBtn.addEventListener("click", function() {
closeCredsModal();
handleFeatureToggle(addonFeat, !addonFeat.enabled);
});
}
}
// Configure / Reconfigure Domain buttons (for non-feature services that need a domain)
var configDomainBtn = document.getElementById("svc-detail-config-domain-btn");
var reconfigDomainBtn = document.getElementById("svc-detail-reconfig-domain-btn");
if ((configDomainBtn || reconfigDomainBtn) && data.needs_domain && data.domain_name) {
var pseudoFeat = {
id: data.domain_name,
name: name,
domain_name: data.domain_name,
needs_ddns: true,
extra_fields: []
};
if (configDomainBtn) configDomainBtn.addEventListener("click", function() {
closeCredsModal();
openDomainSetupModal(pseudoFeat, function() {
openServiceDetailModal(unit, name, icon);
});
});
if (reconfigDomainBtn) reconfigDomainBtn.addEventListener("click", function() {
closeCredsModal();
openDomainReconfigureModal(pseudoFeat, data.domain || "", function() {
openServiceDetailModal(unit, name, icon);
});
});
}
} catch (err) {
if ($credsBody) $credsBody.innerHTML = 'Could not load service details.
';
}
}
// ── Credentials info modal ────────────────────────────────────────
async function openCredsModal(unit, name, icon) {
if (!$credsModal) return;
if ($credsTitle) {
$credsTitle.innerHTML = '';
if (icon) {
var iconImg = document.createElement("img");
iconImg.className = "creds-title-icon";
iconImg.src = "/static/icons/" + escHtml(icon) + ".svg";
iconImg.alt = name;
iconImg.onerror = function() { this.style.display = "none"; };
$credsTitle.appendChild(iconImg);
}
var nameSpan = document.createElement("span");
nameSpan.textContent = name + " — Connection Info";
$credsTitle.appendChild(nameSpan);
}
if ($credsBody) $credsBody.innerHTML = 'Loading…
';
$credsModal.classList.add("open");
try {
var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
if (!data.credentials || data.credentials.length === 0) {
$credsBody.innerHTML = 'No connection info available yet.
';
return;
}
var html = _renderCredsHtml(data.credentials, unit);
if (unit === "matrix-synapse.service") {
html += '' +
'➕ Add New User ' +
'🔑 Change Password ' +
'
';
}
$credsBody.innerHTML = html;
_attachCopyHandlers($credsBody);
if (unit === "matrix-synapse.service") {
var addBtn = document.getElementById("matrix-add-user-btn");
var changePwBtn = document.getElementById("matrix-change-pw-btn");
if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); });
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); });
}
} catch (err) {
$credsBody.innerHTML = 'Could not load credentials.
';
}
}
function openMatrixCreateUserModal(unit, name, icon) {
if (!$credsBody) return;
$credsBody.innerHTML =
'Username ' +
'
' +
'Password ' +
'
' +
'Make admin
' +
'' +
'← Back ' +
'Create User ' +
'
' +
'
';
document.getElementById("matrix-create-back-btn").addEventListener("click", function() {
openServiceDetailModal(unit, name, icon);
});
document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() {
var submitBtn = document.getElementById("matrix-create-submit-btn");
var resultEl = document.getElementById("matrix-create-result");
var username = (document.getElementById("matrix-new-username").value || "").trim();
var password = document.getElementById("matrix-new-password").value || "";
var isAdmin = document.getElementById("matrix-new-admin").checked;
if (!username || !password) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Username and password are required.";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Creating…";
resultEl.className = "matrix-form-result";
resultEl.textContent = "";
try {
var resp = await apiFetch("/api/matrix/create-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password, admin: isAdmin })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ User @" + escHtml(resp.username) + " created successfully.";
submitBtn.textContent = "Create User";
submitBtn.disabled = false;
} catch (err) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to create user.");
submitBtn.textContent = "Create User";
submitBtn.disabled = false;
}
});
}
function openMatrixChangePasswordModal(unit, name, icon) {
if (!$credsBody) return;
$credsBody.innerHTML =
'Username (localpart only, e.g. alice ) ' +
'
' +
'New Password ' +
'
' +
'' +
'← Back ' +
'Change Password ' +
'
' +
'
';
document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() {
openServiceDetailModal(unit, name, icon);
});
document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() {
var submitBtn = document.getElementById("matrix-chpw-submit-btn");
var resultEl = document.getElementById("matrix-chpw-result");
var username = (document.getElementById("matrix-chpw-username").value || "").trim();
var newPassword = document.getElementById("matrix-chpw-password").value || "";
if (!username || !newPassword) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Username and new password are required.";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Changing…";
resultEl.className = "matrix-form-result";
resultEl.textContent = "";
try {
var resp = await apiFetch("/api/matrix/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, new_password: newPassword })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ Password for @" + escHtml(resp.username) + " changed successfully.";
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
} catch (err) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
}
});
}
function openSystemChangePasswordModal(unit, name, icon) {
if (!$credsBody) return;
$credsBody.innerHTML =
'' +
'' +
'' +
'⚠ This will change both your desktop login and Hub login password. After changing, your updated password will appear in the System Passwords credentials tile. Make sure to remember it — you will need it to sign back into the Hub.
' +
'' +
'← Back ' +
'Change Password ' +
'
' +
'
';
document.getElementById("sys-chpw-back-btn").addEventListener("click", function() {
openServiceDetailModal(unit, name, icon);
});
document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() {
var inp = document.getElementById("sys-chpw-new");
var isHidden = inp.type === "password";
inp.type = isHidden ? "text" : "password";
this.textContent = isHidden ? "👁🗨" : "👁";
});
document.getElementById("sys-chpw-confirm-toggle").addEventListener("click", function() {
var inp = document.getElementById("sys-chpw-confirm");
var isHidden = inp.type === "password";
inp.type = isHidden ? "text" : "password";
this.textContent = isHidden ? "👁🗨" : "👁";
});
document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() {
var submitBtn = document.getElementById("sys-chpw-submit-btn");
var resultEl = document.getElementById("sys-chpw-result");
var newPassword = document.getElementById("sys-chpw-new").value || "";
var confirmPassword = document.getElementById("sys-chpw-confirm").value || "";
if (!newPassword || !confirmPassword) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Both password fields are required.";
return;
}
if (newPassword.length < 8) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Password must be at least 8 characters.";
return;
}
if (newPassword !== confirmPassword) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Passwords do not match.";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Changing…";
resultEl.className = "matrix-form-result";
resultEl.textContent = "";
try {
await apiFetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ Free account & Hub login password changed successfully.";
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
} catch (err) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
}
});
}
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }