fixed service layout

This commit is contained in:
2026-04-02 17:20:14 -05:00
parent e436b2f7a6
commit 71a25c12ab
3 changed files with 434 additions and 311 deletions

View File

@@ -2,11 +2,11 @@
v7 — Status-only dashboard + Tech Support */ v7 — Status-only dashboard + Tech Support */
"use strict"; "use strict";
const POLL_INTERVAL_SERVICES = 5000; // 5 s const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000; // 30 min const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000; // 2 s while update is running const UPDATE_POLL_INTERVAL = 2000;
const REBOOT_CHECK_INTERVAL = 5000; // 5 s between reconnect attempts const REBOOT_CHECK_INTERVAL = 5000;
const SUPPORT_TIMER_INTERVAL = 1000; // 1 s for session timer const SUPPORT_TIMER_INTERVAL = 1000;
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
"infrastructure", "infrastructure",
@@ -65,9 +65,7 @@ const $supportCloseBtn = document.getElementById("support-close-btn");
// ── Helpers ─────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────
function tileId(svc) { function tileId(svc) { return svc.unit + "::" + svc.name; }
return svc.unit + "::" + svc.name;
}
function statusClass(status) { function statusClass(status) {
if (!status) return "unknown"; if (!status) return "unknown";
@@ -86,36 +84,27 @@ function statusText(status, enabled) {
} }
function escHtml(str) { function escHtml(str) {
return String(str) return String(str).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
} }
function linkify(str) { function linkify(str) {
const escaped = escHtml(str); return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
return escaped.replace(
/(https?:\/\/[^\s<]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>'
);
} }
function formatDuration(seconds) { function formatDuration(seconds) {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60); const 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";
} }
// ── Fetch wrappers ──────────────────────────────────────────────── // ── Fetch wrappers ────────────────────────────────────────────────
async function apiFetch(path, options = {}) { async function apiFetch(path, options) {
const res = await fetch(path, options); const 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();
} }
@@ -123,157 +112,106 @@ async function apiFetch(path, options = {}) {
function buildTiles(services, categoryLabels) { function buildTiles(services, categoryLabels) {
_servicesCache = services; _servicesCache = services;
var grouped = {};
const grouped = {}; for (var i = 0; i < services.length; i++) {
for (const svc of services) { var cat = services[i].category || "other";
const cat = svc.category || "other";
if (!grouped[cat]) grouped[cat] = []; if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(svc); grouped[cat].push(services[i]);
} }
$tilesArea.innerHTML = ""; $tilesArea.innerHTML = "";
var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; });
const orderedKeys = [ Object.keys(grouped).forEach(function(k) {
...CATEGORY_ORDER.filter(k => grouped[k]), if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k);
...Object.keys(grouped).filter(k => !CATEGORY_ORDER.includes(k)), });
]; for (var j = 0; j < orderedKeys.length; j++) {
var catKey = orderedKeys[j];
for (const catKey of orderedKeys) { var entries = grouped[catKey];
const entries = grouped[catKey];
if (!entries || entries.length === 0) continue; if (!entries || entries.length === 0) continue;
var label = categoryLabels[catKey] || catKey;
const label = categoryLabels[catKey] || catKey; var section = document.createElement("div");
const section = document.createElement("div");
section.className = "category-section"; section.className = "category-section";
section.dataset.category = catKey; section.dataset.category = catKey;
section.innerHTML = '<div class="section-header">' + escHtml(label) + '</div><hr class="section-divider" /><div class="tiles-grid" data-cat="' + escHtml(catKey) + '"></div>';
section.innerHTML = ` var grid = section.querySelector(".tiles-grid");
<div class="section-header">${escHtml(label)}</div> for (var k = 0; k < entries.length; k++) {
<hr class="section-divider" /> grid.appendChild(buildTile(entries[k]));
<div class="tiles-grid" data-cat="${escHtml(catKey)}"></div>
`;
const grid = section.querySelector(".tiles-grid");
for (const svc of entries) {
grid.appendChild(buildTile(svc));
} }
$tilesArea.appendChild(section); $tilesArea.appendChild(section);
} }
if ($tilesArea.children.length === 0) { if ($tilesArea.children.length === 0) {
$tilesArea.innerHTML = `<div class="empty-state"><p>No services configured.</p></div>`; $tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
} }
} }
function buildTile(svc) { function buildTile(svc) {
const isSupport = svc.type === "support"; var isSupport = svc.type === "support";
const sc = statusClass(svc.status); var sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled); var st = statusText(svc.status, svc.enabled);
const dis = !svc.enabled; var dis = !svc.enabled;
const hasCreds = svc.has_credentials && svc.enabled; var hasCreds = svc.has_credentials && svc.enabled;
const tile = document.createElement("div"); var tile = document.createElement("div");
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
tile.dataset.unit = svc.unit; tile.dataset.unit = svc.unit;
tile.dataset.tileId = tileId(svc); tile.dataset.tileId = tileId(svc);
if (dis) tile.title = `${svc.name} is not enabled in custom.nix`; if (dis) tile.title = svc.name + " is not enabled in custom.nix";
if (isSupport) { if (isSupport) {
// Support tile — clickable, no info button, no status dot tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">🛟</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="support-status-label">Click to manage</span></div>';
tile.innerHTML = `
<img class="tile-icon"
src="/static/icons/${escHtml(svc.icon)}.svg"
alt="${escHtml(svc.name)}"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<div class="tile-icon-fallback" style="display:none">🛟</div>
<div class="tile-name">${escHtml(svc.name)}</div>
<div class="tile-status">
<span class="support-status-label">Click to manage</span>
</div>
`;
tile.style.cursor = "pointer"; tile.style.cursor = "pointer";
tile.addEventListener("click", () => openSupportModal()); tile.addEventListener("click", function() { openSupportModal(); });
return tile; return tile;
} }
// Normal tile var infoBtn = hasCreds ? '<button class="tile-info-btn" data-unit="' + escHtml(svc.unit) + '" title="Connection info">i</button>' : "";
const infoBtn = hasCreds tile.innerHTML = infoBtn + '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">⚙</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + escHtml(st) + '</span></div>';
? `<button class="tile-info-btn" data-unit="${escHtml(svc.unit)}" title="Connection info">i</button>`
: "";
tile.innerHTML = ` var infoBtnEl = tile.querySelector(".tile-info-btn");
${infoBtn}
<img class="tile-icon"
src="/static/icons/${escHtml(svc.icon)}.svg"
alt="${escHtml(svc.name)}"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<div class="tile-icon-fallback" style="display:none">⚙</div>
<div class="tile-name">${escHtml(svc.name)}</div>
<div class="tile-status">
<span class="status-dot ${sc}"></span>
<span class="status-text">${escHtml(st)}</span>
</div>
`;
const infoBtnEl = tile.querySelector(".tile-info-btn");
if (infoBtnEl) { if (infoBtnEl) {
infoBtnEl.addEventListener("click", (e) => { infoBtnEl.addEventListener("click", function(e) {
e.stopPropagation(); e.stopPropagation();
openCredsModal(svc.unit, svc.name); openCredsModal(svc.unit, svc.name);
}); });
} }
return tile; return tile;
} }
// ── Render: live update (no DOM rebuild) ────────────────────────── // ── Render: live update ───────────────────────────────────────────
function updateTiles(services) { function updateTiles(services) {
_servicesCache = services; _servicesCache = services;
for (var i = 0; i < services.length; i++) {
for (const svc of services) { var svc = services[i];
const id = CSS.escape(tileId(svc)); if (svc.type === "support") continue;
const tile = $tilesArea.querySelector(`.service-tile[data-tile-id="${id}"]`); var id = CSS.escape(tileId(svc));
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
if (!tile) continue; if (!tile) continue;
var sc = statusClass(svc.status);
if (svc.type === "support") continue; // Support tile doesn't have a systemd status var st = statusText(svc.status, svc.enabled);
var dot = tile.querySelector(".status-dot");
const sc = statusClass(svc.status); var text = tile.querySelector(".status-text");
const st = statusText(svc.status, svc.enabled); if (dot) dot.className = "status-dot " + sc;
if (text) text.textContent = st;
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 ─────────────────────────────────────────────── // ── Service polling ───────────────────────────────────────────────
let _firstLoad = true; var _firstLoad = true;
async function refreshServices() { async function refreshServices() {
try { try {
const services = await apiFetch("/api/services"); var services = await apiFetch("/api/services");
if (_firstLoad) { if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
buildTiles(services, _categoryLabels); else { updateTiles(services); }
_firstLoad = false; } catch (err) { console.warn("Failed to fetch services:", err); }
} else {
updateTiles(services);
}
} catch (err) {
console.warn("Failed to fetch services:", err);
}
} }
// ── Network IPs ─────────────────────────────────────────────────── // ── Network IPs ───────────────────────────────────────────────────
async function loadNetwork() { async function loadNetwork() {
try { try {
const data = await apiFetch("/api/network"); var data = await apiFetch("/api/network");
if ($internalIp) $internalIp.textContent = data.internal_ip || "—"; if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
if ($externalIp) $externalIp.textContent = data.external_ip || "—"; if ($externalIp) $externalIp.textContent = data.external_ip || "—";
_cachedExternalIp = data.external_ip || "unavailable"; _cachedExternalIp = data.external_ip || "unavailable";
@@ -287,14 +225,10 @@ async function loadNetwork() {
async function checkUpdates() { async function checkUpdates() {
try { try {
const data = await apiFetch("/api/updates/check"); var data = await apiFetch("/api/updates/check");
const hasUpdates = !!data.available; var hasUpdates = !!data.available;
if ($updateBadge) { if ($updateBadge) $updateBadge.classList.toggle("visible", hasUpdates);
$updateBadge.classList.toggle("visible", hasUpdates); if ($updateBtn) $updateBtn.classList.toggle("has-updates", hasUpdates);
}
if ($updateBtn) {
$updateBtn.classList.toggle("has-updates", hasUpdates);
}
} catch (_) {} } catch (_) {}
} }
@@ -302,72 +236,45 @@ async function checkUpdates() {
async function openCredsModal(unit, name) { async function openCredsModal(unit, name) {
if (!$credsModal) return; if (!$credsModal) return;
if ($credsTitle) $credsTitle.textContent = name + " — Connection Info"; if ($credsTitle) $credsTitle.textContent = name + " — Connection Info";
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>'; if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
$credsModal.classList.add("open"); $credsModal.classList.add("open");
try { try {
const data = await apiFetch(`/api/credentials/${encodeURIComponent(unit)}`); var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
if (!data.credentials || data.credentials.length === 0) { if (!data.credentials || data.credentials.length === 0) {
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>'; $credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
return; return;
} }
var html = "";
let html = ""; for (var i = 0; i < data.credentials.length; i++) {
for (const cred of data.credentials) { var cred = data.credentials[i];
const id = "cred-" + Math.random().toString(36).substring(2, 8); var id = "cred-" + Math.random().toString(36).substring(2, 8);
const displayValue = linkify(cred.value); var displayValue = linkify(cred.value);
var qrBlock = "";
let qrBlock = "";
if (cred.qrcode) { if (cred.qrcode) {
qrBlock = ` qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
<div class="creds-qr-wrap">
<img class="creds-qr-img" src="${cred.qrcode}" alt="QR Code for ${escHtml(cred.label)}">
<div class="creds-qr-hint">Scan with Zeus app on your phone</div>
</div>
`;
} }
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
html += `
<div class="creds-row">
<div class="creds-label">${escHtml(cred.label)}</div>
${qrBlock}
<div class="creds-value-wrap">
<div class="creds-value" id="${id}">${displayValue}</div>
<button class="creds-copy-btn" data-target="${id}">Copy</button>
</div>
</div>
`;
} }
$credsBody.innerHTML = html; $credsBody.innerHTML = html;
$credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
$credsBody.querySelectorAll(".creds-copy-btn").forEach(btn => { btn.addEventListener("click", function() {
btn.addEventListener("click", () => { var target = document.getElementById(btn.dataset.target);
const target = document.getElementById(btn.dataset.target);
if (target) { if (target) {
navigator.clipboard.writeText(target.textContent).then(() => { navigator.clipboard.writeText(target.textContent).then(function() {
btn.textContent = "Copied!"; btn.textContent = "Copied!";
btn.classList.add("copied"); btn.classList.add("copied");
setTimeout(() => { setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500);
btn.textContent = "Copy"; }).catch(function() {});
btn.classList.remove("copied");
}, 1500);
}).catch(() => {});
} }
}); });
}); });
} catch (err) { } catch (err) {
$credsBody.innerHTML = '<p class="creds-empty">Could not load credentials.</p>'; $credsBody.innerHTML = '<p class="creds-empty">Could not load credentials.</p>';
} }
} }
function closeCredsModal() { function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }
if ($credsModal) $credsModal.classList.remove("open");
}
// ── Tech Support modal ──────────────────────────────────────────── // ── Tech Support modal ────────────────────────────────────────────
@@ -375,15 +282,10 @@ async function openSupportModal() {
if (!$supportModal) return; if (!$supportModal) return;
$supportModal.classList.add("open"); $supportModal.classList.add("open");
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>'; $supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
try { try {
const status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
if (status.active) { if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); }
_supportEnabledAt = status.enabled_at; else { renderSupportInactive(); }
renderSupportActive();
} else {
renderSupportInactive();
}
} catch (err) { } catch (err) {
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>'; $supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
} }
@@ -391,117 +293,34 @@ async function openSupportModal() {
function renderSupportInactive() { function renderSupportInactive() {
stopSupportTimer(); stopSupportTimer();
const ip = _cachedExternalIp || "loading…"; var ip = _cachedExternalIp || "loading…";
$supportBody.innerHTML = ` $supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big">🛟</div><h3 class="support-heading">Need help from Sovran Systems?</h3><p class="support-desc">This will temporarily give Sovran Systems secure SSH access to your machine so we can diagnose and fix issues for you.</p><div class="support-info-box"><div class="support-info-row"><span class="support-info-label">Your External IP</span><span class="support-info-value" id="support-ext-ip">' + escHtml(ip) + '</span></div><p class="support-info-hint">Give this IP to your Sovran Systems technician when asked.</p></div><div class="support-steps"><p class="support-steps-title">What happens when you click Enable:</p><ol><li>A Sovran Systems SSH key is added to this machine</li><li>You give us your External IP shown above</li><li>We connect and help you remotely</li><li>When done, you click <strong>End Support Session</strong> to remove the key</li></ol></div><button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button><p class="support-fine-print">You can end the session at any time. The access key will be completely removed.</p></div>';
<div class="support-section">
<div class="support-icon-big">🛟</div>
<h3 class="support-heading">Need help from Sovran Systems?</h3>
<p class="support-desc">
This will temporarily give Sovran Systems secure SSH access to your machine
so we can diagnose and fix issues for you.
</p>
<div class="support-info-box">
<div class="support-info-row">
<span class="support-info-label">Your External IP</span>
<span class="support-info-value" id="support-ext-ip">${escHtml(ip)}</span>
</div>
<p class="support-info-hint">
Give this IP to your Sovran Systems technician when asked.
</p>
</div>
<div class="support-steps">
<p class="support-steps-title">What happens when you click Enable:</p>
<ol>
<li>A Sovran Systems SSH key is added to this machine</li>
<li>You give us your External IP shown above</li>
<li>We connect and help you remotely</li>
<li>When done, you click <strong>End Support Session</strong> to remove the key</li>
</ol>
</div>
<button class="btn support-btn-enable" id="btn-support-enable">
Enable Support Access
</button>
<p class="support-fine-print">
You can end the session at any time. The access key will be completely removed.
</p>
</div>
`;
document.getElementById("btn-support-enable").addEventListener("click", enableSupport); document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
} }
function renderSupportActive() { function renderSupportActive() {
const ip = _cachedExternalIp || "loading…"; var ip = _cachedExternalIp || "loading…";
$supportBody.innerHTML = ` $supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big support-active-icon">🔓</div><h3 class="support-heading support-active-heading">Support Access is Active</h3><p class="support-desc">Sovran Systems can currently connect to your machine via SSH.</p><div class="support-info-box support-active-box"><div class="support-info-row"><span class="support-info-label">Your External IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div><div class="support-info-row"><span class="support-info-label">Session Duration</span><span class="support-info-value" id="support-timer">—</span></div></div><p class="support-active-note">When your support session is complete, click the button below to <strong>immediately remove</strong> the access key.</p><button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button></div>';
<div class="support-section">
<div class="support-icon-big support-active-icon">🔓</div>
<h3 class="support-heading support-active-heading">Support Access is Active</h3>
<p class="support-desc">
Sovran Systems can currently connect to your machine via SSH.
</p>
<div class="support-info-box support-active-box">
<div class="support-info-row">
<span class="support-info-label">Your External IP</span>
<span class="support-info-value">${escHtml(ip)}</span>
</div>
<div class="support-info-row">
<span class="support-info-label">Session Duration</span>
<span class="support-info-value" id="support-timer">—</span>
</div>
</div>
<p class="support-active-note">
When your support session is complete, click the button below to
<strong>immediately remove</strong> the access key.
</p>
<button class="btn support-btn-disable" id="btn-support-disable">
End Support Session
</button>
</div>
`;
document.getElementById("btn-support-disable").addEventListener("click", disableSupport); document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
startSupportTimer(); startSupportTimer();
} }
function renderSupportRemoved(verified) { function renderSupportRemoved(verified) {
stopSupportTimer(); stopSupportTimer();
const icon = verified ? "✅" : "⚠️"; var icon = verified ? "✅" : "⚠️";
const msg = 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.";
? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." var vclass = verified ? "verified-gone" : "verify-warning";
: "The key removal was requested but could not be fully verified. Please reboot your machine to be sure."; var vlabel = verified ? "✓ Removed — No access" : "⚠ Verify by rebooting";
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big">' + icon + '</div><h3 class="support-heading">Support Session Ended</h3><p class="support-desc">' + escHtml(msg) + '</p><div class="support-verify-box"><span class="support-verify-label">SSH Key Status:</span><span class="support-verify-value ' + vclass + '">' + vlabel + '</span></div><button class="btn support-btn-done" id="btn-support-done">Done</button></div>';
$supportBody.innerHTML = `
<div class="support-section">
<div class="support-icon-big">${icon}</div>
<h3 class="support-heading">Support Session Ended</h3>
<p class="support-desc">${escHtml(msg)}</p>
<div class="support-verify-box">
<span class="support-verify-label">SSH Key Status:</span>
<span class="support-verify-value ${verified ? "verified-gone" : "verify-warning"}">
${verified ? "✓ Removed — No access" : "⚠ Verify by rebooting"}
</span>
</div>
<button class="btn support-btn-done" id="btn-support-done">Done</button>
</div>
`;
document.getElementById("btn-support-done").addEventListener("click", closeSupportModal); document.getElementById("btn-support-done").addEventListener("click", closeSupportModal);
} }
async function enableSupport() { async function enableSupport() {
const btn = document.getElementById("btn-support-enable"); var btn = document.getElementById("btn-support-enable");
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
try { try {
await apiFetch("/api/support/enable", { method: "POST" }); await apiFetch("/api/support/enable", { method: "POST" });
const status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
_supportEnabledAt = status.enabled_at; _supportEnabledAt = status.enabled_at;
renderSupportActive(); renderSupportActive();
} catch (err) { } catch (err) {
@@ -511,10 +330,10 @@ async function enableSupport() {
} }
async function disableSupport() { async function disableSupport() {
const btn = document.getElementById("btn-support-disable"); var btn = document.getElementById("btn-support-disable");
if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; } if (btn) { btn.disabled = true; btn.textContent = "Removing key…"; }
try { try {
const result = await apiFetch("/api/support/disable", { method: "POST" }); var result = await apiFetch("/api/support/disable", { method: "POST" });
renderSupportRemoved(result.verified); renderSupportRemoved(result.verified);
} catch (err) { } catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; } if (btn) { btn.disabled = false; btn.textContent = "End Support Session"; }
@@ -529,16 +348,13 @@ function startSupportTimer() {
} }
function stopSupportTimer() { function stopSupportTimer() {
if (_supportTimerInt) { if (_supportTimerInt) { clearInterval(_supportTimerInt); _supportTimerInt = null; }
clearInterval(_supportTimerInt);
_supportTimerInt = null;
}
} }
function updateSupportTimer() { function updateSupportTimer() {
const el = document.getElementById("support-timer"); var el = document.getElementById("support-timer");
if (!el || !_supportEnabledAt) return; if (!el || !_supportEnabledAt) return;
const elapsed = (Date.now() / 1000) - _supportEnabledAt; var elapsed = (Date.now() / 1000) - _supportEnabledAt;
el.textContent = formatDuration(Math.max(0, elapsed)); el.textContent = formatDuration(Math.max(0, elapsed));
} }
@@ -558,10 +374,9 @@ function openUpdateModal() {
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalStatus) $modalStatus.textContent = "Starting update…";
if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($btnReboot) { $btnReboot.style.display = "none"; } if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) { $btnSave.style.display = "none"; } if ($btnSave) $btnSave.style.display = "none";
if ($btnCloseModal) { $btnCloseModal.disabled = true; } if ($btnCloseModal) $btnCloseModal.disabled = true;
$modal.classList.add("open"); $modal.classList.add("open");
startUpdate(); startUpdate();
} }
@@ -575,29 +390,22 @@ function closeUpdateModal() {
function appendLog(text) { function appendLog(text) {
if (!text) return; if (!text) return;
_updateLog += text; _updateLog += text;
if ($modalLog) { if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
$modalLog.textContent += text;
$modalLog.scrollTop = $modalLog.scrollHeight;
}
} }
function startUpdate() { function startUpdate() {
fetch("/api/updates/run", { method: "POST" }) fetch("/api/updates/run", { method: "POST" })
.then(response => { .then(function(response) {
if (!response.ok) { if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
return response.text().then(t => { throw new Error(t); });
}
return response.json(); return response.json();
}) })
.then(data => { .then(function(data) {
if (data.status === "already_running") { if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
appendLog("[Update already in progress, attaching…]\n\n");
}
if ($modalStatus) $modalStatus.textContent = "Updating…"; if ($modalStatus) $modalStatus.textContent = "Updating…";
startUpdatePoll(); startUpdatePoll();
}) })
.catch(err => { .catch(function(err) {
appendLog(`[Error: failed to start update — ${err}]\n`); appendLog("[Error: failed to start update — " + err + "]\n");
onUpdateDone(false); onUpdateDone(false);
}); });
} }
@@ -608,5 +416,104 @@ function startUpdatePoll() {
} }
function stopUpdatePoll() { function stopUpdatePoll() {
if (_updatePollTimer) { if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; }
clearInterval(_ }
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); });
}
// ── 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);
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;
} catch (_) {}
await refreshServices();
loadNetwork();
checkUpdates();
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -756,4 +756,207 @@ button.btn-reboot:hover:not(:disabled) {
width: 200px; width: 200px;
height: 200px; height: 200px;
} }
/* ── Tech Support tile ───────────────────────────────────────────── */
.support-tile {
border-color: var(--accent-color);
border-width: 2px;
border-style: dashed;
}
.support-tile:hover {
border-color: #a8c8ff;
border-style: solid;
}
.support-status-label {
font-size: 0.75rem;
color: var(--accent-color);
font-weight: 600;
}
/* ── Tech Support modal content ──────────────────────────────────── */
.support-section {
text-align: center;
padding: 8px 0;
}
.support-icon-big {
font-size: 3rem;
margin-bottom: 12px;
}
.support-heading {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.support-active-heading {
color: var(--yellow);
}
.support-desc {
font-size: 0.88rem;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 20px;
max-width: 480px;
margin-left: auto;
margin-right: auto;
}
.support-info-box {
background-color: #12121c;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px 20px;
margin: 0 auto 20px;
max-width: 400px;
}
.support-active-box {
border-color: var(--yellow);
}
.support-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
}
.support-info-label {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.support-info-value {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.92rem;
color: var(--accent-color);
font-weight: 600;
}
.support-info-hint {
font-size: 0.78rem;
color: var(--text-dim);
margin-top: 8px;
font-style: italic;
}
.support-steps {
text-align: left;
max-width: 420px;
margin: 0 auto 24px;
}
.support-steps-title {
font-size: 0.82rem;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 8px;
}
.support-steps ol {
padding-left: 20px;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.8;
}
.support-btn-enable {
background-color: var(--green);
color: #fff;
padding: 12px 32px;
font-size: 1rem;
font-weight: 700;
border-radius: 10px;
}
.support-btn-enable:hover:not(:disabled) {
background-color: #27ae6e;
}
.support-btn-disable {
background-color: var(--red);
color: #fff;
padding: 12px 32px;
font-size: 1rem;
font-weight: 700;
border-radius: 10px;
}
.support-btn-disable:hover:not(:disabled) {
background-color: #c41520;
}
.support-active-note {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 20px;
max-width: 420px;
margin-left: auto;
margin-right: auto;
}
.support-fine-print {
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 12px;
font-style: italic;
}
.support-verify-box {
background-color: #12121c;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px 20px;
margin: 20px auto;
max-width: 400px;
display: flex;
justify-content: space-between;
align-items: center;
}
.support-verify-label {
font-size: 0.82rem;
font-weight: 700;
color: var(--text-dim);
}
.support-verify-value {
font-size: 0.88rem;
font-weight: 700;
}
.support-verify-value.verified-gone {
color: var(--green);
}
.support-verify-value.verify-warning {
color: var(--yellow);
}
.support-btn-done {
background-color: var(--border-color);
color: var(--text-primary);
padding: 10px 28px;
font-size: 0.92rem;
font-weight: 600;
border-radius: 10px;
}
.support-btn-done:hover:not(:disabled) {
background-color: #5a5c72;
}
} }

View File

@@ -67,6 +67,19 @@
</div> </div>
</div> </div>
<!-- Tech Support modal -->
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
<div class="creds-dialog">
<div class="creds-header">
<span class="creds-title" id="support-modal-title">Tech Support</span>
<button class="creds-close-btn" id="support-close-btn" title="Close"></button>
</div>
<div class="creds-body" id="support-body">
<p class="creds-loading">Loading…</p>
</div>
</div>
</div>
<!-- Reboot overlay --> <!-- Reboot overlay -->
<div class="reboot-overlay" id="reboot-overlay"> <div class="reboot-overlay" id="reboot-overlay">
<div class="reboot-card"> <div class="reboot-card">