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] 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) + '
' + 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 = '
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:
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 + '
' + displayValue + '
';
- }
+ var html = _renderCredsHtml(data.credentials, unit);
if (unit === "matrix-synapse.service") {
html += '