From 13af3fb071755f26a146dd786c72aee3e92cd50c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 4 Apr 2026 15:20:54 +0000
Subject: [PATCH 1/2] Initial plan
From 03dd3eefb5706e4c9fa353cb02ae0506844e9a22 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 4 Apr 2026 15:28:07 +0000
Subject: [PATCH 2/2] Redesign dashboard: simplify tiles to icon/name/status,
add service detail modal, new /api/service-detail endpoint,
SERVICE_DESCRIPTIONS dict, and updated CSS styles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4f00183a-525f-4c71-91f8-c96c95ca1025
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
---
app/sovran_systemsos_web/server.py | 246 ++++++++++++++
app/sovran_systemsos_web/static/app.js | 392 +++++++++++++---------
app/sovran_systemsos_web/static/style.css | 216 ++++++------
3 files changed, 597 insertions(+), 257 deletions(-)
diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py
index 631e3ef..dec9bf5 100644
--- a/app/sovran_systemsos_web/server.py
+++ b/app/sovran_systemsos_web/server.py
@@ -256,6 +256,93 @@ ROLE_LABELS = {
"node": "Bitcoin Node",
}
+SERVICE_DESCRIPTIONS: dict[str, str] = {
+ "bitcoind.service": (
+ "The foundation of your financial sovereignty. Your node independently verifies "
+ "every transaction and block — no banks, no intermediaries, no trust required. "
+ "Powered by Sovran_SystemsOS, your node is always on and fully synced."
+ ),
+ "electrs.service": (
+ "Your own Electrum indexing server. Connect any Electrum-compatible wallet "
+ "directly to your node for maximum privacy — your transactions never touch "
+ "a third-party server. Sovran_SystemsOS keeps it running and indexed automatically."
+ ),
+ "lnd.service": (
+ "Your Lightning Network node for instant, low-fee Bitcoin payments. "
+ "LND powers your Zeus wallet, Ride The Lightning dashboard, and BTCPayServer's "
+ "Lightning capabilities. With Sovran_SystemsOS, it's always connected and ready."
+ ),
+ "rtl.service": (
+ "Your personal Lightning Network command center. Open channels, manage liquidity, "
+ "send payments, and monitor your node — all from a clean browser interface. "
+ "Sovran_SystemsOS gives you full visibility into your Lightning operations."
+ ),
+ "btcpayserver.service": (
+ "Your own payment processor — accept Bitcoin and Lightning payments directly, "
+ "with zero fees to any third party. No Stripe, no Square, no middleman. "
+ "Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
+ ),
+ "zeus-connect-setup.service": (
+ "Connect the Zeus mobile wallet to your Lightning node. Send and receive "
+ "Lightning payments from your phone, backed by your own infrastructure. "
+ "Scan the QR code and your phone becomes a sovereign wallet."
+ ),
+ "mempool.service": (
+ "Your own blockchain explorer and mempool visualizer. Monitor transactions, "
+ "fee estimates, and blocks in real time — verified by your node, not someone else's. "
+ "Sovran_SystemsOS runs it locally so your queries stay private."
+ ),
+ "matrix-synapse.service": (
+ "Your own encrypted messaging server. Chat, call, and collaborate using Element "
+ "or any Matrix client — every message is end-to-end encrypted and stored on hardware you control. "
+ "No corporate surveillance, no data harvesting. Sovran_SystemsOS makes private communication effortless."
+ ),
+ "livekit.service": (
+ "Encrypted voice and video calling, integrated directly with your Matrix server. "
+ "Private video conferences without Zoom, Google Meet, or any third-party cloud. "
+ "Sovran_SystemsOS handles the infrastructure — you just make the call."
+ ),
+ "vaultwarden.service": (
+ "Your own password manager, compatible with all Bitwarden apps. Store passwords, "
+ "credit cards, and secure notes across every device — synced through your server, "
+ "never a third-party cloud. Sovran_SystemsOS keeps your vault always accessible and always private."
+ ),
+ "phpfpm-nextcloud.service": (
+ "Your private cloud — file storage, calendar, contacts, and collaboration tools "
+ "all running on your own hardware. Think Google Drive and Google Docs, but without Google. "
+ "Sovran_SystemsOS delivers a full productivity suite that you actually own."
+ ),
+ "phpfpm-wordpress.service": (
+ "Your own publishing platform, powered by the world's most popular CMS. "
+ "Build websites, blogs, or online stores with full creative control and zero monthly hosting fees. "
+ "Sovran_SystemsOS hosts it on your infrastructure — your content, your rules."
+ ),
+ "haven-relay.service": (
+ "Your own Nostr relay for censorship-resistant social networking. Publish and receive notes "
+ "on the Nostr protocol from infrastructure you control — no platform can silence you. "
+ "Sovran_SystemsOS keeps your relay online and connected to the network."
+ ),
+ "caddy.service": (
+ "The automatic HTTPS web server and reverse proxy powering all your services. "
+ "Caddy handles SSL certificates, domain routing, and secure connections behind the scenes. "
+ "Sovran_SystemsOS configures it automatically — you never have to touch a config file."
+ ),
+ "tor.service": (
+ "The onion router, providing .onion addresses for your services. Access your node, "
+ "wallet, and apps from anywhere in the world — privately and without port forwarding. "
+ "Sovran_SystemsOS integrates Tor natively across your entire stack."
+ ),
+ "gnome-remote-desktop.service": (
+ "Access your server's full desktop environment from anywhere using any RDP client. "
+ "Manage your system visually without being physically present. "
+ "Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go."
+ ),
+ "root-password-setup.service": (
+ "Your system account credentials. These are the keys to your Sovran_SystemsOS machine — "
+ "root access, user accounts, and SSH passphrases. Keep them safe."
+ ),
+}
+
# ── App setup ────────────────────────────────────────────────────
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -1197,6 +1284,165 @@ async def api_credentials(unit: str):
}
+@app.get("/api/service-detail/{unit}")
+async def api_service_detail(unit: str):
+ """Return comprehensive details for a single service — status, credentials,
+ port health, domain health, description, and IPs — in one API call."""
+ cfg = load_config()
+ services = cfg.get("services", [])
+
+ # Build reverse map: unit → feature_id
+ unit_to_feature = {
+ u: feat_id
+ for feat_id, u in FEATURE_SERVICE_MAP.items()
+ if u is not None
+ }
+
+ loop = asyncio.get_event_loop()
+ overrides, _ = await loop.run_in_executor(None, _read_hub_overrides)
+
+ # Find the service config entry
+ entry = next((s for s in services if s.get("unit") == unit), None)
+ if entry is None:
+ raise HTTPException(status_code=404, detail="Service not found")
+
+ icon = entry.get("icon", "")
+ enabled = entry.get("enabled", True)
+
+ feat_id = unit_to_feature.get(unit)
+ if feat_id is None:
+ feat_id = FEATURE_ICON_MAP.get(icon)
+ if feat_id is not None and feat_id in overrides:
+ enabled = overrides[feat_id]
+
+ # Service status
+ if enabled:
+ status = await loop.run_in_executor(
+ None, lambda: sysctl.is_active(unit, entry.get("type", "system"))
+ )
+ else:
+ status = "disabled"
+
+ # Credentials
+ creds_list = entry.get("credentials", [])
+ has_credentials = len(creds_list) > 0
+ resolved_creds: list[dict] = []
+ if has_credentials:
+ for cred in creds_list:
+ result = await loop.run_in_executor(None, _resolve_credential, cred)
+ if result:
+ resolved_creds.append(result)
+
+ # Domain
+ domain_key = SERVICE_DOMAIN_MAP.get(unit)
+ needs_domain = domain_key is not None
+ domain: str | None = None
+ if domain_key:
+ domain_path = os.path.join(DOMAINS_DIR, domain_key)
+ try:
+ with open(domain_path, "r") as f:
+ val = f.read(512).strip()
+ domain = val if val else None
+ except OSError:
+ domain = None
+
+ # IPs
+ internal_ip, external_ip = await asyncio.gather(
+ loop.run_in_executor(None, _get_internal_ip),
+ loop.run_in_executor(None, _get_external_ip),
+ )
+ _save_internal_ip(internal_ip)
+
+ # Domain status check
+ domain_status: dict | None = None
+ if needs_domain:
+ if domain:
+ def _check_one_domain(d: str) -> dict:
+ try:
+ results = socket.getaddrinfo(d, None)
+ if not results:
+ return {
+ "status": "unresolvable",
+ "resolved_ip": None,
+ "expected_ip": external_ip,
+ }
+ resolved_ip = results[0][4][0]
+ if external_ip == "unavailable":
+ return {
+ "status": "error",
+ "resolved_ip": resolved_ip,
+ "expected_ip": external_ip,
+ }
+ if resolved_ip == external_ip:
+ return {
+ "status": "connected",
+ "resolved_ip": resolved_ip,
+ "expected_ip": external_ip,
+ }
+ return {
+ "status": "dns_mismatch",
+ "resolved_ip": resolved_ip,
+ "expected_ip": external_ip,
+ }
+ except socket.gaierror:
+ return {
+ "status": "unresolvable",
+ "resolved_ip": None,
+ "expected_ip": external_ip,
+ }
+ except Exception:
+ return {
+ "status": "error",
+ "resolved_ip": None,
+ "expected_ip": external_ip,
+ }
+
+ domain_status = await loop.run_in_executor(None, _check_one_domain, domain)
+ else:
+ domain_status = {
+ "status": "not_set",
+ "resolved_ip": None,
+ "expected_ip": external_ip,
+ }
+
+ # Port requirements and statuses
+ port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, [])
+ port_statuses: list[dict] = []
+ if port_requirements:
+ listening, allowed = await asyncio.gather(
+ loop.run_in_executor(None, _get_listening_ports),
+ loop.run_in_executor(None, _get_firewall_allowed_ports),
+ )
+ for p in port_requirements:
+ port_str = str(p.get("port", ""))
+ protocol = str(p.get("protocol", "TCP"))
+ ps = _check_port_status(port_str, protocol, listening, allowed)
+ port_statuses.append({
+ "port": port_str,
+ "protocol": protocol,
+ "status": ps,
+ "description": p.get("description", ""),
+ })
+
+ return {
+ "name": entry.get("name", ""),
+ "unit": unit,
+ "icon": icon,
+ "status": status,
+ "enabled": enabled,
+ "description": SERVICE_DESCRIPTIONS.get(unit, ""),
+ "has_credentials": has_credentials and bool(resolved_creds),
+ "credentials": resolved_creds,
+ "needs_domain": needs_domain,
+ "domain": domain,
+ "domain_status": domain_status,
+ "port_requirements": port_requirements,
+ "port_statuses": port_statuses,
+ "external_ip": external_ip,
+ "internal_ip": internal_ip,
+ }
+
+
@app.get("/api/network")
async def api_network():
loop = asyncio.get_event_loop()
diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js
index 1ab67e0..5533b7d 100644
--- a/app/sovran_systemsos_web/static/app.js
+++ b/app/sovran_systemsos_web/static/app.js
@@ -245,7 +245,6 @@ function buildTile(svc) {
var sc = statusClass(svc.status);
var st = statusText(svc.status, svc.enabled);
var dis = !svc.enabled;
- var hasCreds = svc.has_credentials && svc.enabled;
var tile = document.createElement("div");
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
@@ -260,121 +259,12 @@ function buildTile(svc) {
return tile;
}
- var infoBtn = hasCreds ? 'i ' : "";
+ tile.innerHTML = '
?
' + escHtml(svc.name) + '
' + st + '
';
- // Port requirements badge
- var ports = svc.port_requirements || [];
- var portsHtml = "";
- if (ports.length > 0) {
- portsHtml = '🔌 Ports: ' + ports.length + ' required
';
- }
-
- // Domain badge — ONLY for services that require a domain
- var domainHtml = "";
- if (svc.needs_domain) {
- domainHtml = ''
- + '🌐 '
- + '' + (svc.domain ? escHtml(svc.domain) : 'Not set') + ' '
- + '
';
- }
-
- tile.innerHTML = infoBtn + '?
' + escHtml(svc.name) + '
' + st + '
' + portsHtml + domainHtml;
-
- var infoBtnEl = tile.querySelector(".tile-info-btn");
- if (infoBtnEl) {
- infoBtnEl.addEventListener("click", function(e) {
- e.stopPropagation();
- openCredsModal(svc.unit, svc.name);
- });
- }
-
- var portsEl = tile.querySelector(".tile-ports");
- if (portsEl) {
- portsEl.style.cursor = "pointer";
- portsEl.addEventListener("click", function(e) {
- e.stopPropagation();
- openPortRequirementsModal(svc.name, ports, null);
- });
-
- // Async: fetch port status and update badge summary
- if (ports.length > 0) {
- fetch("/api/ports/status", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ports: ports }),
- })
- .then(function(r) { return r.json(); })
- .then(function(data) {
- var listeningCount = 0;
- (data.ports || []).forEach(function(p) {
- if (p.status === "listening") listeningCount++;
- });
- var total = ports.length;
- var labelEl = portsEl.querySelector(".tile-ports-label");
- if (labelEl) {
- labelEl.classList.remove("tile-ports-label--loading");
- if (listeningCount === total) {
- labelEl.className = "tile-ports-label tile-ports-all-ready";
- labelEl.textContent = "Ports: " + total + "/" + total + " ready ✓";
- } else if (listeningCount > 0) {
- labelEl.className = "tile-ports-label tile-ports-partial";
- labelEl.textContent = "Ports: " + listeningCount + "/" + total + " ready";
- } else {
- labelEl.className = "tile-ports-label tile-ports-none-ready";
- labelEl.textContent = "Ports: " + total + " required";
- }
- }
- })
- .catch(function() {
- // Leave badge as-is on error
- });
- }
- }
-
- // Domain badge async check
- var domainEl = tile.querySelector(".tile-domain");
- if (domainEl && svc.needs_domain) {
- domainEl.style.cursor = "pointer";
- domainEl.addEventListener("click", function(e) {
- e.stopPropagation();
- });
-
- if (svc.domain) {
- fetch("/api/domains/check", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ domains: [svc.domain] }),
- })
- .then(function(r) { return r.json(); })
- .then(function(data) {
- var d = (data.domains || [])[0];
- var lbl = domainEl.querySelector(".tile-domain-label");
- if (!lbl || !d) return;
- lbl.classList.remove("tile-domain-label--checking");
- if (d.status === "connected") {
- lbl.className = "tile-domain-label tile-domain-label--ok";
- lbl.textContent = svc.domain + " ✓";
- } else if (d.status === "dns_mismatch") {
- lbl.className = "tile-domain-label tile-domain-label--warn";
- lbl.textContent = svc.domain + " (IP mismatch)";
- } else if (d.status === "unresolvable") {
- lbl.className = "tile-domain-label tile-domain-label--error";
- lbl.textContent = svc.domain + " (DNS error)";
- } else {
- lbl.className = "tile-domain-label tile-domain-label--warn";
- lbl.textContent = svc.domain + " (unknown)";
- }
- })
- .catch(function() {});
- } else {
- var lbl = domainEl.querySelector(".tile-domain-label");
- if (lbl) {
- lbl.classList.remove("tile-domain-label--checking");
- lbl.className = "tile-domain-label tile-domain-label--warn";
- lbl.textContent = "Domain: Not set";
- }
- }
- }
+ tile.style.cursor = "pointer";
+ tile.addEventListener("click", function() {
+ openServiceDetailModal(svc.unit, svc.name);
+ });
return tile;
}
@@ -435,6 +325,228 @@ async function checkUpdates() {
} catch (_) {}
}
+// ── Service detail modal ──────────────────────────────────────────
+
+function _renderCredsHtml(credentials, unit) {
+ var html = "";
+ for (var i = 0; i < credentials.length; i++) {
+ var cred = credentials[i];
+ var id = "cred-" + Math.random().toString(36).substring(2, 8);
+ var displayValue = linkify(cred.value);
+ var qrBlock = "";
+ if (cred.qrcode) {
+ qrBlock = 'Scan with Zeus app on your phone
';
+ }
+ html += '' + escHtml(cred.label) + '
' + qrBlock + '
';
+ }
+ return html;
+}
+
+function _attachCopyHandlers(container) {
+ container.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
+ btn.addEventListener("click", function() {
+ var target = document.getElementById(btn.dataset.target);
+ if (!target) return;
+ var text = target.textContent;
+
+ function onSuccess() {
+ btn.textContent = "Copied!";
+ btn.classList.add("copied");
+ setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500);
+ }
+
+ function fallbackCopy() {
+ var ta = document.createElement("textarea");
+ ta.value = text;
+ ta.style.position = "fixed";
+ ta.style.left = "-9999px";
+ document.body.appendChild(ta);
+ ta.select();
+ try {
+ document.execCommand("copy");
+ onSuccess();
+ } catch (e) {}
+ document.body.removeChild(ta);
+ }
+
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy);
+ } else {
+ fallbackCopy();
+ }
+ });
+ });
+}
+
+async function openServiceDetailModal(unit, name) {
+ if (!$credsModal) return;
+ if ($credsTitle) $credsTitle.textContent = name;
+ if ($credsBody) $credsBody.innerHTML = 'Loading…
';
+ $credsModal.classList.add("open");
+
+ try {
+ var data = await apiFetch("/api/service-detail/" + encodeURIComponent(unit));
+ var html = "";
+
+ // Section A: Description
+ if (data.description) {
+ html += '' +
+ '
' + escHtml(data.description) + '
' +
+ '
';
+ }
+
+ // Section B: Status
+ var sc = statusClass(data.status);
+ var st = statusText(data.status, data.enabled);
+ html += '' +
+ '
Status
' +
+ '
' +
+ ' ' +
+ '' + escHtml(st) + ' ' +
+ '
' +
+ '
';
+
+ // Section C: Ports (only if service has port_requirements)
+ if (data.port_statuses && data.port_statuses.length > 0) {
+ var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
+ var portTableRows = "";
+ data.port_statuses.forEach(function(p) {
+ var statusIcon, statusClass2;
+ if (p.status === "listening") {
+ statusIcon = "✅ Open";
+ statusClass2 = "port-status-listening";
+ } else if (p.status === "firewall_open") {
+ statusIcon = "🟡 Firewall open";
+ statusClass2 = "port-status-open";
+ } else if (p.status === "closed") {
+ statusIcon = "🔴 Closed";
+ statusClass2 = "port-status-closed";
+ } else {
+ statusIcon = "— Unknown";
+ statusClass2 = "port-status-unknown";
+ }
+ portTableRows += '' +
+ '' + escHtml(p.port) + ' ' +
+ '' + escHtml(p.protocol) + ' ' +
+ '' + escHtml(p.description) + ' ' +
+ '' + statusIcon + ' ' +
+ ' ';
+ });
+
+ var troubleshootHtml = "";
+ if (anyPortClosed) {
+ troubleshootHtml = '' +
+ '
⚠️ Some ports are not open yet. Here\'s how to fix it: ' +
+ '
' +
+ 'Log into your router\'s admin panel (usually http://192.168.1.1 ) ' +
+ 'Find the Port Forwarding section ' +
+ 'Forward each closed port below to this machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + ' ' +
+ 'Save your router settings ' +
+ ' ' +
+ '
💡 Search "how to set up port forwarding on [your router model]" for step-by-step instructions.
' +
+ '
';
+ }
+
+ html += '' +
+ '
Port Status
' +
+ '
' +
+ '' +
+ 'Port Protocol Description Status ' +
+ ' ' +
+ '' + portTableRows + ' ' +
+ '
' +
+ troubleshootHtml +
+ '
';
+ }
+
+ // Section D: Domain (only if service needs_domain)
+ if (data.needs_domain) {
+ var domainStatusHtml = "";
+ var ds = data.domain_status || {};
+ var domainBadge = "";
+
+ if (data.domain) {
+ if (ds.status === "connected") {
+ domainBadge = '✓ ' + escHtml(data.domain) + ' ';
+ } else if (ds.status === "dns_mismatch") {
+ domainBadge = '⚠ ' + escHtml(data.domain) + ' (IP mismatch) ';
+ domainStatusHtml = '' +
+ '
⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '. ' +
+ '
This usually means the DNS record needs to be updated:
' +
+ '
' +
+ 'Go to njal.la and log into your account ' +
+ 'Find your domain and check the Dynamic DNS record ' +
+ 'Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + ' ' +
+ 'If you set up a DDNS curl command during onboarding, verify it\'s running correctly ' +
+ ' ' +
+ '
';
+ } else if (ds.status === "unresolvable") {
+ domainBadge = '✗ ' + escHtml(data.domain) + ' (DNS error) ';
+ domainStatusHtml = '' +
+ '
⚠️ This domain cannot be resolved. DNS is not configured yet. ' +
+ '
Let\'s get it set up:
' +
+ '
' +
+ 'Go to njal.la and log into your account ' +
+ 'Find the domain you purchased for this service ' +
+ 'Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + ' ' +
+ 'Copy the DDNS curl command from Njal.la\'s dashboard ' +
+ 'You can re-enter it in the Feature Manager to update your configuration ' +
+ ' ' +
+ '
';
+ } else {
+ domainBadge = '' + escHtml(data.domain) + ' ';
+ }
+ } else {
+ domainBadge = 'Not configured ';
+ domainStatusHtml = '' +
+ '
⚠️ No domain has been configured for this service yet. ' +
+ '
To get this service working:
' +
+ '
' +
+ 'Purchase a subdomain at njal.la (if you haven\'t already) ' +
+ 'Go to the Feature Manager in the sidebar ' +
+ 'Find this service and configure your domain through the setup wizard ' +
+ ' ' +
+ '
';
+ }
+
+ html += '' +
+ '
Domain
' +
+ domainBadge +
+ domainStatusHtml +
+ '
';
+ }
+
+ // Section E: Credentials & Links
+ if (data.has_credentials && data.credentials && data.credentials.length > 0) {
+ html += '' +
+ '
Credentials & Access
' +
+ _renderCredsHtml(data.credentials, unit) +
+ (unit === "matrix-synapse.service" ?
+ '
' +
+ '➕ Add New User ' +
+ '🔑 Change Password ' +
+ '
' : "") +
+ '
';
+ } else if (!data.enabled) {
+ html += '' +
+ '
This service is not enabled in your configuration. You can enable it from the Feature Manager in the sidebar.
' +
+ '
';
+ }
+
+ $credsBody.innerHTML = html;
+ _attachCopyHandlers($credsBody);
+
+ if (unit === "matrix-synapse.service") {
+ var addBtn = document.getElementById("matrix-add-user-btn");
+ var changePwBtn = document.getElementById("matrix-change-pw-btn");
+ if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); });
+ if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); });
+ }
+ } catch (err) {
+ if ($credsBody) $credsBody.innerHTML = 'Could not load service details.
';
+ }
+}
+
// ── Credentials info modal ────────────────────────────────────────
async function openCredsModal(unit, name) {
@@ -448,17 +560,7 @@ async function openCredsModal(unit, name) {
$credsBody.innerHTML = 'No connection info available yet.
';
return;
}
- var html = "";
- for (var i = 0; i < data.credentials.length; i++) {
- var cred = data.credentials[i];
- var id = "cred-" + Math.random().toString(36).substring(2, 8);
- var displayValue = linkify(cred.value);
- var qrBlock = "";
- if (cred.qrcode) {
- qrBlock = 'Scan with Zeus app on your phone
';
- }
- html += '' + escHtml(cred.label) + '
' + qrBlock + '
';
- }
+ var html = _renderCredsHtml(data.credentials, unit);
if (unit === "matrix-synapse.service") {
html += '' +
'➕ Add New User ' +
@@ -466,39 +568,7 @@ async function openCredsModal(unit, name) {
'
';
}
$credsBody.innerHTML = html;
- $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
- btn.addEventListener("click", function() {
- var target = document.getElementById(btn.dataset.target);
- if (!target) return;
- var text = target.textContent;
-
- function onSuccess() {
- btn.textContent = "Copied!";
- btn.classList.add("copied");
- setTimeout(function() { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1500);
- }
-
- function fallbackCopy() {
- var ta = document.createElement("textarea");
- ta.value = text;
- ta.style.position = "fixed";
- ta.style.left = "-9999px";
- document.body.appendChild(ta);
- ta.select();
- try {
- document.execCommand("copy");
- onSuccess();
- } catch (e) {}
- document.body.removeChild(ta);
- }
-
- if (navigator.clipboard && window.isSecureContext) {
- navigator.clipboard.writeText(text).then(onSuccess).catch(fallbackCopy);
- } else {
- fallbackCopy();
- }
- });
- });
+ _attachCopyHandlers($credsBody);
if (unit === "matrix-synapse.service") {
var addBtn = document.getElementById("matrix-add-user-btn");
var changePwBtn = document.getElementById("matrix-change-pw-btn");
@@ -525,7 +595,7 @@ function openMatrixCreateUserModal(unit, name) {
'
';
document.getElementById("matrix-create-back-btn").addEventListener("click", function() {
- openCredsModal(unit, name);
+ openServiceDetailModal(unit, name);
});
document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() {
@@ -579,7 +649,7 @@ function openMatrixChangePasswordModal(unit, name) {
'
';
document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() {
- openCredsModal(unit, name);
+ openServiceDetailModal(unit, name);
});
document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() {
diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css
index e7dee5a..f795d0e 100644
--- a/app/sovran_systemsos_web/static/style.css
+++ b/app/sovran_systemsos_web/static/style.css
@@ -428,7 +428,7 @@ button:disabled {
.service-tile {
width: 160px;
- min-height: 150px;
+ min-height: 130px;
background-color: var(--card-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
@@ -441,6 +441,7 @@ button:disabled {
gap: 0;
transition: box-shadow 0.2s, border-color 0.2s;
position: relative;
+ cursor: pointer;
}
.service-tile:hover {
@@ -452,32 +453,6 @@ 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;
@@ -1723,29 +1698,6 @@ button.btn-reboot:hover:not(:disabled) {
/* ── Tile: Port Requirements badge ──────────────────────────────── */
-.tile-ports {
- margin-top: 6px;
- font-size: 0.7rem;
- color: var(--text-secondary);
- display: flex;
- align-items: flex-start;
- gap: 4px;
- line-height: 1.4;
- flex-wrap: wrap;
-}
-
-.tile-ports:hover {
- color: var(--accent-color);
-}
-
-.tile-ports-icon {
- flex-shrink: 0;
-}
-
-.tile-ports-label {
- word-break: break-word;
-}
-
/* ── Port Requirements Modal ────────────────────────────────────── */
.port-req-intro {
@@ -1837,52 +1789,7 @@ button.btn-reboot:hover:not(:disabled) {
font-size: 0.95em;
}
-/* Tile port badge status colours */
-.tile-ports-all-ready {
- color: #a6e3a1;
-}
-
-.tile-ports-partial {
- color: #f9e2af;
-}
-
-.tile-ports-none-ready {
- color: var(--text-secondary);
-}
-
-.tile-ports-label--loading {
- color: var(--text-dim);
-}
-
-/* ── Tile: Domain Status badge ──────────────────────────────────── */
-
-.tile-domain {
- margin-top: 6px;
- font-size: 0.7rem;
- color: var(--text-secondary);
- display: flex;
- align-items: flex-start;
- gap: 4px;
- line-height: 1.4;
- flex-wrap: wrap;
-}
-
-.tile-domain:hover {
- color: var(--accent-color);
-}
-
-.tile-domain-icon {
- flex-shrink: 0;
-}
-
-.tile-domain-label {
- word-break: break-word;
-}
-
-.tile-domain-label--checking {
- color: var(--text-dim);
-}
-
+/* Domain status colour helpers (used in detail modal) */
.tile-domain-label--ok {
color: #a6e3a1;
}
@@ -1895,6 +1802,123 @@ button.btn-reboot:hover:not(:disabled) {
color: #f38ba8;
}
+/* ── Service detail modal sections ──────────────────────────────── */
+
+.svc-detail-section {
+ margin-bottom: 24px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.svc-detail-section:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+ padding-bottom: 0;
+}
+
+.svc-detail-desc {
+ font-size: 0.92rem;
+ line-height: 1.7;
+ color: var(--text-secondary);
+}
+
+.svc-detail-section-title {
+ font-size: 0.78rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-dim);
+ margin-bottom: 12px;
+}
+
+.svc-detail-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.92rem;
+ font-weight: 600;
+}
+
+.svc-detail-troubleshoot {
+ background-color: rgba(229, 165, 10, 0.08);
+ border: 1px solid rgba(229, 165, 10, 0.25);
+ border-radius: 10px;
+ padding: 16px 20px;
+ margin-top: 14px;
+ font-size: 0.85rem;
+ line-height: 1.7;
+ color: var(--text-secondary);
+}
+
+.svc-detail-troubleshoot ol {
+ margin: 10px 0 0 20px;
+ padding: 0;
+}
+
+.svc-detail-troubleshoot li {
+ margin-bottom: 4px;
+}
+
+.svc-detail-troubleshoot code {
+ background-color: #12121c;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.85rem;
+}
+
+/* Port status table inside detail modal */
+.svc-detail-port-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+}
+
+.svc-detail-port-table th {
+ text-align: left;
+ padding: 6px 10px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.svc-detail-port-table td {
+ padding: 8px 10px;
+ border-bottom: 1px solid rgba(69, 71, 90, 0.3);
+}
+
+.svc-detail-port-table-port {
+ font-family: 'JetBrains Mono', monospace;
+ color: var(--accent-color);
+ white-space: nowrap;
+}
+
+.svc-detail-port-table-proto {
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.svc-detail-port-table-desc {
+ color: var(--text-primary);
+}
+
+.svc-detail-port-table-status {
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+/* Domain status badge in detail modal */
+.svc-detail-domain-value {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.9rem;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
/* ── Sidebar: compact feature card overrides ─────────────────────── */
.sidebar .feature-manager-section {