added info dialog for each tile

This commit is contained in:
2026-04-02 14:20:06 -05:00
parent d9a5416012
commit 64c32a7f53
5 changed files with 296 additions and 385 deletions

View File

@@ -20,7 +20,7 @@ const STATUS_LOADING_STATES = new Set([
"reloading", "activating", "deactivating", "maintenance",
]);
// ── State ──────────────────────────────────────────────<EFBFBD><EFBFBD><EFBFBD>──────────
// ── State ────────────────────────────────────────────────────────
let _servicesCache = [];
let _categoryLabels = {};
@@ -49,6 +49,11 @@ const $btnCloseModal = document.getElementById("btn-close-modal");
const $rebootOverlay = document.getElementById("reboot-overlay");
const $credsModal = document.getElementById("creds-modal");
const $credsTitle = document.getElementById("creds-modal-title");
const $credsBody = document.getElementById("creds-body");
const $credsCloseBtn = document.getElementById("creds-close-btn");
// ── Helpers ───────────────────────────────────────────────────────
function statusClass(status) {
@@ -102,366 +107,4 @@ function buildTiles(services, categoryLabels) {
const section = document.createElement("div");
section.className = "category-section";
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>
`;
const grid = section.querySelector(".tiles-grid");
for (const svc of entries) {
grid.appendChild(buildTile(svc));
}
$tilesArea.appendChild(section);
}
if ($tilesArea.children.length === 0) {
$tilesArea.innerHTML = `<div class="empty-state"><p>No services configured.</p></div>`;
}
}
function buildTile(svc) {
const sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled);
const dis = !svc.enabled;
const isOn = svc.status === "active";
const tile = document.createElement("div");
tile.className = "service-tile" + (dis ? " disabled" : "");
tile.dataset.unit = svc.unit;
if (dis) tile.title = `${svc.name} is not enabled in custom.nix`;
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="status-dot ${sc}"></span>
<span class="status-text">${escHtml(st)}</span>
</div>
<div class="tile-spacer"></div>
<div class="tile-controls">
<label class="toggle-label${dis ? " disabled-toggle" : ""}" title="${dis ? "Not enabled in custom.nix" : (isOn ? "Stop" : "Start")}">
<input type="checkbox" class="tile-toggle" data-unit="${escHtml(svc.unit)}"
${isOn ? "checked" : ""} ${dis ? "disabled" : ""}>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<button class="tile-restart-btn" data-unit="${escHtml(svc.unit)}"
title="Restart" ${dis ? "disabled" : ""}>↺</button>
</div>
`;
const chk = tile.querySelector(".tile-toggle");
if (!dis) {
chk.addEventListener("change", async (e) => {
const action = e.target.checked ? "start" : "stop";
chk.disabled = true;
try {
await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/${action}`, { method: "POST" });
} catch (_) {}
setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY);
});
}
const restartBtn = tile.querySelector(".tile-restart-btn");
if (!dis) {
restartBtn.addEventListener("click", async () => {
restartBtn.disabled = true;
try {
await apiFetch(`/api/services/${encodeURIComponent(svc.unit)}/restart`, { method: "POST" });
} catch (_) {}
setTimeout(() => refreshServices(), ACTION_REFRESH_DELAY);
});
}
return tile;
}
// ── Render: live update (no DOM rebuild) ──────────────────────────
function updateTiles(services) {
_servicesCache = services;
for (const svc of services) {
const tile = $tilesArea.querySelector(`.service-tile[data-unit="${CSS.escape(svc.unit)}"]`);
if (!tile) continue;
const sc = statusClass(svc.status);
const st = statusText(svc.status, svc.enabled);
const dot = tile.querySelector(".status-dot");
const text = tile.querySelector(".status-text");
const chk = tile.querySelector(".tile-toggle");
if (dot) { dot.className = `status-dot ${sc}`; }
if (text) { text.textContent = st; }
if (chk && !chk.disabled) {
chk.checked = svc.status === "active";
}
}
}
// ── HTML escape ───────────────────────────────────────────────────
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ── 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 || "—";
} 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 (_) {}
}
// ── 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(_updatePollTimer);
_updatePollTimer = null;
}
}
async function pollUpdateStatus() {
if (_updateFinished) return;
try {
const 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() {
const blob = new Blob([_updateLog], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const 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 with confirmation overlay ──────────────────────────────
function doReboot() {
// Close the update modal
if ($modal) $modal.classList.remove("open");
stopUpdatePoll();
// Show the reboot overlay
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
// Send the reboot command
fetch("/api/reboot", { method: "POST" }).catch(() => {});
// Start polling to detect when the server comes back
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
}
function waitForServerReboot() {
fetch("/api/config", { cache: "no-store" })
.then(res => {
if (res.ok) {
// Server is back — reload the page to get the fresh state
window.location.reload();
} else {
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
}
})
.catch(() => {
// Still down — keep trying
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
});
}
// ── Event listeners ───────────────────────────────────────────────
if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal);
if ($refreshBtn) $refreshBtn.addEventListener("click", () => refreshServices());
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
if ($modal) {
$modal.addEventListener("click", (e) => {
if (e.target === $modal) closeUpdateModal();
});
}
// ── Init ──────────────────────────────────────────────────────────
async function init() {
try {
const cfg = await apiFetch("/api/config");
if (cfg.category_order) {
for (const [key, label] of cfg.category_order) {
_categoryLabels[key] = label;
}
}
const 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);
section.dataset.category

View File

@@ -1,6 +1,6 @@
/* Sovran_SystemsOS Hub — Web UI Stylesheet
Dark theme matching the Adwaita dark aesthetic
v3 — reboot overlay */
v4credentials info modal */
*, *::before, *::after {
box-sizing: border-box;
@@ -70,7 +70,7 @@ body {
letter-spacing: 0.03em;
}
/* ── Buttons ────────────────────────────────────────────────────<EFBFBD><EFBFBD>─ */
/* ── Buttons ───────────────────────────────────────────────────── */
button {
font-family: inherit;
@@ -249,6 +249,32 @@ button:disabled {
opacity: 0.45;
}
/* Info badge on tiles with credentials */
.tile-info-btn {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: var(--accent-color);
color: #1e1e2e;
font-size: 0.75rem;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: none;
transition: transform 0.15s, background-color 0.15s;
line-height: 1;
}
.tile-info-btn:hover {
transform: scale(1.15);
background-color: #a8c8ff;
}
.tile-icon {
width: 48px;
height: 48px;
@@ -504,6 +530,133 @@ button.btn-reboot:hover:not(:disabled) {
background-color: #5a5c72;
}
/* ── Credentials info modal ──────────────────────────────────────── */
.creds-dialog {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
border-radius: 16px;
width: 90vw;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
animation: creds-fade-in 0.2s ease-out;
}
@keyframes creds-fade-in {
from { opacity: 0; transform: scale(0.95) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.creds-header {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.creds-title {
font-size: 1rem;
font-weight: 700;
flex: 1;
}
.creds-close-btn {
background: none;
color: var(--text-secondary);
font-size: 1.1rem;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
border: none;
}
.creds-close-btn:hover {
background-color: var(--border-color);
color: var(--text-primary);
}
.creds-body {
padding: 16px 20px;
overflow-y: auto;
}
.creds-loading {
color: var(--text-dim);
text-align: center;
padding: 24px 0;
}
.creds-row {
margin-bottom: 14px;
}
.creds-row:last-child {
margin-bottom: 0;
}
.creds-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 4px;
}
.creds-value-wrap {
display: flex;
align-items: flex-start;
gap: 8px;
}
.creds-value {
flex: 1;
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.82rem;
color: var(--accent-color);
background-color: #12121c;
padding: 8px 12px;
border-radius: 8px;
word-break: break-all;
white-space: pre-wrap;
line-height: 1.5;
border: 1px solid var(--border-color);
}
.creds-copy-btn {
background-color: var(--border-color);
color: var(--text-primary);
font-size: 0.72rem;
font-weight: 600;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
border: none;
white-space: nowrap;
flex-shrink: 0;
align-self: flex-start;
margin-top: 6px;
}
.creds-copy-btn:hover {
background-color: #5a5c72;
}
.creds-copy-btn.copied {
background-color: var(--green);
color: #fff;
}
.creds-empty {
color: var(--text-dim);
text-align: center;
padding: 24px 0;
font-size: 0.88rem;
}
/* ── Reboot overlay ─────────────────────────────────────────────── */
.reboot-overlay {
@@ -634,4 +787,7 @@ button.btn-reboot:hover:not(:disabled) {
padding: 36px 28px;
margin: 0 16px;
}
.creds-dialog {
margin: 0 12px;
}
}