`;
const infoBtnEl = tile.querySelector(".tile-info-btn");
if (infoBtnEl) {
infoBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
openCredsModal(svc.unit, svc.name);
});
}
return tile;
}
// ── Render: live update (no DOM rebuild) ──────────────────────────
function updateTiles(services) {
_servicesCache = services;
for (const svc of services) {
const id = CSS.escape(tileId(svc));
const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`);
if (!tile) continue;
if (svc.type === "support") continue; // Support tile doesn't have a systemd status
const sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled);
const dot = tile.querySelector(".status-dot");
const text = tile.querySelector(".status-text");
if (dot) { dot.className = `status-dot ${sc}`; }
if (text) { text.textContent = st; }
}
}
// ── Service polling ───────────────────────────────────────────────
let _firstLoad = true;
async function refreshServices() {
try {
const 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 {
const 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 {
const data = await apiFetch("/api/updates/check");
const 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 {
const data = await apiFetch(`/api/credentials/${encodeURIComponent(unit)}`);
if (!data.credentials || data.credentials.length === 0) {
$credsBody.innerHTML = '
No connection info available yet.
';
return;
}
let html = "";
for (const cred of data.credentials) {
const id = "cred-" + Math.random().toString(36).substring(2, 8);
const displayValue = linkify(cred.value);
let qrBlock = "";
if (cred.qrcode) {
qrBlock = `
';
}
}
function renderSupportInactive() {
stopSupportTimer();
const 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() {
const 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();
const icon = verified ? "✅" : "⚠️";
const 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.";
$supportBody.innerHTML = `
${icon}
Support Session Ended
${escHtml(msg)}
SSH Key Status:
${verified ? "✓ Removed — No access" : "⚠ Verify by rebooting"}
`;
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
}
async function enableSupport() {
const btn = document.getElementById("btn-support-enable");
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
try {
await apiFetch("/api/support/enable", { method: "POST" });
const 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() {
const btn = document.getElementById("btn-support-disable");
if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; }
try {
const 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() {
const el = document.getElementById("support-timer");
if (!el || !_supportEnabledAt) return;
const 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(response => {
if (!response.ok) {
return response.text().then(t => { throw new Error(t); });
}
return response.json();
})
.then(data => {
if (data.status === "already_running") {
appendLog("[Update already in progress, attaching…]\n\n");
}
if ($modalStatus) $modalStatus.textContent = "Updating…";
startUpdatePoll();
})
.catch(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(_