updated Feature Manager
This commit is contained in:
@@ -22,7 +22,7 @@ from pydantic import BaseModel
|
|||||||
from .config import load_config
|
from .config import load_config
|
||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────<EFBFBD><EFBFBD>─────────────────────
|
# ── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
|
FLAKE_LOCK_PATH = "/etc/nixos/flake.lock"
|
||||||
FLAKE_INPUT_NAME = "Sovran_Systems"
|
FLAKE_INPUT_NAME = "Sovran_Systems"
|
||||||
@@ -146,6 +146,16 @@ FEATURE_REGISTRY = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Map feature IDs to their systemd units in config.json
|
||||||
|
FEATURE_SERVICE_MAP = {
|
||||||
|
"rdp": "gnome-remote-desktop.service",
|
||||||
|
"haven": "haven-relay.service",
|
||||||
|
"element-calling": "livekit.service",
|
||||||
|
"mempool": "mempool-frontend.service",
|
||||||
|
"bip110": None,
|
||||||
|
"bitcoin-core": None,
|
||||||
|
}
|
||||||
|
|
||||||
ROLE_LABELS = {
|
ROLE_LABELS = {
|
||||||
"server_plus_desktop": "Server + Desktop",
|
"server_plus_desktop": "Server + Desktop",
|
||||||
"desktop": "Desktop Only",
|
"desktop": "Desktop Only",
|
||||||
@@ -191,7 +201,7 @@ def _file_hash(filename: str) -> str:
|
|||||||
_APP_JS_HASH = _file_hash("app.js")
|
_APP_JS_HASH = _file_hash("app.js")
|
||||||
_STYLE_CSS_HASH = _file_hash("style.css")
|
_STYLE_CSS_HASH = _file_hash("style.css")
|
||||||
|
|
||||||
# ── Update check helpers ──────────────────<EFBFBD><EFBFBD>──────────────────────
|
# ── Update check helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
def _get_locked_info():
|
def _get_locked_info():
|
||||||
try:
|
try:
|
||||||
@@ -463,6 +473,21 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None) -> None:
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feature status helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
||||||
|
"""Check if a feature's service appears as enabled in the running config.json.
|
||||||
|
Returns True/False if found, None if the feature has no mapped service."""
|
||||||
|
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||||
|
if unit is None:
|
||||||
|
return None # bip110, bitcoin-core — can't determine from config
|
||||||
|
cfg = load_config()
|
||||||
|
for svc in cfg.get("services", []):
|
||||||
|
if svc.get("unit") == unit:
|
||||||
|
return svc.get("enabled", False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Tech Support helpers ──────────────────────────────────────────
|
# ── Tech Support helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
def _is_support_active() -> bool:
|
def _is_support_active() -> bool:
|
||||||
@@ -582,7 +607,6 @@ async def api_config():
|
|||||||
"role": role,
|
"role": role,
|
||||||
"role_label": ROLE_LABELS.get(role, role),
|
"role_label": ROLE_LABELS.get(role, role),
|
||||||
"category_order": CATEGORY_ORDER,
|
"category_order": CATEGORY_ORDER,
|
||||||
"feature_manager": cfg.get("feature_manager", False),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -786,7 +810,18 @@ async def api_features():
|
|||||||
features = []
|
features = []
|
||||||
for feat in FEATURE_REGISTRY:
|
for feat in FEATURE_REGISTRY:
|
||||||
feat_id = feat["id"]
|
feat_id = feat["id"]
|
||||||
enabled = overrides.get(feat_id, False)
|
|
||||||
|
# Determine enabled state:
|
||||||
|
# 1. Check hub-overrides.nix first (explicit hub toggle)
|
||||||
|
# 2. Fall back to config.json services (features enabled in custom.nix)
|
||||||
|
if feat_id in overrides:
|
||||||
|
enabled = overrides[feat_id]
|
||||||
|
else:
|
||||||
|
config_state = _is_feature_enabled_in_config(feat_id)
|
||||||
|
if config_state is not None:
|
||||||
|
enabled = config_state
|
||||||
|
else:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
domain_name = feat.get("domain_name")
|
domain_name = feat.get("domain_name")
|
||||||
domain_configured = True
|
domain_configured = True
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
/* Sovran_SystemsOS Hub — Vanilla JS Frontend
|
||||||
v7 — Status-only dashboard + Tech Support + Feature Manager */
|
v8 — Status-only dashboard + Tech Support + Feature Manager */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const POLL_INTERVAL_SERVICES = 5000;
|
var POLL_INTERVAL_SERVICES = 5000;
|
||||||
const POLL_INTERVAL_UPDATES = 1800000;
|
var POLL_INTERVAL_UPDATES = 1800000;
|
||||||
const UPDATE_POLL_INTERVAL = 2000;
|
var UPDATE_POLL_INTERVAL = 2000;
|
||||||
const REBOOT_CHECK_INTERVAL = 5000;
|
var REBOOT_CHECK_INTERVAL = 5000;
|
||||||
const SUPPORT_TIMER_INTERVAL = 1000;
|
var SUPPORT_TIMER_INTERVAL = 1000;
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
var CATEGORY_ORDER = [
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
"bitcoin-base",
|
"bitcoin-base",
|
||||||
"bitcoin-apps",
|
"bitcoin-apps",
|
||||||
@@ -19,97 +19,104 @@ const CATEGORY_ORDER = [
|
|||||||
"feature-manager",
|
"feature-manager",
|
||||||
];
|
];
|
||||||
|
|
||||||
const FEATURE_SUBCATEGORY_LABELS = {
|
var FEATURE_SUBCATEGORY_LABELS = {
|
||||||
"infrastructure": "🔧 Infrastructure",
|
"infrastructure": "🔧 Infrastructure",
|
||||||
"bitcoin": "₿ Bitcoin",
|
"bitcoin": "₿ Bitcoin",
|
||||||
"communication": "💬 Communication",
|
"communication": "💬 Communication",
|
||||||
"nostr": "📡 Nostr",
|
"nostr": "📡 Nostr",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"];
|
var FEATURE_SUBCATEGORY_ORDER = ["infrastructure", "bitcoin", "communication", "nostr"];
|
||||||
|
|
||||||
const STATUS_LOADING_STATES = new Set([
|
var FEATURE_UNIT_MAP = {
|
||||||
|
"rdp": "gnome-remote-desktop.service",
|
||||||
|
"haven": "haven-relay.service",
|
||||||
|
"element-calling": "livekit.service",
|
||||||
|
"mempool": "mempool-frontend.service",
|
||||||
|
};
|
||||||
|
|
||||||
|
var STATUS_LOADING_STATES = new Set([
|
||||||
"reloading", "activating", "deactivating", "maintenance",
|
"reloading", "activating", "deactivating", "maintenance",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _servicesCache = [];
|
var _servicesCache = [];
|
||||||
let _categoryLabels = {};
|
var _categoryLabels = {};
|
||||||
let _updateLog = "";
|
var _updateLog = "";
|
||||||
let _updatePollTimer = null;
|
var _updatePollTimer = null;
|
||||||
let _updateLogOffset = 0;
|
var _updateLogOffset = 0;
|
||||||
let _serverWasDown = false;
|
var _serverWasDown = false;
|
||||||
let _updateFinished = false;
|
var _updateFinished = false;
|
||||||
let _supportTimerInt = null;
|
var _supportTimerInt = null;
|
||||||
let _supportEnabledAt = null;
|
var _supportEnabledAt = null;
|
||||||
let _cachedExternalIp = null;
|
var _cachedExternalIp = null;
|
||||||
|
|
||||||
// Feature Manager state
|
// Feature Manager state
|
||||||
let _featuresData = null;
|
var _featuresData = null;
|
||||||
let _rebuildLog = "";
|
var _rebuildLog = "";
|
||||||
let _rebuildLogOffset = 0;
|
var _rebuildLogOffset = 0;
|
||||||
let _rebuildPollTimer = null;
|
var _rebuildPollTimer = null;
|
||||||
let _rebuildFinished = false;
|
var _rebuildFinished = false;
|
||||||
let _rebuildServerDown = false;
|
var _rebuildServerDown = false;
|
||||||
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
|
var _pendingToggle = null;
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const $tilesArea = document.getElementById("tiles-area");
|
var $tilesArea = document.getElementById("tiles-area");
|
||||||
const $updateBtn = document.getElementById("btn-update");
|
var $updateBtn = document.getElementById("btn-update");
|
||||||
const $updateBadge = document.getElementById("update-badge");
|
var $updateBadge = document.getElementById("update-badge");
|
||||||
const $refreshBtn = document.getElementById("btn-refresh");
|
var $refreshBtn = document.getElementById("btn-refresh");
|
||||||
const $internalIp = document.getElementById("ip-internal");
|
var $internalIp = document.getElementById("ip-internal");
|
||||||
const $externalIp = document.getElementById("ip-external");
|
var $externalIp = document.getElementById("ip-external");
|
||||||
|
|
||||||
const $modal = document.getElementById("update-modal");
|
var $modal = document.getElementById("update-modal");
|
||||||
const $modalSpinner = document.getElementById("modal-spinner");
|
var $modalSpinner = document.getElementById("modal-spinner");
|
||||||
const $modalStatus = document.getElementById("modal-status");
|
var $modalStatus = document.getElementById("modal-status");
|
||||||
const $modalLog = document.getElementById("modal-log");
|
var $modalLog = document.getElementById("modal-log");
|
||||||
const $btnReboot = document.getElementById("btn-reboot");
|
var $btnReboot = document.getElementById("btn-reboot");
|
||||||
const $btnSave = document.getElementById("btn-save-report");
|
var $btnSave = document.getElementById("btn-save-report");
|
||||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
var $btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
|
||||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
var $rebootOverlay = document.getElementById("reboot-overlay");
|
||||||
|
|
||||||
const $credsModal = document.getElementById("creds-modal");
|
var $credsModal = document.getElementById("creds-modal");
|
||||||
const $credsTitle = document.getElementById("creds-modal-title");
|
var $credsTitle = document.getElementById("creds-modal-title");
|
||||||
const $credsBody = document.getElementById("creds-body");
|
var $credsBody = document.getElementById("creds-body");
|
||||||
const $credsCloseBtn = document.getElementById("creds-close-btn");
|
var $credsCloseBtn = document.getElementById("creds-close-btn");
|
||||||
|
|
||||||
const $supportModal = document.getElementById("support-modal");
|
var $supportModal = document.getElementById("support-modal");
|
||||||
const $supportBody = document.getElementById("support-body");
|
var $supportBody = document.getElementById("support-body");
|
||||||
const $supportCloseBtn = document.getElementById("support-close-btn");
|
var $supportCloseBtn = document.getElementById("support-close-btn");
|
||||||
|
|
||||||
// Feature Manager — rebuild modal
|
// Feature Manager — rebuild modal
|
||||||
const $rebuildModal = document.getElementById("rebuild-modal");
|
var $rebuildModal = document.getElementById("rebuild-modal");
|
||||||
const $rebuildSpinner = document.getElementById("rebuild-spinner");
|
var $rebuildSpinner = document.getElementById("rebuild-spinner");
|
||||||
const $rebuildStatus = document.getElementById("rebuild-status");
|
var $rebuildStatus = document.getElementById("rebuild-status");
|
||||||
const $rebuildLog = document.getElementById("rebuild-log");
|
var $rebuildLogEl = document.getElementById("rebuild-log");
|
||||||
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
var $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
||||||
const $rebuildSave = document.getElementById("rebuild-save-report");
|
var $rebuildSave = document.getElementById("rebuild-save-report");
|
||||||
const $rebuildClose = document.getElementById("rebuild-close-btn");
|
var $rebuildClose = document.getElementById("rebuild-close-btn");
|
||||||
|
|
||||||
// Feature Manager — domain setup modal
|
// Feature Manager — domain setup modal
|
||||||
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
var $domainSetupModal = document.getElementById("domain-setup-modal");
|
||||||
const $domainSetupTitle = document.getElementById("domain-setup-title");
|
var $domainSetupTitle = document.getElementById("domain-setup-title");
|
||||||
const $domainSetupBody = document.getElementById("domain-setup-body");
|
var $domainSetupBody = document.getElementById("domain-setup-body");
|
||||||
const $domainSetupClose = document.getElementById("domain-setup-close-btn");
|
var $domainSetupClose = document.getElementById("domain-setup-close-btn");
|
||||||
|
|
||||||
// Feature Manager — SSL email modal
|
// Feature Manager — SSL email modal
|
||||||
const $sslEmailModal = document.getElementById("ssl-email-modal");
|
var $sslEmailModal = document.getElementById("ssl-email-modal");
|
||||||
const $sslEmailInput = document.getElementById("ssl-email-input");
|
var $sslEmailInput = document.getElementById("ssl-email-input");
|
||||||
const $sslEmailSave = document.getElementById("ssl-email-save-btn");
|
var $sslEmailSave = document.getElementById("ssl-email-save-btn");
|
||||||
const $sslEmailCancel = document.getElementById("ssl-email-cancel-btn");
|
var $sslEmailCancel = document.getElementById("ssl-email-cancel-btn");
|
||||||
const $sslEmailClose = document.getElementById("ssl-email-close-btn");
|
var $sslEmailClose = document.getElementById("ssl-email-close-btn");
|
||||||
|
|
||||||
// Feature Manager — confirm modal
|
// Feature Manager — confirm modal
|
||||||
const $featureConfirmModal = document.getElementById("feature-confirm-modal");
|
var $featureConfirmModal = document.getElementById("feature-confirm-modal");
|
||||||
const $featureConfirmMsg = document.getElementById("feature-confirm-message");
|
var $featureConfirmMsg = document.getElementById("feature-confirm-message");
|
||||||
const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn");
|
var $featureConfirmOk = document.getElementById("feature-confirm-ok-btn");
|
||||||
const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn");
|
var $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn");
|
||||||
const $featureConfirmClose = document.getElementById("feature-confirm-close-btn");
|
var $featureConfirmClose = document.getElementById("feature-confirm-close-btn");
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -140,9 +147,9 @@ function linkify(str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
const h = Math.floor(seconds / 3600);
|
var h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
var m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
var s = Math.floor(seconds % 60);
|
||||||
if (h > 0) return h + "h " + m + "m " + s + "s";
|
if (h > 0) return h + "h " + m + "m " + s + "s";
|
||||||
if (m > 0) return m + "m " + s + "s";
|
if (m > 0) return m + "m " + s + "s";
|
||||||
return s + "s";
|
return s + "s";
|
||||||
@@ -151,7 +158,7 @@ function formatDuration(seconds) {
|
|||||||
// ── Fetch wrappers ────────────────────────────────────────────────
|
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiFetch(path, options) {
|
async function apiFetch(path, options) {
|
||||||
const res = await fetch(path, options || {});
|
var res = await fetch(path, options || {});
|
||||||
if (!res.ok) throw new Error(res.status + " " + res.statusText);
|
if (!res.ok) throw new Error(res.status + " " + res.statusText);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -280,7 +287,7 @@ async function checkUpdates() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Credentials info modal ────────────────────────────────────────
|
// ── Credentials info modal ──────────<EFBFBD><EFBFBD>─────────────────────────────
|
||||||
|
|
||||||
async function openCredsModal(unit, name) {
|
async function openCredsModal(unit, name) {
|
||||||
if (!$credsModal) return;
|
if (!$credsModal) return;
|
||||||
@@ -369,640 +376,3 @@ async function enableSupport() {
|
|||||||
try {
|
try {
|
||||||
await apiFetch("/api/support/enable", { method: "POST" });
|
await apiFetch("/api/support/enable", { method: "POST" });
|
||||||
var status = await apiFetch("/api/support/status");
|
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 = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1…" value="' + escHtml(currentNpub) + '" /></div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$domainSetupBody.innerHTML =
|
|
||||||
'<div class="domain-setup-intro"><p>Before continuing, you need:</p><ol><li>A subdomain purchased on njal.la</li><li>A Dynamic DNS record for it</li></ol></div>' +
|
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain:</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="relay.mydomain.com" /></div>' +
|
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la DDNS URL:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="https://njal.la/update/?h=..." /><p class="domain-field-hint">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
|
||||||
npubField +
|
|
||||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
|
||||||
|
|
||||||
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 = '<div class="section-header">Feature Manager</div><hr class="section-divider" />';
|
|
||||||
|
|
||||||
// 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 = '<div class="feature-subcategory-header">' + escHtml(subcatLabel) + '</div>';
|
|
||||||
|
|
||||||
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 = '<div class="feature-conflict-warning">⚠ Conflicts with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainHtml = "";
|
|
||||||
if (feat.needs_domain) {
|
|
||||||
if (feat.domain_configured) {
|
|
||||||
domainHtml = '<div class="feature-domain-badge configured">🌐 Domain: Configured</div>';
|
|
||||||
} else {
|
|
||||||
domainHtml = '<div class="feature-domain-badge not-configured">🌐 Domain: Not configured</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusText = feat.enabled ? "Enabled" : "Disabled";
|
|
||||||
|
|
||||||
card.innerHTML =
|
|
||||||
'<div class="feature-card-top">' +
|
|
||||||
'<div class="feature-card-info">' +
|
|
||||||
'<div class="feature-card-name">' + escHtml(feat.name) + '</div>' +
|
|
||||||
'<div class="feature-card-desc">' + escHtml(feat.description) + '</div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<label class="feature-toggle' + (feat.enabled ? " active" : "") + '" title="Toggle ' + escHtml(feat.name) + '">' +
|
|
||||||
'<input type="checkbox" class="feature-toggle-input"' + (feat.enabled ? " checked" : "") + ' />' +
|
|
||||||
'<span class="feature-toggle-slider"></span>' +
|
|
||||||
'</label>' +
|
|
||||||
'</div>' +
|
|
||||||
domainHtml +
|
|
||||||
conflictHtml +
|
|
||||||
'<div class="feature-card-status">Status: ' + escHtml(statusText) + '</div>';
|
|
||||||
|
|
||||||
var toggle = card.querySelector(".feature-toggle-input");
|
|
||||||
var toggleLabel = card.querySelector(".feature-toggle");
|
|
||||||
toggle.addEventListener("change", function() {
|
|
||||||
var newEnabled = toggle.checked;
|
|
||||||
// Revert visually to original state while confirmation/modal is pending
|
|
||||||
toggle.checked = feat.enabled;
|
|
||||||
if (feat.enabled) { toggleLabel.classList.add("active"); } else { toggleLabel.classList.remove("active"); }
|
|
||||||
handleFeatureToggle(feat, newEnabled);
|
|
||||||
});
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Event listeners ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
|
|
||||||
if ($refreshBtn) $refreshBtn.addEventListener("click", function() { refreshServices(); });
|
|
||||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
|
||||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
|
||||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
|
||||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
|
||||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
|
||||||
|
|
||||||
// Rebuild modal
|
|
||||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
|
||||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
|
||||||
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
|
||||||
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
|
||||||
|
|
||||||
// Domain setup modal
|
|
||||||
if ($domainSetupClose) $domainSetupClose.addEventListener("click", closeDomainSetupModal);
|
|
||||||
if ($domainSetupModal) $domainSetupModal.addEventListener("click", function(e) { if (e.target === $domainSetupModal) closeDomainSetupModal(); });
|
|
||||||
|
|
||||||
// SSL Email modal
|
|
||||||
if ($sslEmailClose) $sslEmailClose.addEventListener("click", closeSslEmailModal);
|
|
||||||
if ($sslEmailCancel) $sslEmailCancel.addEventListener("click", closeSslEmailModal);
|
|
||||||
if ($sslEmailModal) $sslEmailModal.addEventListener("click", function(e) { if (e.target === $sslEmailModal) closeSslEmailModal(); });
|
|
||||||
|
|
||||||
// Feature confirm modal
|
|
||||||
if ($featureConfirmClose) $featureConfirmClose.addEventListener("click", closeFeatureConfirm);
|
|
||||||
if ($featureConfirmCancel) $featureConfirmCancel.addEventListener("click", closeFeatureConfirm);
|
|
||||||
if ($featureConfirmModal) $featureConfirmModal.addEventListener("click", function(e) { if (e.target === $featureConfirmModal) closeFeatureConfirm(); });
|
|
||||||
|
|
||||||
if ($modal) $modal.addEventListener("click", function(e) { if (e.target === $modal) closeUpdateModal(); });
|
|
||||||
if ($credsModal) $credsModal.addEventListener("click", function(e) { if (e.target === $credsModal) closeCredsModal(); });
|
|
||||||
if ($supportModal) $supportModal.addEventListener("click", function(e) { if (e.target === $supportModal) closeSupportModal(); });
|
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
var cfg = await apiFetch("/api/config");
|
|
||||||
if (cfg.category_order) {
|
|
||||||
for (var i = 0; i < cfg.category_order.length; i++) {
|
|
||||||
_categoryLabels[cfg.category_order[i][0]] = cfg.category_order[i][1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var badge = document.getElementById("role-badge");
|
|
||||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
|
||||||
|
|
||||||
await refreshServices();
|
|
||||||
loadNetwork();
|
|
||||||
checkUpdates();
|
|
||||||
|
|
||||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
|
||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
|
||||||
|
|
||||||
if (cfg.feature_manager) {
|
|
||||||
loadFeatureManager();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
await refreshServices();
|
|
||||||
loadNetwork();
|
|
||||||
checkUpdates();
|
|
||||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
|
||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
|
||||||
Reference in New Issue
Block a user