';
var infoBtnEl = tile.querySelector(".tile-info-btn");
if (infoBtnEl) {
infoBtnEl.addEventListener("click", function(e) {
e.stopPropagation();
openCredsModal(svc.unit, svc.name);
});
}
return tile;
}
// ββ Render: live update βββββββββββββββββββββββββββββββββββββββββββ
function updateTiles(services) {
_servicesCache = services;
for (var i = 0; i < services.length; i++) {
var svc = services[i];
if (svc.type === "support") continue;
var id = CSS.escape(tileId(svc));
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
if (!tile) continue;
var sc = statusClass(svc.status);
var st = statusText(svc.status, svc.enabled);
var dot = tile.querySelector(".status-dot");
var text = tile.querySelector(".status-text");
if (dot) dot.className = "status-dot " + sc;
if (text) text.textContent = st;
}
}
// ββ Service polling βββββββββββββββββββββββββββββββββββββββββββββββ
var _firstLoad = true;
async function refreshServices() {
try {
var services = await apiFetch("/api/services");
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
else { updateTiles(services); }
} catch (err) { console.warn("Failed to fetch services:", err); }
}
// ββ Network IPs βββββββββββββββββββββββββββββββββββββββββββββββββββ
async function loadNetwork() {
try {
var data = await apiFetch("/api/network");
if ($internalIp) $internalIp.textContent = data.internal_ip || "β";
if ($externalIp) $externalIp.textContent = data.external_ip || "β";
_cachedExternalIp = data.external_ip || "unavailable";
} catch (_) {
if ($internalIp) $internalIp.textContent = "β";
if ($externalIp) $externalIp.textContent = "β";
}
}
// ββ Update check ββββββββββββββββββββββββββββββββββββββββββββββββββ
async function checkUpdates() {
try {
var data = await apiFetch("/api/updates/check");
var hasUpdates = !!data.available;
if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates);
if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates);
} catch (_) {}
}
// ββ Credentials info modal ββββββββββββββββββββββββββββββββββββββββ
async function openCredsModal(unit, name) {
if (!$credsModal) return;
if ($credsTitle) $credsTitle.textContent = name + " β Connection Info";
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 = "";
for (var i = 0; i < data.credentials.length; i++) {
var cred = data.credentials[i];
var id = "cred-" + Math.random().toString(36).substring(2, 8);
var displayValue = linkify(cred.value);
var qrBlock = "";
if (cred.qrcode) {
qrBlock = '
';
}
}
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }
// ββ Tech Support modal ββββββββββββββββββββββββββββββββββββββββββββ
async function openSupportModal() {
if (!$supportModal) return;
$supportModal.classList.add("open");
$supportBody.innerHTML = '
Checking support statusβ¦
';
try {
var status = await apiFetch("/api/support/status");
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); }
else { renderSupportInactive(); }
} catch (err) {
$supportBody.innerHTML = '
Could not check support status.
';
}
}
function renderSupportInactive() {
stopSupportTimer();
var ip = _cachedExternalIp || "loadingβ¦";
$supportBody.innerHTML = '
π
Need help from Sovran Systems?
This will temporarily give Sovran Systems secure SSH access to your machine so we can diagnose and fix issues for you.
Your External IP' + escHtml(ip) + '
Give this IP to your Sovran Systems technician when asked.
What happens when you click Enable:
A Sovran Systems SSH key is added to this machine
You give us your External IP shown above
We connect and help you remotely
When done, you click End Support Session to remove the key
You can end the session at any time. The access key will be completely removed.
';
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
}
function renderSupportActive() {
var ip = _cachedExternalIp || "loadingβ¦";
$supportBody.innerHTML = '
π
Support Access is Active
Sovran Systems can currently connect to your machine via SSH.
Your External IP' + escHtml(ip) + '
Session Durationβ
When your support session is complete, click the button below to immediately remove the access key.
';
document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
startSupportTimer();
}
function renderSupportRemoved(verified) {
stopSupportTimer();
var icon = verified ? "β " : "β οΈ";
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot your machine to be sure.";
var vclass = verified ? "verified-gone" : "verify-warning";
var vlabel = verified ? "β Removed β No access" : "β Verify by rebooting";
$supportBody.innerHTML = '
' + icon + '
Support Session Ended
' + escHtml(msg) + '
SSH Key Status:' + vlabel + '
';
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
}
async function enableSupport() {
var btn = document.getElementById("btn-support-enable");
if (btn) { btn.disabled = true; btn.textContent = "Enablingβ¦"; }
try {
await apiFetch("/api/support/enable", { method: "POST" });
var status = await apiFetch("/api/support/status");
_supportEnabledAt = status.enabled_at;
renderSupportActive();
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
alert("Failed to enable support access. Please try again.");
}
}
async function disableSupport() {
var btn = document.getElementById("btn-support-disable");
if (btn) { btn.disabled = true; btn.textContent = "Removing keyβ¦"; }
try {
var result = await apiFetch("/api/support/disable", { method: "POST" });
renderSupportRemoved(result.verified);
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; }
alert("Failed to disable support access. Please try again.");
}
}
function startSupportTimer() {
stopSupportTimer();
updateSupportTimer();
_supportTimerInt = setInterval(updateSupportTimer, SUPPORT_TIMER_INTERVAL);
}
function stopSupportTimer() {
if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; }
}
function updateSupportTimer() {
var el = document.getElementById("support-timer");
if (!el || !_supportEnabledAt) return;
var elapsed = (Date.now() / 1000) - _supportEnabledAt;
el.textContent = formatDuration(Math.max(0, elapsed));
}
function closeSupportModal() {
if ($supportModal) $supportModal.classList.remove("open");
stopSupportTimer();
}
// ββ Update modal ββββββββββββββββββββββββββββββββββββββββββββββββββ
function openUpdateModal() {
if (!$modal) return;
_updateLog = "";
_updateLogOffset = 0;
_serverWasDown = false;
_updateFinished = false;
if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Starting updateβ¦";
if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true;
$modal.classList.add("open");
startUpdate();
}
function closeUpdateModal() {
if (!$modal) return;
$modal.classList.remove("open");
stopUpdatePoll();
}
function appendLog(text) {
if (!text) return;
_updateLog += text;
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
}
function startUpdate() {
fetch("/api/updates/run", { method: "POST" })
.then(function(response) {
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
return response.json();
})
.then(function(data) {
if (data.status === "already_running") appendLog("[Update already in progress, attachingβ¦]\n\n");
if ($modalStatus) $modalStatus.textContent = "Updatingβ¦";
startUpdatePoll();
})
.catch(function(err) {
appendLog("[Error: failed to start update β " + err + "]\n");
onUpdateDone(false);
});
}
function startUpdatePoll() {
pollUpdateStatus();
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
}
function stopUpdatePoll() {
if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; }
}
async function pollUpdateStatus() {
if (_updateFinished) return;
try {
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updatingβ¦"; }
if (data.log) appendLog(data.log);
_updateLogOffset = data.offset;
if (data.running) return;
_updateFinished = true;
stopUpdatePoll();
if (data.result === "success") onUpdateDone(true);
else onUpdateDone(false);
} catch (err) {
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting β waiting for it to come backβ¦]\n"); if ($modalStatus) $modalStatus.textContent = "Server restartingβ¦"; }
}
}
function onUpdateDone(success) {
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnCloseModal) $btnCloseModal.disabled = false;
if (success) {
if ($modalStatus) $modalStatus.textContent = "β Update complete";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else {
if ($modalStatus) $modalStatus.textContent = "β Update failed";
if ($btnSave) $btnSave.style.display = "inline-flex";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
}
}
function saveErrorReport() {
var blob = new Blob([_updateLog], { type: "text/plain" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ββ Reboot ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function doReboot() {
if ($modal) $modal.classList.remove("open");
stopUpdatePoll();
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
fetch("/api/reboot", { method: "POST" }).catch(function() {});
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
}
function waitForServerReboot() {
fetch("/api/config", { cache: "no-store" })
.then(function(res) {
if (res.ok) window.location.reload();
else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
})
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
}
// ββ Rebuild modal βββββββββββββββββββββββββββββββββββββββββββββββββ
function openRebuildModal() {
if (!$rebuildModal) return;
_rebuildLog = "";
_rebuildLogOffset = 0;
_rebuildServerDown = false;
_rebuildFinished = false;
if ($rebuildLog) $rebuildLog.textContent = "";
if ($rebuildStatus) $rebuildStatus.textContent = "Rebuildingβ¦";
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
if ($rebuildReboot) $rebuildReboot.style.display = "none";
if ($rebuildSave) $rebuildSave.style.display = "none";
if ($rebuildClose) $rebuildClose.disabled = true;
$rebuildModal.classList.add("open");
startRebuildPoll();
}
function closeRebuildModal() {
if ($rebuildModal) $rebuildModal.classList.remove("open");
stopRebuildPoll();
}
function appendRebuildLog(text) {
if (!text) return;
_rebuildLog += text;
if ($rebuildLog) { $rebuildLog.textContent += text; $rebuildLog.scrollTop = $rebuildLog.scrollHeight; }
}
function startRebuildPoll() {
pollRebuildStatus();
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
}
function stopRebuildPoll() {
if (_rebuildPollTimer) { clearInterval(_rebuildPollTimer); _rebuildPollTimer = null; }
}
async function pollRebuildStatus() {
if (_rebuildFinished) return;
try {
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
if (_rebuildServerDown) { _rebuildServerDown = false; appendRebuildLog("[Server reconnected]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Rebuildingβ¦"; }
if (data.log) appendRebuildLog(data.log);
_rebuildLogOffset = data.offset;
if (data.running) return;
_rebuildFinished = true;
stopRebuildPoll();
onRebuildDone(data.result === "success");
} catch (err) {
if (!_rebuildServerDown) { _rebuildServerDown = true; appendRebuildLog("\n[Server restarting β waiting for it to come backβ¦]\n"); if ($rebuildStatus) $rebuildStatus.textContent = "Server restartingβ¦"; }
}
}
function onRebuildDone(success) {
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
if ($rebuildClose) $rebuildClose.disabled = false;
if (success) {
if ($rebuildStatus) $rebuildStatus.textContent = "β Rebuild complete";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
// Refresh feature states
loadFeatureManager();
} else {
if ($rebuildStatus) $rebuildStatus.textContent = "β Rebuild failed";
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
}
}
function saveRebuildErrorReport() {
var blob = new Blob([_rebuildLog], { type: "text/plain" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "sovran-rebuild-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ββ Feature confirm modal βββββββββββββββββββββββββββββββββββββββββ
function openFeatureConfirm(message, onConfirm) {
if (!$featureConfirmModal) return;
if ($featureConfirmMsg) $featureConfirmMsg.textContent = message;
$featureConfirmModal.classList.add("open");
// Replace ok handler
var newOk = $featureConfirmOk.cloneNode(true);
$featureConfirmOk.parentNode.replaceChild(newOk, $featureConfirmOk);
newOk.addEventListener("click", function() {
closeFeatureConfirm();
onConfirm();
});
}
function closeFeatureConfirm() {
if ($featureConfirmModal) $featureConfirmModal.classList.remove("open");
}
// ββ SSL Email modal βββββββββββββββββββββββββββββββββββββββββββββββ
function openSslEmailModal(onSaved) {
if (!$sslEmailModal) return;
if ($sslEmailInput) $sslEmailInput.value = "";
$sslEmailModal.classList.add("open");
// Replace save handler
var newSave = $sslEmailSave.cloneNode(true);
$sslEmailSave.parentNode.replaceChild(newSave, $sslEmailSave);
newSave.addEventListener("click", async function() {
var email = $sslEmailInput ? $sslEmailInput.value.trim() : "";
if (!email) { alert("Please enter an email address."); return; }
newSave.disabled = true;
newSave.textContent = "Savingβ¦";
try {
await apiFetch("/api/domains/set-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email }),
});
closeSslEmailModal();
onSaved();
} catch (err) {
newSave.disabled = false;
newSave.textContent = "Save";
alert("Failed to save email. Please try again.");
}
});
}
function closeSslEmailModal() {
if ($sslEmailModal) $sslEmailModal.classList.remove("open");
}
// ββ Domain Setup modal ββββββββββββββββββββββββββββββββββββββββββββ
function openDomainSetupModal(feat, onSaved) {
if (!$domainSetupModal) return;
if ($domainSetupTitle) $domainSetupTitle.textContent = "π Domain Setup β " + feat.name;
var npubField = "";
if (feat.id === "haven") {
var currentNpub = "";
if (feat.extra_fields && feat.extra_fields.length > 0) {
for (var i = 0; i < feat.extra_fields.length; i++) {
if (feat.extra_fields[i].id === "nostr_npub") {
currentNpub = feat.extra_fields[i].current_value || "";
break;
}
}
}
npubField = '';
}
$domainSetupBody.innerHTML =
'
Before continuing, you need:
A subdomain purchased on njal.la
A Dynamic DNS record for it
' +
'' +
'
βΉ Paste the curl URL from your Njal.la dashboard\'s Dynamic record
' +
npubField +
'';
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
subdomain = subdomain.trim();
ddnsUrl = ddnsUrl.trim();
npub = npub.trim();
if (!subdomain) { alert("Please enter a subdomain."); return; }
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
var saveBtn = document.getElementById("domain-setup-save-btn");
saveBtn.disabled = true;
saveBtn.textContent = "Savingβ¦";
try {
await apiFetch("/api/domains/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
domain_name: feat.domain_name,
domain: subdomain,
ddns_url: ddnsUrl,
}),
});
closeDomainSetupModal();
onSaved(npub);
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Enable";
alert("Failed to save domain. Please try again.");
}
});
$domainSetupModal.classList.add("open");
}
function closeDomainSetupModal() {
if ($domainSetupModal) $domainSetupModal.classList.remove("open");
}
// ββ Feature toggle logic ββββββββββββββββββββββββββββββββββββββββββ
async function performFeatureToggle(featId, enabled, extra) {
try {
var res = await fetch("/api/features/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ feature: featId, enabled: enabled, extra: extra || {} }),
});
var body = await res.json();
if (!res.ok) {
if (body && body.error === "domain_required") {
alert("Domain not configured for this feature. Please configure it first.");
} else {
alert("Error: " + (body.detail || body.error || "Unknown error"));
}
loadFeatureManager();
return;
}
openRebuildModal();
} catch (err) {
alert("Failed to toggle feature: " + err);
loadFeatureManager();
}
}
function handleFeatureToggle(feat, newEnabled) {
if (!newEnabled) {
// Disable: ask confirmation
openFeatureConfirm(
"This will disable " + feat.name + ". The system will rebuild. Continue?",
function() { performFeatureToggle(feat.id, false, {}); }
);
return;
}
// Enabling
var conflictNames = [];
if (feat.conflicts_with && feat.conflicts_with.length > 0 && _featuresData) {
feat.conflicts_with.forEach(function(cid) {
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
if (cf && cf.enabled) conflictNames.push(cf.name);
});
}
function proceedAfterConflictCheck() {
// Check SSL email first
if (!_featuresData || !_featuresData.ssl_email_configured) {
if (feat.needs_domain) {
openSslEmailModal(function() {
// After ssl email saved, check domain
checkDomainAndEnable(feat, {});
});
return;
}
}
if (feat.needs_domain && !feat.domain_configured) {
checkDomainAndEnable(feat, {});
return;
}
if (feat.id === "haven") {
var npub = "";
if (feat.extra_fields) {
var ef = feat.extra_fields.find(function(e) { return e.id === "nostr_npub"; });
if (ef) npub = ef.current_value || "";
}
if (!npub) {
// Need to collect npub via domain modal
openDomainSetupModal(feat, function(collectedNpub) {
performFeatureToggle(feat.id, true, { nostr_npub: collectedNpub });
});
return;
}
}
performFeatureToggle(feat.id, true, {});
}
if (conflictNames.length > 0) {
openFeatureConfirm(
"This will disable " + conflictNames.join(", ") + ". Continue?",
proceedAfterConflictCheck
);
} else {
proceedAfterConflictCheck();
}
}
function checkDomainAndEnable(feat, extra) {
openDomainSetupModal(feat, function(collectedNpub) {
var extraData = {};
if (collectedNpub) extraData.nostr_npub = collectedNpub;
performFeatureToggle(feat.id, true, extraData);
});
}
// ββ Feature Manager rendering βββββββββββββββββββββββββββββββββββββ
async function loadFeatureManager() {
try {
var data = await apiFetch("/api/features");
_featuresData = data;
renderFeatureManager(data);
} catch (err) {
console.warn("Failed to load features:", err);
}
}
function renderFeatureManager(data) {
// Remove old feature manager section if it exists
var old = $tilesArea.querySelector(".feature-manager-section");
if (old) old.parentNode.removeChild(old);
var section = document.createElement("div");
section.className = "category-section feature-manager-section";
section.dataset.category = "feature-manager";
section.innerHTML = '
Feature Manager
';
// Group by sub-category
var grouped = {};
for (var i = 0; i < data.features.length; i++) {
var f = data.features[i];
var cat = f.category || "other";
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(f);
}
var orderedCats = FEATURE_SUBCATEGORY_ORDER.filter(function(k) { return grouped[k]; });
Object.keys(grouped).forEach(function(k) {
if (orderedCats.indexOf(k) === -1) orderedCats.push(k);
});
for (var j = 0; j < orderedCats.length; j++) {
var catKey = orderedCats[j];
var feats = grouped[catKey];
if (!feats || feats.length === 0) continue;
var subcat = document.createElement("div");
subcat.className = "feature-subcategory";
var subcatLabel = FEATURE_SUBCATEGORY_LABELS[catKey] || catKey;
subcat.innerHTML = '
' + escHtml(subcatLabel) + '
';
var cardsWrap = document.createElement("div");
cardsWrap.className = "feature-cards-wrap";
for (var k = 0; k < feats.length; k++) {
cardsWrap.appendChild(buildFeatureCard(feats[k]));
}
subcat.appendChild(cardsWrap);
section.appendChild(subcat);
}
$tilesArea.appendChild(section);
}
function buildFeatureCard(feat) {
var card = document.createElement("div");
card.className = "feature-card";
var conflictHtml = "";
if (feat.conflicts_with && feat.conflicts_with.length > 0) {
var conflictNames = feat.conflicts_with.map(function(cid) {
if (!_featuresData) return cid;
var cf = _featuresData.features.find(function(f) { return f.id === cid; });
return cf ? cf.name : cid;
});
conflictHtml = '