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 ? '' : ""; + tile.innerHTML = '' + escHtml(svc.name) + '
' + 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) + '
' + 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 = '
QR Code for ' + escHtml(cred.label) + '
Scan with Zeus app on your phone
'; + } + html += '
' + escHtml(cred.label) + '
' + qrBlock + '
' + displayValue + '
'; + } + 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:' + + '
    ' + + '
  1. Log into your router\'s admin panel (usually http://192.168.1.1)
  2. ' + + '
  3. Find the Port Forwarding section
  4. ' + + '
  5. Forward each closed port below to this machine\'s internal IP: ' + escHtml(data.internal_ip || "—") + '
  6. ' + + '
  7. Save your router settings
  8. ' + + '
' + + '

💡 Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

' + + '
'; + } + + html += '
' + + '
Port Status
' + + '' + + '' + + '' + + '' + + '' + portTableRows + '' + + '
PortProtocolDescriptionStatus
' + + 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:

' + + '
    ' + + '
  1. Go to njal.la and log into your account
  2. ' + + '
  3. Find your domain and check the Dynamic DNS record
  4. ' + + '
  5. Make sure it points to your current external IP: ' + escHtml(ds.expected_ip || "—") + '
  6. ' + + '
  7. If you set up a DDNS curl command during onboarding, verify it\'s running correctly
  8. ' + + '
' + + '
'; + } 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:

' + + '
    ' + + '
  1. Go to njal.la and log into your account
  2. ' + + '
  3. Find the domain you purchased for this service
  4. ' + + '
  5. Create a Dynamic DNS record pointing to your external IP: ' + escHtml(ds.expected_ip || "—") + '
  6. ' + + '
  7. Copy the DDNS curl command from Njal.la\'s dashboard
  8. ' + + '
  9. You can re-enter it in the Feature Manager to update your configuration
  10. ' + + '
' + + '
'; + } else { + domainBadge = '' + escHtml(data.domain) + ''; + } + } else { + domainBadge = 'Not configured'; + domainStatusHtml = '
' + + '⚠️ No domain has been configured for this service yet.' + + '

To get this service working:

' + + '
    ' + + '
  1. Purchase a subdomain at njal.la (if you haven\'t already)
  2. ' + + '
  3. Go to the Feature Manager in the sidebar
  4. ' + + '
  5. Find this service and configure your domain through the setup wizard
  6. ' + + '
' + + '
'; + } + + 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" ? + '
' + + '' + + '' + + '
' : "") + + '
'; + } 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 = '
QR Code for ' + escHtml(cred.label) + '
Scan with Zeus app on your phone
'; - } - html += '
' + escHtml(cred.label) + '
' + qrBlock + '
' + displayValue + '
'; - } + var html = _renderCredsHtml(data.credentials, unit); if (unit === "matrix-synapse.service") { html += '
' + '' + @@ -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 {