Add Groups stat bar, align Create group button, fix Logs/Performance/Certificates header spacing (v0.16.13)
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
||||
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
|
||||
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.12-62E6A7">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.16.13-62E6A7">
|
||||
</p>
|
||||
<p>
|
||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||
|
||||
@@ -150,3 +150,5 @@ Roughly in priority order:
|
||||
`v0.16.11` merges the System tab's separate "Sync" and "Reload & restart" sections into one "Sync & control" panel, since the two were both short, related, single-button gateway-control sections that were falling out of line with the rest of the tab's panel widths on their own. Also adds a spacing rule so the merged panel's status line and description text don't sit flush against each other.
|
||||
|
||||
`v0.16.12` fixes the Backup & Restore tab's "Backup history" section getting stuck on "Loading backup history…" indefinitely. Root cause: `renderBackupHistory()` is only invoked from the periodic `refresh()` cycle, and it bailed out before fetching whenever the Backups tab wasn't the currently active admin tab at that moment — but switching admin tabs only toggles CSS visibility, it never re-triggers a fetch. So if a refresh cycle landed while you were on a different tab, the placeholder text was left in place with no later refresh ever replacing it. Same bug class as v0.16.3's System-tab fix; resolved the same way, by always populating the section's content regardless of the panel's current visibility.
|
||||
|
||||
`v0.16.13` fixes three Administration/Logs layout inconsistencies found in live use. First, the Groups tab was missing the stat-count bar ("N Administrators · N Standard Users · ...") that every other listing tab (Users) shows, making it look unfinished by comparison — added a matching Enabled/Disabled group count bar. Second, "Create group" lived in its own row inside the Groups panel instead of the shared top-right header button used by "Create user," "New hosted site," and every other creation action — moved it into that same header slot so it behaves and aligns like all the others. Third, the Logs page's "Refresh logs" button (and Performance's and Certificates') sat directly against the first box below it with no gap, because those three pages are the only ones with no status-summary bar to provide the usual spacing under the page header — added a matching top margin so they're consistent with every other page.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.16.12",
|
||||
"version": "0.16.13",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
+5
-3
@@ -504,8 +504,9 @@ function render() {
|
||||
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account");
|
||||
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
|
||||
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
||||
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
|
||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||
const activeAdminTab = state.view === "administration" ? document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab : null;
|
||||
const adminUsersActive = activeAdminTab === "users", adminGroupsActive = activeAdminTab === "groups";
|
||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || adminGroupsActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||
if (overview) {
|
||||
$("#page-title").textContent = "Dashboard";
|
||||
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
||||
@@ -515,7 +516,7 @@ function render() {
|
||||
if (!management) {
|
||||
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
|
||||
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
|
||||
$("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
$("#open-create").textContent = state.view === "administration" ? (adminGroupsActive ? "+ Create group" : "+ Create user") : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||
if (state.view === "streaming") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); }
|
||||
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
|
||||
@@ -659,6 +660,7 @@ $("#event-category").addEventListener("change", renderLogs);
|
||||
|
||||
// --- "Create" dialog: opens the right create form/dialog for the current view --------------
|
||||
function openCreate() {
|
||||
if (state.view === "administration" && state.adminTab === "groups") { openNewGroupEditor(); return; }
|
||||
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
|
||||
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
|
||||
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
|
||||
|
||||
@@ -258,12 +258,12 @@ document.addEventListener("change", async event => { const checkbox = event.targ
|
||||
document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; const kind = checkbox.dataset.assignmentKind; if (!accessListId) { checkbox.checked = !checkbox.checked; toast("Open an Access List before assigning hosts."); return; } try { await api("/api/access-lists/" + accessListId + "/assignments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind, hostId: checkbox.dataset.assignmentId, assigned: checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true);
|
||||
|
||||
// --- Groups admin panel (dynamically inserted "Groups" tab) ----------------------
|
||||
function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } panel.innerHTML = '<div class="panel-heading"><div><h2>Groups</h2><p class="muted">Organize users for Access List permissions.</p></div><button id="create-group" class="button primary">Create group</button></div>' + (state.groups.length ? '<div class="user-grid">' + state.groups.map(group => '<article class="site-card group-card"><div class="card-top"><div class="site-icon">GR</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Group options" aria-expanded="false">•••</button><div class="menu"><button data-group-action="edit" data-group-id="' + group.id + '">Edit group</button><button data-group-action="icon" data-group-id="' + group.id + '">Change icon</button><button data-group-action="delete" data-group-id="' + group.id + '" class="danger-text">Delete group</button></div></div></div><h2>' + extendedEscape(group.name) + '</h2><p class="address">' + (group.members?.length || 0) + ' members</p><div class="card-footer"><span class="status-pill"><span class="status-dot ' + (group.enabled === false ? "disabled" : "running") + '"></span>' + (group.enabled === false ? "Disabled" : "Enabled") + '</span><div class="card-actions"><button class="toggle ' + (group.enabled !== false ? "on" : "") + '" data-group-action="toggle" data-group-id="' + group.id + '" aria-label="' + (group.enabled !== false ? "Disable" : "Enable") + ' group"><span></span></button></div></div></article>').join("") + '</div>' : '<p class="quiet-state padded">No groups yet. Create one to organize users.</p>'); }
|
||||
function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } const enabledCount = state.groups.filter(group => group.enabled !== false).length, disabledCount = state.groups.length - enabledCount; const summaryHtml = '<div id="group-summary" class="summary">' + [["Enabled", enabledCount, "#62e6a7"], ["Disabled", disabledCount, "#ff7185"]].map(([label, count, color]) => `<div><span class="status-dot" style="${count ? `background:${color}` : ""}"></span><strong>${count}</strong><span>${label}</span></div>`).join("") + '</div>'; panel.innerHTML = '<div class="panel-heading"><div><h2>Groups</h2><p class="muted">Organize users for Access List permissions.</p></div></div>' + summaryHtml + (state.groups.length ? '<div class="user-grid">' + state.groups.map(group => '<article class="site-card group-card"><div class="card-top"><div class="site-icon">GR</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Group options" aria-expanded="false">•••</button><div class="menu"><button data-group-action="edit" data-group-id="' + group.id + '">Edit group</button><button data-group-action="icon" data-group-id="' + group.id + '">Change icon</button><button data-group-action="delete" data-group-id="' + group.id + '" class="danger-text">Delete group</button></div></div></div><h2>' + extendedEscape(group.name) + '</h2><p class="address">' + (group.members?.length || 0) + ' members</p><div class="card-footer"><span class="status-pill"><span class="status-dot ' + (group.enabled === false ? "disabled" : "running") + '"></span>' + (group.enabled === false ? "Disabled" : "Enabled") + '</span><div class="card-actions"><button class="toggle ' + (group.enabled !== false ? "on" : "") + '" data-group-action="toggle" data-group-id="' + group.id + '" aria-label="' + (group.enabled !== false ? "Disable" : "Enable") + ' group"><span></span></button></div></div></article>').join("") + '</div>' : '<p class="quiet-state padded">No groups yet. Create one to organize users.</p>'); }
|
||||
|
||||
function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Edit group</h2></div></div><label>Group name<input name="name" required maxlength="80"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Save group</button></div></form>'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
|
||||
document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true);
|
||||
function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">' + (state.users || []).filter(user => user.status !== "disabled").map(user => '<label class="check-control"><input type="checkbox" name="members" value="' + user.id + '"><span>' + extendedEscape(user.username) + ' <small>' + extendedEscape(user.role || "Standard User") + '</small></span></label>').join("") + '</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); }
|
||||
document.addEventListener("click", async event => { if (event.target.id === "create-group") { openNewGroupEditor(); return; } const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } });
|
||||
document.addEventListener("click", async event => { const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } });
|
||||
function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>" + (state.groups.length ? "<div class=\"access-group-options\">" + state.groups.map(group => "<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\"" + group.id + "\"" + (selected.includes(group.id) ? " checked" : "") + "><span>" + extendedEscape(group.name) + " <small>" + (group.members?.length || 0) + " members" + (group.enabled === false ? " · Disabled" : "") + "</small></span></label>").join("") + "</div>" : "<p class=\"access-group-empty\">No groups have been created yet.</p>"); }
|
||||
document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true);
|
||||
document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); });
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<title>Site Gateway</title>
|
||||
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
|
||||
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.16.12">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.16.13">
|
||||
</head>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -439,6 +439,6 @@
|
||||
<div id="toast" class="toast" role="status"></div>
|
||||
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
|
||||
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
|
||||
<script src="/app.js?v=0.16.12" defer></script><script src="/features.js?v=0.16.12" defer></script><script src="/select-enhance.js?v=0.16.12" defer></script>
|
||||
<script src="/app.js?v=0.16.13" defer></script><script src="/features.js?v=0.16.13" defer></script><script src="/select-enhance.js?v=0.16.13" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -149,6 +149,7 @@ header{align-items:flex-end}
|
||||
/* Dashboard */
|
||||
.mobile-nav{display:none}
|
||||
.dashboard-view{margin-top:38px}
|
||||
#certificates-view,#performance-view,#logs-view{margin-top:var(--space-7)}
|
||||
.metric-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-4)}
|
||||
.metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:var(--radius-2xl);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));color:var(--text);text-align:left;position:relative;overflow:hidden}
|
||||
.metric-card::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--card-accent,var(--green));opacity:.85}
|
||||
|
||||
Reference in New Issue
Block a user