Merge pull request #46 from naturallaw777/copilot/redesign-dashboard-service-tiles

[WIP] Redesign dashboard to simplify service tiles and add detail modal
This commit is contained in:
Sovran_Systems
2026-04-04 10:28:56 -05:00
committed by GitHub
3 changed files with 597 additions and 257 deletions

View File

@@ -256,6 +256,93 @@ ROLE_LABELS = {
"node": "Bitcoin Node", "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 ──────────────────────────────────────────────────── # ── App setup ────────────────────────────────────────────────────
_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) _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") @app.get("/api/network")
async def api_network(): async def api_network():
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

View File

@@ -245,7 +245,6 @@ function buildTile(svc) {
var sc = statusClass(svc.status); var sc = statusClass(svc.status);
var st = statusText(svc.status, svc.enabled); var st = statusText(svc.status, svc.enabled);
var dis = !svc.enabled; var dis = !svc.enabled;
var hasCreds = svc.has_credentials && svc.enabled;
var tile = document.createElement("div"); var tile = document.createElement("div");
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : ""); tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
@@ -260,121 +259,12 @@ function buildTile(svc) {
return tile; return tile;
} }
var infoBtn = hasCreds ? '<button class="tile-info-btn" data-unit="' + escHtml(svc.unit) + '" title="Connection info">i</button>' : ""; 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">' + st + '</span></div>';
// Port requirements badge tile.style.cursor = "pointer";
var ports = svc.port_requirements || []; tile.addEventListener("click", function() {
var portsHtml = ""; openServiceDetailModal(svc.unit, svc.name);
if (ports.length > 0) {
portsHtml = '<div class="tile-ports" title="Click to view required router ports"><span class="tile-ports-icon">🔌</span><span class="tile-ports-label tile-ports-label--loading">Ports: ' + ports.length + ' required</span></div>';
}
// Domain badge — ONLY for services that require a domain
var domainHtml = "";
if (svc.needs_domain) {
domainHtml = '<div class="tile-domain" title="Click to check domain status">'
+ '<span class="tile-domain-icon">🌐</span>'
+ '<span class="tile-domain-label tile-domain-label--checking">' + (svc.domain ? escHtml(svc.domain) : 'Not set') + '</span>'
+ '</div>';
}
tile.innerHTML = infoBtn + '<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">' + st + '</span></div>' + 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";
}
}
}
return tile; return tile;
} }
@@ -435,22 +325,12 @@ async function checkUpdates() {
} catch (_) {} } catch (_) {}
} }
// ── Credentials info modal ──────────────────────────────────────── // ── Service detail modal ──────────────────────────────────────────
async function openCredsModal(unit, name) { function _renderCredsHtml(credentials, unit) {
if (!$credsModal) return;
if ($credsTitle) $credsTitle.textContent = name + " — Connection Info";
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
$credsModal.classList.add("open");
try {
var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
if (!data.credentials || data.credentials.length === 0) {
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
return;
}
var html = ""; var html = "";
for (var i = 0; i < data.credentials.length; i++) { for (var i = 0; i < credentials.length; i++) {
var cred = data.credentials[i]; var cred = credentials[i];
var id = "cred-" + Math.random().toString(36).substring(2, 8); var id = "cred-" + Math.random().toString(36).substring(2, 8);
var displayValue = linkify(cred.value); var displayValue = linkify(cred.value);
var qrBlock = ""; var qrBlock = "";
@@ -459,14 +339,11 @@ async function openCredsModal(unit, name) {
} }
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>'; html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
} }
if (unit === "matrix-synapse.service") { return html;
html += '<hr class="matrix-actions-divider"><div class="matrix-actions-row">' + }
'<button class="matrix-action-btn" id="matrix-add-user-btn"> Add New User</button>' +
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' + function _attachCopyHandlers(container) {
'</div>'; container.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
}
$credsBody.innerHTML = html;
$credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
btn.addEventListener("click", function() { btn.addEventListener("click", function() {
var target = document.getElementById(btn.dataset.target); var target = document.getElementById(btn.dataset.target);
if (!target) return; if (!target) return;
@@ -499,6 +376,199 @@ async function openCredsModal(unit, name) {
} }
}); });
}); });
}
async function openServiceDetailModal(unit, name) {
if (!$credsModal) return;
if ($credsTitle) $credsTitle.textContent = name;
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
$credsModal.classList.add("open");
try {
var data = await apiFetch("/api/service-detail/" + encodeURIComponent(unit));
var html = "";
// Section A: Description
if (data.description) {
html += '<div class="svc-detail-section">' +
'<p class="svc-detail-desc">' + escHtml(data.description) + '</p>' +
'</div>';
}
// Section B: Status
var sc = statusClass(data.status);
var st = statusText(data.status, data.enabled);
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Status</div>' +
'<div class="svc-detail-status">' +
'<span class="status-dot ' + sc + '"></span>' +
'<span>' + escHtml(st) + '</span>' +
'</div>' +
'</div>';
// 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 += '<tr>' +
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(p.description) + '</td>' +
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
'</tr>';
});
var troubleshootHtml = "";
if (anyPortClosed) {
troubleshootHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ Some ports are not open yet. Here\'s how to fix it:</strong>' +
'<ol>' +
'<li>Log into your router\'s admin panel (usually <a href="http://192.168.1.1" target="_blank">http://192.168.1.1</a>)</li>' +
'<li>Find the <strong>Port Forwarding</strong> section</li>' +
'<li>Forward each closed port below to this machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
'<li>Save your router settings</li>' +
'</ol>' +
'<p style="margin-top:10px">💡 Search <em>"how to set up port forwarding on [your router model]"</em> for step-by-step instructions.</p>' +
'</div>';
}
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Port Status</div>' +
'<table class="svc-detail-port-table">' +
'<thead><tr>' +
'<th>Port</th><th>Protocol</th><th>Description</th><th>Status</th>' +
'</tr></thead>' +
'<tbody>' + portTableRows + '</tbody>' +
'</table>' +
troubleshootHtml +
'</div>';
}
// 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 = '<span class="svc-detail-domain-value"><span class="tile-domain-label--ok">✓ ' + escHtml(data.domain) + '</span></span>';
} else if (ds.status === "dns_mismatch") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">⚠ ' + escHtml(data.domain) + ' (IP mismatch)</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.</strong>' +
'<p style="margin-top:8px">This usually means the DNS record needs to be updated:</p>' +
'<ol>' +
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
'<li>Find your domain and check the Dynamic DNS record</li>' +
'<li>Make sure it points to your current external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
'<li>If you set up a DDNS curl command during onboarding, verify it\'s running correctly</li>' +
'</ol>' +
'</div>';
} else if (ds.status === "unresolvable") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--error">✗ ' + escHtml(data.domain) + ' (DNS error)</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ This domain cannot be resolved. DNS is not configured yet.</strong>' +
'<p style="margin-top:8px">Let\'s get it set up:</p>' +
'<ol>' +
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
'<li>Find the domain you purchased for this service</li>' +
'<li>Create a Dynamic DNS record pointing to your external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
'<li>Copy the DDNS curl command from Njal.la\'s dashboard</li>' +
'<li>You can re-enter it in the Feature Manager to update your configuration</li>' +
'</ol>' +
'</div>';
} else {
domainBadge = '<span class="svc-detail-domain-value">' + escHtml(data.domain) + '</span>';
}
} else {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">Not configured</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ No domain has been configured for this service yet.</strong>' +
'<p style="margin-top:8px">To get this service working:</p>' +
'<ol>' +
'<li>Purchase a subdomain at <a href="https://njal.la" target="_blank">njal.la</a> (if you haven\'t already)</li>' +
'<li>Go to the <strong>Feature Manager</strong> in the sidebar</li>' +
'<li>Find this service and configure your domain through the setup wizard</li>' +
'</ol>' +
'</div>';
}
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Domain</div>' +
domainBadge +
domainStatusHtml +
'</div>';
}
// Section E: Credentials & Links
if (data.has_credentials && data.credentials && data.credentials.length > 0) {
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Credentials &amp; Access</div>' +
_renderCredsHtml(data.credentials, unit) +
(unit === "matrix-synapse.service" ?
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
'<button class="matrix-action-btn" id="matrix-add-user-btn"> Add New User</button>' +
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
'</div>' : "") +
'</div>';
} else if (!data.enabled) {
html += '<div class="svc-detail-section">' +
'<p class="creds-empty">This service is not enabled in your configuration. You can enable it from the <strong>Feature Manager</strong> in the sidebar.</p>' +
'</div>';
}
$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 = '<p class="creds-empty">Could not load service details.</p>';
}
}
// ── Credentials info modal ────────────────────────────────────────
async function openCredsModal(unit, name) {
if (!$credsModal) return;
if ($credsTitle) $credsTitle.textContent = name + " — Connection Info";
if ($credsBody) $credsBody.innerHTML = '<p class="creds-loading">Loading…</p>';
$credsModal.classList.add("open");
try {
var data = await apiFetch("/api/credentials/" + encodeURIComponent(unit));
if (!data.credentials || data.credentials.length === 0) {
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
return;
}
var html = _renderCredsHtml(data.credentials, unit);
if (unit === "matrix-synapse.service") {
html += '<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
'<button class="matrix-action-btn" id="matrix-add-user-btn"> Add New User</button>' +
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
'</div>';
}
$credsBody.innerHTML = html;
_attachCopyHandlers($credsBody);
if (unit === "matrix-synapse.service") { if (unit === "matrix-synapse.service") {
var addBtn = document.getElementById("matrix-add-user-btn"); var addBtn = document.getElementById("matrix-add-user-btn");
var changePwBtn = document.getElementById("matrix-change-pw-btn"); var changePwBtn = document.getElementById("matrix-change-pw-btn");
@@ -525,7 +595,7 @@ function openMatrixCreateUserModal(unit, name) {
'<div class="matrix-form-result" id="matrix-create-result"></div>'; '<div class="matrix-form-result" id="matrix-create-result"></div>';
document.getElementById("matrix-create-back-btn").addEventListener("click", function() { 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() { document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() {
@@ -579,7 +649,7 @@ function openMatrixChangePasswordModal(unit, name) {
'<div class="matrix-form-result" id="matrix-chpw-result"></div>'; '<div class="matrix-form-result" id="matrix-chpw-result"></div>';
document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() { 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() { document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() {

View File

@@ -428,7 +428,7 @@ button:disabled {
.service-tile { .service-tile {
width: 160px; width: 160px;
min-height: 150px; min-height: 130px;
background-color: var(--card-color); background-color: var(--card-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-card); border-radius: var(--radius-card);
@@ -441,6 +441,7 @@ button:disabled {
gap: 0; gap: 0;
transition: box-shadow 0.2s, border-color 0.2s; transition: box-shadow 0.2s, border-color 0.2s;
position: relative; position: relative;
cursor: pointer;
} }
.service-tile:hover { .service-tile:hover {
@@ -452,32 +453,6 @@ button:disabled {
opacity: 0.45; 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 { .tile-icon {
width: 48px; width: 48px;
height: 48px; height: 48px;
@@ -1723,29 +1698,6 @@ button.btn-reboot:hover:not(:disabled) {
/* ── Tile: Port Requirements badge ──────────────────────────────── */ /* ── 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 Requirements Modal ────────────────────────────────────── */
.port-req-intro { .port-req-intro {
@@ -1837,52 +1789,7 @@ button.btn-reboot:hover:not(:disabled) {
font-size: 0.95em; font-size: 0.95em;
} }
/* Tile port badge status colours */ /* Domain status colour helpers (used in detail modal) */
.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);
}
.tile-domain-label--ok { .tile-domain-label--ok {
color: #a6e3a1; color: #a6e3a1;
} }
@@ -1895,6 +1802,123 @@ button.btn-reboot:hover:not(:disabled) {
color: #f38ba8; 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: compact feature card overrides ─────────────────────── */
.sidebar .feature-manager-section { .sidebar .feature-manager-section {