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

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import json
import os
import re
import socket
import subprocess
import urllib.request
@@ -192,6 +193,43 @@ def _read_log(offset: int = 0) -> tuple[str, int]:
return "", 0
# ── Credentials helpers ──────────────────────────────────────────
def _resolve_credential(cred: dict) -> dict | None:
"""Resolve a single credential entry to {label, value}."""
label = cred.get("label", "")
prefix = cred.get("prefix", "")
suffix = cred.get("suffix", "")
extract = cred.get("extract", "")
multiline = cred.get("multiline", False)
# Static value
if "value" in cred:
return {"label": label, "value": prefix + cred["value"] + suffix, "multiline": multiline}
# File-based value
filepath = cred.get("file", "")
if not filepath:
return None
try:
with open(filepath, "r") as f:
raw = f.read().strip()
except (FileNotFoundError, PermissionError):
return None
if extract:
# Extract a key=value from an env file (e.g., ADMIN_TOKEN=...)
match = re.search(rf'{re.escape(extract)}=(.*)', raw)
if match:
raw = match.group(1).strip()
else:
return None
value = prefix + raw + suffix
return {"label": label, "value": value, "multiline": multiline}
# ── Routes ───────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
@@ -228,6 +266,10 @@ async def api_services():
)
else:
status = "disabled"
creds = entry.get("credentials", [])
has_credentials = len(creds) > 0
return {
"name": entry.get("name", ""),
"unit": unit,
@@ -236,12 +278,44 @@ async def api_services():
"enabled": enabled,
"category": entry.get("category", "other"),
"status": status,
"has_credentials": has_credentials,
}
results = await asyncio.gather(*[get_status(s) for s in services])
return list(results)
@app.get("/api/credentials/{unit}")
async def api_credentials(unit: str):
"""Return resolved credentials for a given service unit."""
cfg = load_config()
services = cfg.get("services", [])
# Find the service entry matching this unit
entry = None
for s in services:
if s.get("unit") == unit:
creds = s.get("credentials", [])
if creds:
entry = s
break
if not entry:
raise HTTPException(status_code=404, detail="No credentials for this service")
loop = asyncio.get_event_loop()
resolved = []
for cred in entry.get("credentials", []):
result = await loop.run_in_executor(None, _resolve_credential, cred)
if result:
resolved.append(result)
return {
"name": entry.get("name", ""),
"credentials": resolved,
}
def _get_allowed_units() -> set[str]:
cfg = load_config()
return {s.get("unit", "") for s in cfg.get("services", []) if s.get("unit")}

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;
}
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS Hub</title>
<link rel="stylesheet" href="/static/style.css?v=3" />
<link rel="stylesheet" href="/static/style.css?v=4" />
</head>
<body>
@@ -54,6 +54,19 @@
</div>
</div>
<!-- Credentials info modal -->
<div class="modal-overlay" id="creds-modal" role="dialog" aria-modal="true" aria-labelledby="creds-modal-title">
<div class="creds-dialog">
<div class="creds-header">
<span class="creds-title" id="creds-modal-title">Service Info</span>
<button class="creds-close-btn" id="creds-close-btn" title="Close"></button>
</div>
<div class="creds-body" id="creds-body">
<p class="creds-loading">Loading…</p>
</div>
</div>
</div>
<!-- Reboot overlay -->
<div class="reboot-overlay" id="reboot-overlay">
<div class="reboot-card">
@@ -72,6 +85,6 @@
</div>
</div>
<script src="/static/app.js?v=3"></script>
<script src="/static/app.js?v=4"></script>
</body>
</html>