Add System tab; fix settings-revert bug, encryption-status messaging, JS caching, native dropdown theming, and backup panel presentation (v0.16.0)
This commit is contained in:
+77
-4
@@ -79,7 +79,7 @@ function renderBackups() {
|
||||
const completeBackups = state.backups.filter(item => item.type === "complete"), configBackups = state.backups.filter(item => item.type === "configuration");
|
||||
const summaryEl = document.querySelector("#backup-summary");
|
||||
if (summaryEl) summaryEl.textContent = state.backups.length ? `${completeBackups.length} Complete (${formatBytes(completeBackups.reduce((sum, item) => sum + item.size, 0))}), ${configBackups.length} Configuration only (${formatBytes(configBackups.reduce((sum, item) => sum + item.size, 0))}).` : "";
|
||||
document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `<article class="data-row backup-row" data-backup="${extendedEscape(item.filename)}"><span class="status-dot ${item.valid ? "running" : "error"}"></span><div><strong>${extendedEscape(item.filename)}</strong><small>${formatTime(item.createdAt)}</small></div><div><span class="chip type-${extendedEscape(item.type)}">${backupTypeLabel(item.type)}</span><small>Site Gateway ${extendedEscape(item.appVersion)}</small></div><div><strong>${formatBytes(item.size)}</strong><small>${item.valid ? "Verified manifest" : "Unreadable manifest"}</small></div><div class="row-actions"><a class="button secondary" href="/api/backups/${encodeURIComponent(item.filename)}/download">Download</a><button class="button secondary" data-backup-action="restore">Restore</button><button class="button secondary danger-text" data-backup-action="delete">Delete</button></div></article>`).join("") : '<p class="quiet-state padded">No stored backups yet.</p>';
|
||||
document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `<article class="data-row backup-row" data-backup="${extendedEscape(item.filename)}"><span class="status-dot ${item.valid ? "running" : "error"}"></span><div><strong title="${extendedEscape(item.filename)}">${backupTypeLabel(item.type)} backup — ${formatTime(item.createdAt)}</strong><small>${formatBytes(item.size)}</small></div><div><span class="chip type-${extendedEscape(item.type)}">${backupTypeLabel(item.type)}</span><small>Site Gateway ${extendedEscape(item.appVersion)}</small></div><div><strong>${item.valid ? "Verified" : "Unreadable"}</strong><small>${item.valid ? "Manifest checks out" : "Manifest could not be read"}</small></div><div class="row-actions"><a class="button secondary" href="/api/backups/${encodeURIComponent(item.filename)}/download">Download</a><button class="button secondary" data-backup-action="restore">Restore</button><button class="button secondary danger-text" data-backup-action="delete">Delete</button></div></article>`).join("") : '<p class="quiet-state padded">No stored backups yet.</p>';
|
||||
}
|
||||
// Toggle the "configuration only" warning banner whenever scheduling or the
|
||||
// backup type changes.
|
||||
@@ -275,7 +275,7 @@ document.addEventListener("click", event => { if (event.target.closest(".create-
|
||||
function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = "<span></span>"; actions.append(toggle); }); }
|
||||
function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="groups"] .group-card').forEach(card => { const group = state.groups.find(value => value.id === card.querySelector("[data-group-action]")?.dataset.groupId); if (!group) return; const icon = card.querySelector(".site-icon"); if (icon && icon.textContent.trim() === "GR") icon.innerHTML = featureIcon(group, "GR"); const menu = card.querySelector(".menu"); if (menu && !menu.querySelector("[data-group-action=icon]")) { const button = document.createElement("button"); button.dataset.groupAction = "icon"; button.dataset.groupId = group.id; button.textContent = "Change icon"; menu.prepend(button); } }); }
|
||||
document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true);
|
||||
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","retention","api","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
|
||||
function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["system","users","groups","defaults","audit","backups","retention","api","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); }
|
||||
document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); });
|
||||
|
||||
// --- Backup encryption password field: placeholder/visibility polish -------------
|
||||
@@ -290,7 +290,7 @@ function renderEncryptionToggle() {
|
||||
const available = Boolean(state.config && state.config.backup && state.config.backup.encryptionAvailable);
|
||||
const savedEncrypt = Boolean(state.settings && state.settings.backups && state.settings.backups.encrypt);
|
||||
encryptionToggle.className = "encryption-toggle";
|
||||
let message = "Uses the container\u2019s <code>BACKUP_PASSWORD</code> value. Enable only after configuring that value.";
|
||||
let message = "<code>BACKUP_PASSWORD</code> is configured \u2014 scheduled backups can be encrypted.";
|
||||
if (!available && savedEncrypt) message = "This is enabled but <code>BACKUP_PASSWORD</code> is no longer configured \u2014 encrypted scheduled backups will fail until it\u2019s set again.";
|
||||
else if (!available) message = "<code>BACKUP_PASSWORD</code> not configured \u2014 set it in the container\u2019s environment to enable encrypted scheduled backups.";
|
||||
encryptionToggle.innerHTML = '<span class="field-label">Encrypt scheduled backups</span><span class="encryption-toggle-box"><input name="encrypt" type="checkbox"' + (available ? "" : " disabled") + (savedEncrypt ? " checked" : "") + '><span' + (!available ? ' class="warning-text"' : '') + '>' + message + '</span></span>';
|
||||
@@ -446,7 +446,7 @@ async function renderBackupHistory() {
|
||||
// saved value, so the integration can never be switched on without its prerequisite.
|
||||
function renderDockerPanel() {
|
||||
if (state.user?.role !== "administrator") return;
|
||||
const panel = document.querySelector('[data-admin-panel="defaults"]'); if (!panel) return;
|
||||
const panel = document.querySelector('[data-admin-panel="system"] .system-integrations'); if (!panel) return;
|
||||
const socketMounted = state.config?.docker?.socketMounted === true;
|
||||
const enabled = socketMounted && (state.settings?.dockerIntegration?.enabled === true || state.config?.docker?.enabled === true);
|
||||
let section = panel.querySelector(".docker-integration-section");
|
||||
@@ -524,11 +524,84 @@ document.addEventListener("click", async event => {
|
||||
});
|
||||
|
||||
|
||||
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
|
||||
function renderSystemPanel() {
|
||||
if (state.user?.role !== "administrator") return;
|
||||
const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]');
|
||||
if (!tabs || !users) return;
|
||||
let tab = tabs.querySelector('[data-admin-tab="system"]');
|
||||
if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "system"; tab.textContent = "System"; tabs.insertBefore(tab, tabs.firstChild); }
|
||||
let panel = document.querySelector('[data-admin-panel="system"]');
|
||||
if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "system"; panel.className = "settings-panel hidden"; users.parentElement.insertBefore(panel, users); }
|
||||
if (!panel.dataset.ready) {
|
||||
panel.dataset.ready = "1";
|
||||
panel.innerHTML = [
|
||||
'<div class="panel-heading"><div><h2>System</h2><p class="muted">What\u2019s configured, what\u2019s running, and what this deployment can do. Nothing here is customizable except the Docker toggle below and the action buttons \u2014 everything else is status.</p></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Integrations</h2></div></div><div class="system-integrations"></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Security status</h2></div></div><div id="system-security" class="health-grid"></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Gateway</p><h2>Sync</h2></div><button type="button" id="system-resync" class="button secondary">Resync now</button></div><p id="system-sync-status" class="muted"></p></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="dashboard-jobs-list"></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Storage</p><h2>Disk usage</h2></div></div><div id="system-storage" class="health-grid"></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Build</p><h2>Version</h2></div></div><div id="system-version" class="muted"></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Gateway</p><h2>Reload & restart</h2></div></div><p class="muted">Reloading re-applies the current configuration to Caddy with no downtime. Restarting stops and restarts the whole application \u2014 only available when a restart policy is set on the container.</p><div class="row-actions"><button type="button" id="system-reload" class="button secondary">Reload gateway config</button><button type="button" id="system-restart" class="button secondary danger-text" disabled>Restart application</button></div><p id="system-restart-status" class="muted"></p></div>',
|
||||
].join("");
|
||||
panel.querySelector("#system-resync").addEventListener("click", async event => {
|
||||
const button = event.currentTarget; button.disabled = true; const original = button.textContent; button.textContent = "Resyncing\u2026";
|
||||
try { await api("/api/gateway/resync", { method: "POST" }); toast("Gateway configuration re-synced."); await refresh(); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
finally { button.disabled = false; button.textContent = original; }
|
||||
});
|
||||
panel.querySelector("#system-reload").addEventListener("click", async event => {
|
||||
const button = event.currentTarget; button.disabled = true; const original = button.textContent; button.textContent = "Reloading\u2026";
|
||||
try { await api("/api/system/reload", { method: "POST" }); toast("Gateway configuration reloaded."); await refresh(); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
finally { button.disabled = false; button.textContent = original; }
|
||||
});
|
||||
panel.querySelector("#system-restart").addEventListener("click", async event => {
|
||||
if (!await themedConfirm("Restart Site Gateway?", "The application will stop and restart. This takes a few seconds and briefly interrupts hosted sites and the dashboard.", "Restart")) return;
|
||||
const button = event.currentTarget; button.disabled = true; button.textContent = "Restarting\u2026";
|
||||
try { await api("/api/system/restart", { method: "POST" }); toast("Restarting \u2014 this dashboard will be unavailable briefly."); }
|
||||
catch (error) { toast(error.message, "error"); button.disabled = false; button.textContent = "Restart application"; }
|
||||
});
|
||||
}
|
||||
renderSystemStatus(panel);
|
||||
}
|
||||
async function renderSystemStatus(panel) {
|
||||
panel = panel || document.querySelector('[data-admin-panel="system"]');
|
||||
if (!panel || panel.classList.contains("hidden")) return;
|
||||
const security = document.querySelector("#system-security"), storage = document.querySelector("#system-storage"),
|
||||
version = document.querySelector("#system-version"), jobs = document.querySelector("#system-jobs"),
|
||||
syncStatus = document.querySelector("#system-sync-status"), restartButton = document.querySelector("#system-restart"),
|
||||
restartStatus = document.querySelector("#system-restart-status");
|
||||
if (jobs) jobs.innerHTML = (state.dashboard?.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${extendedEscape(job.name)}</strong><small>${job.enabled ? `Active \u00b7 ${extendedEscape(job.schedule)}` : "Disabled"}</small></span></div>`).join("") || '<p class="quiet-state">No scheduled jobs reported.</p>';
|
||||
if (syncStatus) { const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); syncStatus.textContent = drift ? "Configuration drift detected \u2014 the running gateway no longer matches the last known-good configuration." : `Gateway configuration is in sync. Last reload: ${state.dashboard?.gateway?.lastReload ? formatTime(state.dashboard.gateway.lastReload) : "unknown"}.`; syncStatus.className = drift ? "muted status-warning" : "muted"; }
|
||||
if (version) version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")}<br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> · Admin port: <code>${extendedEscape(String(state.config?.adminPort ?? ""))}</code> · Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
|
||||
try {
|
||||
const [sec, store, policy] = await Promise.all([
|
||||
api("/api/system/security"),
|
||||
api("/api/system/storage"),
|
||||
api("/api/system/restart-policy"),
|
||||
]);
|
||||
if (security) security.innerHTML = [
|
||||
{ ok: !sec.adminPasswordIsDefault, label: "ADMIN_PASSWORD", detail: sec.adminPasswordIsDefault ? "Still using the built-in default \u2014 set this before exposing the dashboard." : "Configured." },
|
||||
{ ok: !sec.sessionSecretIsDefault, label: "SESSION_SECRET", detail: sec.sessionSecretIsDefault ? "Not set \u2014 sessions are keyed off the admin credentials instead of an independent secret." : "Configured." },
|
||||
{ ok: sec.acmeEmailConfigured, label: "ACME_EMAIL", detail: sec.acmeEmailConfigured ? "Configured." : "Not set \u2014 certificate issuance will proceed without a registration contact." },
|
||||
].map(row => `<div class="health-tile"><span class="status-dot ${row.ok ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${row.label}</strong><small>${row.detail}</small></span></div>`).join("");
|
||||
if (storage) {
|
||||
const rows = Object.entries(store.breakdown || {}).map(([key, bytes]) => `<div class="health-tile"><span class="status-dot running"></span><span class="health-tile-copy"><strong>${key[0].toUpperCase()}${key.slice(1)}</strong><small>${formatBytes(bytes)}</small></span></div>`).join("");
|
||||
const capacity = store.capacity ? `<div class="health-tile"><span class="status-dot ${store.capacity.availableBytes / store.capacity.totalBytes > 0.1 ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>Disk</strong><small>${formatBytes(store.capacity.availableBytes)} free of ${formatBytes(store.capacity.totalBytes)}</small></span></div>` : "";
|
||||
storage.innerHTML = rows + capacity || '<p class="quiet-state">Storage usage unavailable.</p>';
|
||||
}
|
||||
if (restartButton) { restartButton.disabled = !policy.restartAvailable; if (restartStatus) restartStatus.textContent = policy.reason || (policy.policyName ? `Restart policy: ${policy.policyName}.` : ""); }
|
||||
} catch { /* Status widgets keep their last-known values if a refresh call fails. */ }
|
||||
}
|
||||
|
||||
// --- Wire the new panels into the shared refresh entry point ----------------------------------
|
||||
const baseRenderExtendedViews = window.renderExtendedViews;
|
||||
window.renderExtendedViews = function () {
|
||||
baseRenderExtendedViews();
|
||||
renderApiTokensPanel();
|
||||
renderSystemPanel();
|
||||
renderDockerPanel();
|
||||
decorateContainerPickers();
|
||||
renderBackupHistory();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,121 @@
|
||||
// ============================================================================================
|
||||
// select-enhance.js -- replaces native <select> popups with a custom-drawn, dark-themed
|
||||
// listbox (Task #20). The `color-scheme` CSS hint does not reliably theme native select
|
||||
// popups across real browsers/engines, so this draws its own. The underlying native <select>
|
||||
// is kept in the DOM, fully intact for its `name`/`value`/form submission and for every
|
||||
// existing piece of code that reads or sets `form.elements[name].value` or listens for a
|
||||
// native "change" event -- none of that code needed to change. Only direct user interaction
|
||||
// with the native popup is replaced.
|
||||
// ============================================================================================
|
||||
|
||||
function enhanceSelects() {
|
||||
document.querySelectorAll("select").forEach(select => {
|
||||
if (select.dataset.enhanced) return;
|
||||
if (select.closest(".custom-select")) return;
|
||||
select.dataset.enhanced = "1";
|
||||
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "custom-select";
|
||||
select.replaceWith(wrap);
|
||||
wrap.append(select);
|
||||
|
||||
// The native element stays for value/name/form/event-listener compatibility, but is
|
||||
// removed from the tab order and made unclickable -- the trigger below is what users
|
||||
// and assistive tech actually interact with.
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute("aria-hidden", "true");
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "custom-select-trigger";
|
||||
trigger.setAttribute("role", "combobox");
|
||||
trigger.setAttribute("aria-haspopup", "listbox");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
wrap.append(trigger);
|
||||
|
||||
const syncTriggerLabel = () => {
|
||||
const option = select.options[select.selectedIndex];
|
||||
trigger.textContent = option ? option.textContent : "";
|
||||
trigger.disabled = select.disabled;
|
||||
};
|
||||
syncTriggerLabel();
|
||||
|
||||
let menu = null;
|
||||
const closeMenu = () => {
|
||||
if (!menu) return;
|
||||
menu.remove();
|
||||
menu = null;
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
const commit = (option, index) => {
|
||||
select.selectedIndex = index;
|
||||
select.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
syncTriggerLabel();
|
||||
closeMenu();
|
||||
trigger.focus();
|
||||
};
|
||||
const openMenu = () => {
|
||||
if (menu || select.disabled) return;
|
||||
menu = document.createElement("div");
|
||||
menu.className = "custom-select-menu";
|
||||
menu.setAttribute("role", "listbox");
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
menu.style.left = `${rect.left}px`;
|
||||
menu.style.top = `${rect.bottom + 4}px`;
|
||||
menu.style.width = `${rect.width}px`;
|
||||
[...select.options].forEach((option, index) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "custom-select-option" + (index === select.selectedIndex ? " is-selected" : "") + (option.disabled ? " is-disabled" : "");
|
||||
item.setAttribute("role", "option");
|
||||
item.textContent = option.textContent;
|
||||
if (option.disabled) item.setAttribute("aria-disabled", "true");
|
||||
else item.addEventListener("click", () => commit(option, index));
|
||||
menu.append(item);
|
||||
});
|
||||
// Dialogs render in the browser's top layer, which sits above ordinary DOM regardless
|
||||
// of z-index -- a menu appended to <body> for a select inside a <dialog> would render
|
||||
// beneath it. Appending into the dialog keeps the menu in the same stacking context.
|
||||
(select.closest("dialog") || document.body).append(menu);
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
const highlighted = () => menu?.querySelector(".is-highlighted") || menu?.querySelector(".is-selected") || menu?.firstElementChild;
|
||||
menu.querySelector(".is-selected")?.classList.add("is-highlighted");
|
||||
menu._moveHighlight = delta => {
|
||||
const items = [...menu.querySelectorAll(".custom-select-option:not(.is-disabled)")];
|
||||
if (!items.length) return;
|
||||
const current = menu.querySelector(".is-highlighted");
|
||||
let index = current ? items.indexOf(current) : -1;
|
||||
index = (index + delta + items.length) % items.length;
|
||||
menu.querySelectorAll(".is-highlighted").forEach(item => item.classList.remove("is-highlighted"));
|
||||
items[index].classList.add("is-highlighted");
|
||||
items[index].scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
menu._chooseHighlighted = () => {
|
||||
const item = highlighted();
|
||||
if (!item) return;
|
||||
const index = [...menu.children].indexOf(item);
|
||||
if (index >= 0 && !select.options[index]?.disabled) commit(select.options[index], index);
|
||||
};
|
||||
};
|
||||
|
||||
trigger.addEventListener("click", () => (menu ? closeMenu() : openMenu()));
|
||||
trigger.addEventListener("keydown", event => {
|
||||
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) event.preventDefault();
|
||||
if (event.key === "ArrowDown") { if (!menu) openMenu(); else menu._moveHighlight(1); }
|
||||
else if (event.key === "ArrowUp") { if (!menu) openMenu(); else menu._moveHighlight(-1); }
|
||||
else if (event.key === "Enter" || event.key === " ") { if (!menu) openMenu(); else menu._chooseHighlighted(); }
|
||||
else if (event.key === "Escape") closeMenu();
|
||||
else if (event.key === "Tab") closeMenu();
|
||||
});
|
||||
document.addEventListener("click", event => { if (menu && !wrap.contains(event.target) && !menu.contains(event.target)) closeMenu(); }, true);
|
||||
|
||||
wrap.__syncTriggerLabel = syncTriggerLabel;
|
||||
});
|
||||
// Keep every already-enhanced trigger's label in sync with code elsewhere that sets
|
||||
// `select.value`/`select.selectedIndex` directly (e.g. renderBackups() populating the
|
||||
// scheduled-backup form from saved settings) without going through the custom menu.
|
||||
document.querySelectorAll(".custom-select").forEach(wrap => wrap.__syncTriggerLabel?.());
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", enhanceSelects);
|
||||
setInterval(enhanceSelects, 150);
|
||||
@@ -709,6 +709,7 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
||||
.inline-status{grid-column:1/-1;margin:0;color:var(--green);font-size:.8rem;font-weight:700}
|
||||
.inline-status.status-success{color:var(--green)}
|
||||
.inline-status.status-warning{color:var(--warning)}
|
||||
.muted.status-warning{color:var(--warning)}
|
||||
.encryption-grid{align-items:start}
|
||||
.encryption-toggle{align-items:flex-start;padding-top:28px}
|
||||
.encryption-toggle small{display:block;margin-top:5px;color:var(--muted);font-size:.72rem;line-height:1.4}
|
||||
@@ -771,6 +772,22 @@ label.check-control:has(input[name="upstreamTlsInsecure"]){position:relative;hei
|
||||
label.check-control:has(input[name="upstreamTlsInsecure"]) span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
label.check-control:has(input[name="upstreamTlsInsecure"]) small{position:absolute;left:0;top:calc(100% + 7px);width:100%;padding:0!important;white-space:normal}
|
||||
|
||||
/* Custom-drawn select (Task #20): native <select> popups can't be reliably themed dark across
|
||||
browsers, so this replaces the popup only -- the native select stays for value/form/event
|
||||
compatibility, positioned invisibly beneath the trigger. */
|
||||
.custom-select{position:relative;display:block;width:100%;margin-top:7px}
|
||||
.custom-select select{position:absolute;inset:0;opacity:0;pointer-events:none;margin:0;width:100%;height:100%}
|
||||
.custom-select-trigger{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:100%;border:1px solid var(--line);border-radius:var(--radius-sm);padding:var(--space-3);background:var(--field-bg);color:var(--text);font:inherit;text-align:left;cursor:pointer}
|
||||
.custom-select-trigger::after{content:"";width:9px;height:9px;flex:0 0 auto;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted);transform:rotate(45deg) translateY(-2px)}
|
||||
.custom-select-trigger:focus-visible{outline:none;border-color:var(--green);box-shadow:0 0 0 3px rgba(var(--green-rgb),.1)}
|
||||
.custom-select-trigger[aria-expanded="true"]::after{transform:rotate(225deg) translateY(-2px)}
|
||||
.custom-select-trigger:disabled{opacity:.55;cursor:not-allowed}
|
||||
.custom-select-menu{position:fixed;z-index:2147483647;max-height:min(280px,40vh);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--panel);box-shadow:var(--shadow);padding:4px}
|
||||
.custom-select-option{padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);cursor:pointer;color:var(--text);font-size:.85rem}
|
||||
.custom-select-option:hover,.custom-select-option.is-highlighted{background:rgba(var(--panel-rgb),.6);background:var(--field-bg)}
|
||||
.custom-select-option.is-selected{color:var(--green)}
|
||||
.custom-select-option.is-disabled{color:var(--muted);cursor:not-allowed}
|
||||
|
||||
/* Shared diagnostic lists: Certificates, Access Logs, Gateway Events, Audit */
|
||||
select{appearance:none!important;-webkit-appearance:none!important;background-repeat:no-repeat!important;background-position:right 14px center!important;background-size:16px!important}
|
||||
#certificate-list,#readiness-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:var(--radius-2xl);background:var(--panel)}
|
||||
|
||||
Reference in New Issue
Block a user