diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py
index 238e5c3..2034a0f 100644
--- a/app/sovran_systemsos_web/server.py
+++ b/app/sovran_systemsos_web/server.py
@@ -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")}
diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js
index 1bd498b..3120df6 100644
--- a/app/sovran_systemsos_web/static/app.js
+++ b/app/sovran_systemsos_web/static/app.js
@@ -20,7 +20,7 @@ const STATUS_LOADING_STATES = new Set([
"reloading", "activating", "deactivating", "maintenance",
]);
-// ── State ──────────────────────────────────────────────���──────────
+// ── 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 = `
-
-
-
- `;
-
- 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 = ``;
- }
-}
-
-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 = `
-
- ⚙
- ${escHtml(svc.name)}
-
-
- ${escHtml(st)}
-
-
-
-
-
-
- `;
-
- 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, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-}
-
-// ── 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);
\ No newline at end of file
+ section.dataset.category
\ No newline at end of file
diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css
index fcc8606..62eeee2 100644
--- a/app/sovran_systemsos_web/static/style.css
+++ b/app/sovran_systemsos_web/static/style.css
@@ -1,6 +1,6 @@
/* Sovran_SystemsOS Hub — Web UI Stylesheet
Dark theme matching the Adwaita dark aesthetic
- v3 — reboot overlay */
+ v4 — credentials info modal */
*, *::before, *::after {
box-sizing: border-box;
@@ -70,7 +70,7 @@ body {
letter-spacing: 0.03em;
}
-/* ── Buttons ────────────────────────────────────────────────────��─ */
+/* ── 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;
+ }
}
\ No newline at end of file
diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html
index e9144cd..e7f0f7e 100644
--- a/app/sovran_systemsos_web/templates/index.html
+++ b/app/sovran_systemsos_web/templates/index.html
@@ -4,7 +4,7 @@
Sovran_SystemsOS Hub
-
+
@@ -54,6 +54,19 @@
+
+
+
-
+