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>
This commit is contained in:
committed by
GitHub
parent
13af3fb071
commit
03dd3eefb5
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
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 = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</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>';
|
||||
}
|
||||
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 = '<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) {
|
||||
@@ -448,17 +560,7 @@ async function openCredsModal(unit, name) {
|
||||
$credsBody.innerHTML = '<p class="creds-empty">No connection info available yet.</p>';
|
||||
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 = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</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>';
|
||||
}
|
||||
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>' +
|
||||
@@ -466,39 +568,7 @@ async function openCredsModal(unit, name) {
|
||||
'</div>';
|
||||
}
|
||||
$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) {
|
||||
'<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