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:
@@ -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()
|
||||
|
||||
@@ -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 ? '<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
|
||||
var ports = svc.port_requirements || [];
|
||||
var portsHtml = "";
|
||||
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);
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
openServiceDetailModal(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;
|
||||
}
|
||||
@@ -435,22 +325,12 @@ async function checkUpdates() {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Credentials info modal ────────────────────────────────────────
|
||||
// ── Service detail 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;
|
||||
}
|
||||
function _renderCredsHtml(credentials, unit) {
|
||||
var html = "";
|
||||
for (var i = 0; i < data.credentials.length; i++) {
|
||||
var cred = data.credentials[i];
|
||||
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 = "";
|
||||
@@ -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>';
|
||||
}
|
||||
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;
|
||||
$credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
|
||||
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;
|
||||
@@ -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 & 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") {
|
||||
var addBtn = document.getElementById("matrix-add-user-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>';
|
||||
|
||||
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) {
|
||||
'<div class="matrix-form-result" id="matrix-chpw-result"></div>';
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user