обновлён /admin/config и API для os_registry. - Molecule/раннер: env из конфигурации, ensure roleforge-os (ensure_roleforge_os.yml), os_registry_pull и доработки executors / runner / create.yml. - /admin/os-images: выбор реестра, buildx (в т.ч. split amd64+arm64 + imagetools), опция --no-cache, стрим логов; domain.py: план команд build, ретраи push. - UI: брендинг (app_name, app_tagline) из app_config через get_ui_branding_context; base.xhtml, role-create / role-view, core.js, pages-main, стили. - Dockerfiles: требование Python ≥3.9 (assert), доработки alt9/astra/debian9/ubuntu20 и др.; новые Dockerfile.arm64 для centos7/centos8. - Конфиг: .env.example, config.py, pyproject.toml.
1128 lines
44 KiB
JavaScript
1128 lines
44 KiB
JavaScript
window.dashboard = window.dashboard || {};
|
|
|
|
const pageRoot = document.querySelector("[data-page]");
|
|
dashboard.page = pageRoot ? pageRoot.getAttribute("data-page") : "";
|
|
dashboard.wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
dashboard.chartState = { main: null, cpu: null, ram: null, pods: null };
|
|
dashboard.sparkHistory = { cpu: [], ram: [], pods: [] };
|
|
dashboard.refreshTimer = null;
|
|
/** ansi-to-html from npm (CommonJS); browser loads ESM (see initAnsiToHtml). */
|
|
dashboard.ansiConverter = null;
|
|
void (async function initAnsiToHtml() {
|
|
try {
|
|
const mod = await import("https://esm.sh/ansi-to-html@0.7.2");
|
|
const AnsiToHtml = mod && mod.default;
|
|
if (typeof AnsiToHtml !== "function") return;
|
|
window.AnsiToHtml = AnsiToHtml;
|
|
dashboard.ansiConverter = new AnsiToHtml({ fg: "#e6edf7", bg: "#0a0f1a", newline: false });
|
|
} catch (_e) {
|
|
/* No ESM/network — fall back to regex in ansiToHtml */
|
|
}
|
|
}());
|
|
|
|
dashboard.getAccessToken = function getAccessToken() { return localStorage.getItem("access_token"); };
|
|
dashboard.getRefreshToken = function getRefreshToken() { return localStorage.getItem("refresh_token"); };
|
|
dashboard.setTokens = function setTokens(accessToken, refreshToken) {
|
|
localStorage.setItem("access_token", accessToken);
|
|
localStorage.setItem("refresh_token", refreshToken);
|
|
};
|
|
dashboard.clearTokens = function clearTokens() {
|
|
localStorage.removeItem("access_token");
|
|
localStorage.removeItem("refresh_token");
|
|
};
|
|
|
|
dashboard.refreshTokens = async function refreshTokens() {
|
|
const refreshToken = dashboard.getRefreshToken();
|
|
if (!refreshToken) return false;
|
|
const response = await fetch("/auth/refresh", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
});
|
|
if (!response.ok) {
|
|
dashboard.clearTokens();
|
|
return false;
|
|
}
|
|
const data = await response.json();
|
|
dashboard.setTokens(data.access_token, data.refresh_token);
|
|
return true;
|
|
};
|
|
|
|
dashboard.startAutoRefresh = function startAutoRefresh() {
|
|
if (dashboard.refreshTimer) clearInterval(dashboard.refreshTimer);
|
|
dashboard.refreshTimer = setInterval(async () => {
|
|
await dashboard.refreshTokens();
|
|
}, 10 * 60 * 1000);
|
|
};
|
|
|
|
dashboard.authFetch = async function authFetch(url, options = {}) {
|
|
const headers = { ...(options.headers || {}) };
|
|
const token = dashboard.getAccessToken();
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
return fetch(url, { ...options, headers });
|
|
};
|
|
|
|
dashboard.ansiToHtml = function ansiToHtml(value) {
|
|
if (dashboard.ansiConverter) return dashboard.ansiConverter.toHtml(String(value || ""));
|
|
return String(value || "")
|
|
.replace(/\u001b\[31m/g, '<span style="color:#f87171">')
|
|
.replace(/\u001b\[32m/g, '<span style="color:#4ade80">')
|
|
.replace(/\u001b\[33m/g, '<span style="color:#facc15">')
|
|
.replace(/\u001b\[34m/g, '<span style="color:#60a5fa">')
|
|
.replace(/\u001b\[35m/g, '<span style="color:#c084fc">')
|
|
.replace(/\u001b\[36m/g, '<span style="color:#22d3ee">')
|
|
.replace(/\u001b\[0m/g, "</span>");
|
|
};
|
|
|
|
dashboard.escapeHtml = function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
};
|
|
|
|
dashboard.normalizeApiError = function normalizeApiError(payload, fallbackText) {
|
|
if (payload == null) return fallbackText || "Unexpected error";
|
|
if (typeof payload === "string") return payload;
|
|
if (Array.isArray(payload)) {
|
|
return payload
|
|
.map((item) => dashboard.normalizeApiError(item, ""))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
if (typeof payload === "object") {
|
|
if (typeof payload.detail === "string") return payload.detail;
|
|
if (Array.isArray(payload.detail)) {
|
|
return payload.detail
|
|
.map((item) => {
|
|
if (typeof item === "string") return item;
|
|
if (item && typeof item === "object") {
|
|
const path = Array.isArray(item.loc) ? item.loc.join(".") : "";
|
|
const msg = item.msg || JSON.stringify(item);
|
|
return path ? `${path}: ${msg}` : String(msg);
|
|
}
|
|
return String(item);
|
|
})
|
|
.join("\n");
|
|
}
|
|
try {
|
|
return JSON.stringify(payload, null, 2);
|
|
} catch (_e) {
|
|
return fallbackText || "Unexpected error";
|
|
}
|
|
}
|
|
return String(payload);
|
|
};
|
|
|
|
dashboard.getErrorMessage = function getErrorMessage(error, fallbackText = "Unexpected error") {
|
|
if (!error) return fallbackText;
|
|
if (typeof error === "string") return error;
|
|
if (error instanceof Error) return error.message || fallbackText;
|
|
return dashboard.normalizeApiError(error, fallbackText);
|
|
};
|
|
|
|
dashboard.showErrorModal = async function showErrorModal(error, title = "Error") {
|
|
const message = dashboard.getErrorMessage(error, "Unexpected error");
|
|
await dashboard.infoDialog(message, { title, okText: "Close" });
|
|
};
|
|
|
|
dashboard.actionIcon = function actionIcon(name) {
|
|
const icons = {
|
|
open: "<path d=\"M14 3h7v7\"/><path d=\"M10 14 21 3\"/><path d=\"M21 14v7h-7\"/><path d=\"M3 10V3h7\"/><path d=\"M3 21h7v-7\"/>",
|
|
retry: "<path d=\"M3 12a9 9 0 0 1 15.4-6.4\"/><path d=\"M18 2v4h-4\"/><path d=\"M21 12a9 9 0 0 1-15.4 6.4\"/><path d=\"M6 22v-4h4\"/>",
|
|
cancel: "<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M9 9l6 6\"/><path d=\"M15 9l-6 6\"/>",
|
|
delete: "<path d=\"M3 6h18\"/><path d=\"M8 6V4h8v2\"/><path d=\"M19 6l-1 14H6L5 6\"/><path d=\"M10 11v6\"/><path d=\"M14 11v6\"/>",
|
|
remove: "<path d=\"M5 12h14\"/>",
|
|
approve: "<path d=\"M20 6 9 17l-5-5\"/>",
|
|
reject: "<path d=\"M18 6 6 18\"/><path d=\"M6 6l12 12\"/>",
|
|
};
|
|
const body = icons[String(name || "").toLowerCase()] || icons.open;
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
};
|
|
|
|
dashboard.actionIconButtonHtml = function actionIconButtonHtml(options = {}) {
|
|
const tag = options.tag === "a" ? "a" : "button";
|
|
const variant = options.variant === "danger" ? "btn-danger" : "btn-muted";
|
|
const icon = dashboard.actionIcon(options.icon || "open");
|
|
const title = dashboard.escapeHtml(String(options.title || ""));
|
|
const classes = `${variant} btn-icon ${String(options.className || "")}`.trim();
|
|
const tooltipAttr = `data-tooltip="${title}"`;
|
|
const attrs = String(options.attrs || "");
|
|
if (tag === "a") {
|
|
const href = dashboard.escapeHtml(String(options.href || "#"));
|
|
return `<a class="${classes}" href="${href}" title="${title}" aria-label="${title}" ${tooltipAttr} ${attrs}>${icon}</a>`;
|
|
}
|
|
return `<button type="button" class="${classes}" title="${title}" aria-label="${title}" ${tooltipAttr} ${attrs}>${icon}</button>`;
|
|
};
|
|
|
|
dashboard.confirmDialog = function confirmDialog(message, options = {}) {
|
|
return new Promise((resolve) => {
|
|
const title = options && options.title ? String(options.title) : "Confirm action";
|
|
const okText = options && options.okText ? String(options.okText) : "Confirm";
|
|
const cancelText = options && options.cancelText ? String(options.cancelText) : "Cancel";
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "app-modal-overlay";
|
|
overlay.innerHTML = `
|
|
<div class="app-modal" role="dialog" aria-modal="true" aria-label="${dashboard.escapeHtml(title)}">
|
|
<h3 class="app-modal__title">${dashboard.escapeHtml(title)}</h3>
|
|
<p class="app-modal__text">${dashboard.escapeHtml(String(message || ""))}</p>
|
|
<div class="app-modal__actions">
|
|
<button type="button" class="btn-muted js-modal-cancel">${dashboard.escapeHtml(cancelText)}</button>
|
|
<button type="button" class="btn-danger js-modal-ok">${dashboard.escapeHtml(okText)}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const finalize = (result) => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
overlay.remove();
|
|
resolve(Boolean(result));
|
|
};
|
|
const onKeyDown = (event) => {
|
|
if (event.key === "Escape") finalize(false);
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
overlay.addEventListener("click", (event) => {
|
|
if (event.target === overlay) finalize(false);
|
|
});
|
|
overlay.querySelector(".js-modal-cancel").addEventListener("click", () => finalize(false));
|
|
overlay.querySelector(".js-modal-ok").addEventListener("click", () => finalize(true));
|
|
|
|
document.body.appendChild(overlay);
|
|
});
|
|
};
|
|
|
|
dashboard.infoDialog = function infoDialog(message, options = {}) {
|
|
return new Promise((resolve) => {
|
|
const title = options && options.title ? String(options.title) : "Information";
|
|
const okText = options && options.okText ? String(options.okText) : "OK";
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "app-modal-overlay";
|
|
overlay.innerHTML = `
|
|
<div class="app-modal" role="dialog" aria-modal="true" aria-label="${dashboard.escapeHtml(title)}">
|
|
<h3 class="app-modal__title">${dashboard.escapeHtml(title)}</h3>
|
|
<p class="app-modal__text">${dashboard.escapeHtml(String(message || ""))}</p>
|
|
<div class="app-modal__actions">
|
|
<button type="button" class="btn-muted js-modal-ok">${dashboard.escapeHtml(okText)}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const finalize = () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
overlay.remove();
|
|
resolve(true);
|
|
};
|
|
const onKeyDown = (event) => {
|
|
if (event.key === "Escape" || event.key === "Enter") finalize();
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
overlay.addEventListener("click", (event) => {
|
|
if (event.target === overlay) finalize();
|
|
});
|
|
overlay.querySelector(".js-modal-ok").addEventListener("click", finalize);
|
|
document.body.appendChild(overlay);
|
|
});
|
|
};
|
|
|
|
/** Read-only shell snippet with Copy + Close (used after Docker Hub push). */
|
|
dashboard.snippetCopyDialog = function snippetCopyDialog(snippet, options = {}) {
|
|
return new Promise((resolve) => {
|
|
const title = options && options.title ? String(options.title) : "Shell commands";
|
|
const intro = options && options.intro ? String(options.intro) : "";
|
|
const copyLabel = options && options.copyText ? String(options.copyText) : "Copy";
|
|
const closeLabel = options && options.closeText ? String(options.closeText) : "Close";
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "app-modal-overlay";
|
|
|
|
const modal = document.createElement("div");
|
|
modal.className = "app-modal app-modal--snippet";
|
|
modal.setAttribute("role", "dialog");
|
|
modal.setAttribute("aria-modal", "true");
|
|
modal.setAttribute("aria-label", title);
|
|
|
|
const h = document.createElement("h3");
|
|
h.className = "app-modal__title";
|
|
h.textContent = title;
|
|
|
|
const ta = document.createElement("textarea");
|
|
ta.className = "admin-os-snippet-textarea";
|
|
ta.readOnly = true;
|
|
ta.rows = Math.min(18, Math.max(6, String(snippet || "").split("\n").length + 2));
|
|
ta.value = String(snippet || "");
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "app-modal__actions";
|
|
|
|
const copyBtn = document.createElement("button");
|
|
copyBtn.type = "button";
|
|
copyBtn.className = "cta-button js-modal-copy";
|
|
copyBtn.textContent = copyLabel;
|
|
|
|
const closeBtn = document.createElement("button");
|
|
closeBtn.type = "button";
|
|
closeBtn.className = "btn-muted js-modal-close";
|
|
closeBtn.textContent = closeLabel;
|
|
|
|
actions.appendChild(copyBtn);
|
|
actions.appendChild(closeBtn);
|
|
|
|
modal.appendChild(h);
|
|
if (intro) {
|
|
const introEl = document.createElement("p");
|
|
introEl.className = "app-modal__text muted";
|
|
introEl.textContent = intro;
|
|
modal.appendChild(introEl);
|
|
}
|
|
modal.appendChild(ta);
|
|
modal.appendChild(actions);
|
|
overlay.appendChild(modal);
|
|
|
|
const finalize = () => {
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
overlay.remove();
|
|
resolve(true);
|
|
};
|
|
const onKeyDown = (event) => {
|
|
if (event.key === "Escape") finalize();
|
|
};
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
overlay.addEventListener("click", (event) => {
|
|
if (event.target === overlay) finalize();
|
|
});
|
|
closeBtn.addEventListener("click", finalize);
|
|
|
|
copyBtn.addEventListener("click", async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(ta.value);
|
|
} catch (_e) {
|
|
ta.focus();
|
|
ta.select();
|
|
try {
|
|
document.execCommand("copy");
|
|
} catch (__e) {
|
|
void __e;
|
|
}
|
|
}
|
|
copyBtn.textContent = "Copied";
|
|
window.setTimeout(() => {
|
|
copyBtn.textContent = copyLabel;
|
|
}, 1600);
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
ta.focus();
|
|
ta.select();
|
|
});
|
|
};
|
|
|
|
/** ISO/UTC dates from the API → local human-readable date/time. */
|
|
dashboard.formatDateTime = function formatDateTime(iso) {
|
|
if (iso == null || iso === "") return "—";
|
|
const s = String(iso).trim();
|
|
const d = new Date(s.includes("T") || s.endsWith("Z") || /[+-]\d{2}:?\d{2}$/.test(s) ? s : s.replace(" ", "T"));
|
|
if (Number.isNaN(d.getTime())) return s;
|
|
const loc = document.documentElement.lang || "en-US";
|
|
return d.toLocaleString(loc, {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
};
|
|
|
|
/** Compact date/time for tables (audit, logs). */
|
|
dashboard.formatDateTimeCompact = function formatDateTimeCompact(iso) {
|
|
if (iso == null || iso === "") return "—";
|
|
const s = String(iso).trim();
|
|
const d = new Date(
|
|
s.includes("T") || s.endsWith("Z") || /[+-]\d{2}:?\d{2}$/.test(s) ? s : s.replace(" ", "T")
|
|
);
|
|
if (Number.isNaN(d.getTime())) return s;
|
|
const loc = document.documentElement.lang || "en-US";
|
|
return d.toLocaleString(loc, {
|
|
year: "2-digit",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
});
|
|
};
|
|
|
|
dashboard._resolveNavClusterId = function _resolveNavClusterId() {
|
|
const layout = document.querySelector(".layout");
|
|
const d = layout && layout.getAttribute("data-cluster-id");
|
|
if (d && String(d).trim()) {
|
|
return String(d).trim();
|
|
}
|
|
const m = /\/clusters\/(\d+)/.exec(window.location.pathname || "");
|
|
if (m) {
|
|
try { localStorage.setItem("k3s_last_cluster", m[1]); } catch (e) { void e; }
|
|
return m[1];
|
|
}
|
|
try {
|
|
return String(localStorage.getItem("k3s_last_cluster") || "").trim() || null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Monochrome SVG icons for submenu items (stroke currentColor), no external fonts.
|
|
* @param {string} id — key (rbac_users, k3s, header_clusters, …)
|
|
*/
|
|
dashboard._sublinkIcon = function _sublinkIcon(id) {
|
|
const p = (paths) => (
|
|
"<span class=\"menu-sublink__icon\" aria-hidden=\"true\">" +
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">" +
|
|
paths +
|
|
"</svg></span>"
|
|
);
|
|
const o = {
|
|
/** Cluster list (same as top-level menu item) */
|
|
all_clusters: p(
|
|
"<path d=\"M8 6h13\"/><path d=\"M8 12h13\"/><path d=\"M8 18h10\"/>" +
|
|
"<circle cx=\"4\" cy=\"6\" r=\"1.5\"/><circle cx=\"4\" cy=\"12\" r=\"1.5\"/><circle cx=\"4\" cy=\"18\" r=\"1.5\"/>"
|
|
),
|
|
new_cluster: p("<line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/>"),
|
|
cluster_node: p(
|
|
"<rect x=\"2\" y=\"2\" width=\"20\" height=\"8\" rx=\"1.5\"/>" +
|
|
"<rect x=\"2\" y=\"14\" width=\"20\" height=\"8\" rx=\"1.5\"/>" +
|
|
"<line x1=\"6\" y1=\"6\" x2=\"6.01\" y2=\"6\"/>" +
|
|
"<line x1=\"6\" y1=\"18\" x2=\"6.01\" y2=\"18\"/>"
|
|
),
|
|
k3s: p(
|
|
"<line x1=\"4\" y1=\"21\" x2=\"4\" y2=\"15\"/>" +
|
|
"<line x1=\"4\" y1=\"9\" x2=\"4\" y2=\"3\"/>" +
|
|
"<line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"12\"/>" +
|
|
"<line x1=\"12\" y1=\"6\" x2=\"12\" y2=\"3\"/>" +
|
|
"<line x1=\"20\" y1=\"21\" x2=\"20\" y2=\"16\"/>" +
|
|
"<line x1=\"20\" y1=\"10\" x2=\"20\" y2=\"3\"/>"
|
|
),
|
|
group_all: p("<polygon points=\"12 2 2 7 12 12 22 7 12 2\"/><polyline points=\"2 17 12 22 22 17\"/><polyline points=\"2 12 12 17 22 12\"/>"),
|
|
group_addons: p(
|
|
"<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"/>" +
|
|
"<polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"/>" +
|
|
"<line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"/>"
|
|
),
|
|
vault: p(
|
|
"<rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"/>" +
|
|
"<path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>"
|
|
),
|
|
inventory: p(
|
|
"<ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\"/>" +
|
|
"<path d=\"M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5\"/>" +
|
|
"<path d=\"M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6\"/>"
|
|
),
|
|
host_vars: p(
|
|
"<path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\"/>" +
|
|
"<path d=\"M14 2v4a2 2 0 0 0 2 2h4\"/>" +
|
|
"<line x1=\"8\" y1=\"13\" x2=\"12\" y2=\"13\"/><line x1=\"8\" y1=\"17\" x2=\"12\" y2=\"17\"/>" +
|
|
"<line x1=\"8\" y1=\"9\" x2=\"8.01\" y2=\"9\"/>"
|
|
),
|
|
/** Add-ons enable (power on) */
|
|
addons_enable: p(
|
|
"<path d=\"M18.36 6.64A9 9 0 1 1 5.64 6.64\"/>" + "<line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"6\"/>"
|
|
),
|
|
};
|
|
return o[id] || "";
|
|
};
|
|
|
|
dashboard.initSidebarNav = async function initSidebarNav() {
|
|
const root = document.getElementById("sidebar-clusters-sub");
|
|
if (!root) return;
|
|
let clusters;
|
|
try {
|
|
clusters = await dashboard.apiFetch("/api/clusters");
|
|
} catch (_e) {
|
|
root.innerHTML = "<p class=\"muted\" style=\"font-size:0.8rem;padding:0.2rem 0\">Clusters unavailable (API)</p>";
|
|
return;
|
|
}
|
|
let activeCid = dashboard._resolveNavClusterId();
|
|
if (!activeCid && Array.isArray(clusters) && clusters.length) {
|
|
activeCid = String(clusters[0].id);
|
|
try { localStorage.setItem("k3s_last_cluster", activeCid); } catch (e) { void e; }
|
|
}
|
|
if (activeCid) {
|
|
document.querySelectorAll("a.js-cid-href[data-cid-suffix]").forEach((a) => {
|
|
a.setAttribute("href", "/clusters/" + activeCid + "/" + a.getAttribute("data-cid-suffix"));
|
|
a.classList.remove("js-cid-href");
|
|
});
|
|
}
|
|
const esc = dashboard.escapeHtml;
|
|
const ic = dashboard._sublinkIcon;
|
|
const provLabels = [
|
|
["k3s", "Cluster options", "k3s"],
|
|
["group-all", "Group vars", "group_all"],
|
|
["group-addons", "Addons", "group_addons"],
|
|
["group-vault", "Vault", "vault"],
|
|
["inventory", "Inventory & nodes", "inventory"],
|
|
["host-vars", "Host vars", "host_vars"],
|
|
["addons-enable", "Addons enable", "addons_enable"],
|
|
];
|
|
const accChev =
|
|
"<span class=\"menu-accordion__toggle\" aria-hidden=\"true\">" +
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">" +
|
|
"<path d=\"M6 9l6 6 6-6\"/>" +
|
|
"</svg></span>";
|
|
let html =
|
|
"<a href=\"/clusters\" class=\"menu-sublink menu-link menu-sublink--with-icon\" data-menu-path=\"/clusters\">" +
|
|
ic("all_clusters") +
|
|
"<span class=\"menu-sublink__label\">All clusters</span></a>";
|
|
html +=
|
|
"<a href=\"/clusters/create\" class=\"menu-sublink menu-link menu-sublink--with-icon\">" +
|
|
ic("new_cluster") +
|
|
"<span class=\"menu-sublink__label\">Create cluster</span></a>";
|
|
clusters.forEach((c) => {
|
|
const id = String(c.id);
|
|
const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
const name = c.name || "Cluster #" + id;
|
|
const b = "/clusters/" + id;
|
|
const hId = "acc-head-c-" + safeId;
|
|
const pId = "acc-panel-c-" + safeId;
|
|
const accKey = "cluster-" + id;
|
|
html += "<div class=\"menu-accordion menu-accordion--cluster\" data-menu-accordion=\"" + accKey + "\" data-tree-cluster-id=\"" + esc(id) + "\">";
|
|
html +=
|
|
"<button class=\"menu-link menu-accordion__head menu-accordion__head--cluster\" type=\"button\" id=\"" +
|
|
hId +
|
|
"\" aria-controls=\"" +
|
|
pId +
|
|
"\" aria-expanded=\"false\">" +
|
|
"<span class=\"menu-icon\" aria-hidden=\"true\">" +
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">" +
|
|
"<rect x=\"2\" y=\"2\" width=\"20\" height=\"8\" rx=\"1.5\"/>" +
|
|
"<rect x=\"2\" y=\"14\" width=\"20\" height=\"8\" rx=\"1.5\"/>" +
|
|
"<line x1=\"6\" y1=\"6\" x2=\"6.01\" y2=\"6\"/>" +
|
|
"<line x1=\"6\" y1=\"18\" x2=\"6.01\" y2=\"18\"/>" +
|
|
"</svg></span>" +
|
|
"<span class=\"menu-text\">" +
|
|
esc(String(name)) +
|
|
"</span>" +
|
|
accChev +
|
|
"</button>";
|
|
html += "<div class=\"menu-accordion__panel\" id=\"" + pId + "\" role=\"region\" aria-labelledby=\"" + hId + "\">";
|
|
html += "<div class=\"menu-nested--tree menu-nested--cluster-tree\" aria-label=\"Submenu for " + esc(String(name)) + "\">";
|
|
html +=
|
|
"<a class=\"menu-sublink menu-link menu-sublink--with-icon\" href=\"" +
|
|
b +
|
|
"\" data-menu-path=\"" +
|
|
b +
|
|
"\">" +
|
|
ic("cluster_node") +
|
|
"<span class=\"menu-sublink__label\">Overview</span></a>";
|
|
provLabels.forEach(([sub, label, key]) => {
|
|
html +=
|
|
"<a class=\"menu-sublink menu-link menu-sublink--with-icon\" href=\"" +
|
|
b +
|
|
"/provisioning/" +
|
|
sub +
|
|
"\">" +
|
|
ic(key) +
|
|
"<span class=\"menu-sublink__label\">" +
|
|
label +
|
|
"</span></a>";
|
|
});
|
|
html += "</div></div></div>";
|
|
});
|
|
root.innerHTML = html;
|
|
dashboard.markActiveSidebar();
|
|
};
|
|
|
|
dashboard.applyMenuAccordions = function applyMenuAccordions() {
|
|
const accs = document.querySelectorAll(".menu-accordion");
|
|
if (!accs.length) return;
|
|
const stateKey = "k3s_sidebar_accordion_state";
|
|
const rawPath = window.location.pathname || "/";
|
|
const pathname = rawPath.length > 1 && rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath;
|
|
const clusterPathMatch = pathname.match(/^\/clusters\/([^/]+)(?:\/|$)/);
|
|
const activeClusterIdFromPath = clusterPathMatch ? clusterPathMatch[1] : "";
|
|
let saved = {};
|
|
try {
|
|
saved = JSON.parse(localStorage.getItem(stateKey) || "{}") || {};
|
|
} catch (_e) {
|
|
saved = {};
|
|
}
|
|
|
|
function hasActive(accordion) {
|
|
return Boolean(accordion.querySelector("a.active"));
|
|
}
|
|
|
|
accs.forEach((acc) => {
|
|
const key = acc.getAttribute("data-menu-accordion") || "";
|
|
const isCluster = key.indexOf("cluster-") === 0;
|
|
const hasActiveLink = hasActive(acc);
|
|
let active = hasActiveLink;
|
|
if (isCluster) {
|
|
const treeClusterId = acc.getAttribute("data-tree-cluster-id") || "";
|
|
const isCurrentCluster = Boolean(activeClusterIdFromPath) && treeClusterId === activeClusterIdFromPath;
|
|
/* In the cluster tree only the leaf page link is highlighted, not the cluster heading */
|
|
active = false;
|
|
acc.classList.toggle("is-open", isCurrentCluster && hasActiveLink);
|
|
acc.classList.toggle("is-active", false);
|
|
const hCluster = acc.querySelector(".menu-accordion__head");
|
|
if (hCluster) hCluster.setAttribute("aria-expanded", isCurrentCluster && hasActiveLink ? "true" : "false");
|
|
return;
|
|
}
|
|
let open = false;
|
|
if (Object.prototype.hasOwnProperty.call(saved, key)) {
|
|
open = Boolean(saved[key]);
|
|
}
|
|
if (active) {
|
|
open = true;
|
|
}
|
|
acc.classList.toggle("is-open", open);
|
|
acc.classList.toggle("is-active", active);
|
|
const h = acc.querySelector(".menu-accordion__head");
|
|
if (h) h.setAttribute("aria-expanded", open ? "true" : "false");
|
|
});
|
|
};
|
|
|
|
dashboard.initMenuAccordions = function initMenuAccordions() {
|
|
const nav = document.querySelector(".sidebar-nav");
|
|
if (!nav || nav.dataset.accordionInit === "1") return;
|
|
nav.dataset.accordionInit = "1";
|
|
const stateKey = "k3s_sidebar_accordion_state";
|
|
const scrollKey = "k3s_sidebar_scroll_top";
|
|
const sidebar = document.querySelector(".sidebar");
|
|
if (sidebar) {
|
|
try {
|
|
const st = Number(localStorage.getItem(scrollKey));
|
|
if (Number.isFinite(st) && st >= 0) sidebar.scrollTop = st;
|
|
} catch (_e) {
|
|
}
|
|
sidebar.addEventListener("scroll", () => {
|
|
try { localStorage.setItem(scrollKey, String(sidebar.scrollTop)); } catch (_e) {}
|
|
}, { passive: true });
|
|
}
|
|
nav.addEventListener("click", (e) => {
|
|
const t = e.target;
|
|
if (!t || !t.closest) return;
|
|
const head = t.closest(".menu-accordion__head");
|
|
if (!head || !head.closest(".menu-accordion") || !nav.contains(head)) return;
|
|
e.preventDefault();
|
|
const acc = head.closest(".menu-accordion");
|
|
if (!acc) return;
|
|
acc.classList.toggle("is-open");
|
|
const open = acc.classList.contains("is-open");
|
|
head.setAttribute("aria-expanded", open ? "true" : "false");
|
|
const key = acc.getAttribute("data-menu-accordion");
|
|
if (!key) return;
|
|
let saved = {};
|
|
try { saved = JSON.parse(localStorage.getItem(stateKey) || "{}") || {}; } catch (_e) { saved = {}; }
|
|
saved[key] = open;
|
|
try { localStorage.setItem(stateKey, JSON.stringify(saved)); } catch (_e) {}
|
|
});
|
|
};
|
|
|
|
dashboard.markActiveSidebar = function markActiveSidebar() {
|
|
const raw = window.location.pathname || "/";
|
|
const pathname = raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
|
|
|
const links = [...document.querySelectorAll(".menu-link, a.menu-sublink")];
|
|
const scored = links
|
|
.map((link) => {
|
|
let href = link.getAttribute("href") || "";
|
|
if (!href) return { link, score: -1 };
|
|
href = href.length > 1 && href.endsWith("/") ? href.slice(0, -1) : href;
|
|
|
|
let score = -1;
|
|
if (href === "/") {
|
|
if (pathname === "/") score = 100000;
|
|
} else if (pathname === href) {
|
|
score = href.length + 10000;
|
|
} else if (pathname.startsWith(`${href}/`)) {
|
|
score = href.length;
|
|
}
|
|
return { link, score };
|
|
})
|
|
.filter((x) => x.score >= 0);
|
|
|
|
links.forEach((l) => l.classList.remove("active"));
|
|
if (!scored.length) {
|
|
dashboard.applyMenuAccordions();
|
|
return;
|
|
}
|
|
const best = Math.max(...scored.map((x) => x.score));
|
|
scored.forEach(({ link, score }) => {
|
|
if (score === best) link.classList.add("active");
|
|
});
|
|
dashboard.applyMenuAccordions();
|
|
};
|
|
|
|
dashboard.initTheme = function initTheme() {
|
|
const body = document.body;
|
|
const root = document.documentElement;
|
|
const saved = localStorage.getItem("dashboard-theme");
|
|
const initial = saved || "dark";
|
|
body.setAttribute("data-theme", initial);
|
|
root.setAttribute("data-theme", initial);
|
|
const button = document.getElementById("theme-toggle");
|
|
if (!button) return;
|
|
button.textContent = initial === "dark" ? "☀" : "🌙";
|
|
button.title = initial === "dark" ? "Switch to light theme" : "Switch to dark theme";
|
|
button.addEventListener("click", () => {
|
|
const next = body.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
|
body.setAttribute("data-theme", next);
|
|
root.setAttribute("data-theme", next);
|
|
localStorage.setItem("dashboard-theme", next);
|
|
button.textContent = next === "dark" ? "☀" : "🌙";
|
|
button.title = next === "dark" ? "Switch to light theme" : "Switch to dark theme";
|
|
});
|
|
};
|
|
|
|
dashboard.performLogout = async function performLogout() {
|
|
const refreshToken = dashboard.getRefreshToken();
|
|
try {
|
|
await dashboard.authFetch("/auth/logout", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
});
|
|
} catch (_error) {
|
|
} finally {
|
|
dashboard.clearTokens();
|
|
window.location.href = "/login";
|
|
}
|
|
};
|
|
|
|
dashboard.initLogout = function initLogout() {
|
|
const btn = document.getElementById("logout-btn");
|
|
if (btn) btn.addEventListener("click", () => dashboard.performLogout());
|
|
};
|
|
|
|
dashboard.setSkeleton = function setSkeleton(element, enabled = true) {
|
|
if (!element) return;
|
|
element.classList.toggle("skeleton", enabled);
|
|
};
|
|
|
|
dashboard.makeIdempotencyKey = function makeIdempotencyKey(prefix = "req") {
|
|
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
};
|
|
|
|
dashboard.withIdempotency = function withIdempotency(url, options = {}) {
|
|
const method = String(options.method || "GET").toUpperCase();
|
|
if (!["POST", "PUT", "PATCH"].includes(method)) return options;
|
|
const headers = { ...(options.headers || {}) };
|
|
if (!headers["Idempotency-Key"]) {
|
|
const safePrefix = url.replace(/[^a-zA-Z0-9]/g, "-").slice(0, 24) || "req";
|
|
headers["Idempotency-Key"] = dashboard.makeIdempotencyKey(safePrefix);
|
|
}
|
|
return { ...options, headers };
|
|
};
|
|
|
|
dashboard.apiFetch = async function apiFetch(url, options = {}) {
|
|
const requestOptions = dashboard.withIdempotency(url, options);
|
|
let response = await dashboard.authFetch(url, requestOptions);
|
|
let data = null;
|
|
try {
|
|
data = await response.json();
|
|
} catch (_error) {
|
|
data = null;
|
|
}
|
|
if (response.status === 401 && dashboard.getRefreshToken()) {
|
|
const refreshed = await dashboard.refreshTokens();
|
|
if (refreshed) {
|
|
response = await dashboard.authFetch(url, requestOptions);
|
|
try {
|
|
data = await response.json();
|
|
} catch (_error) {
|
|
data = null;
|
|
}
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
if (response.status === 401 && data && data.error_code === "token_revoked") {
|
|
throw new Error("Session expired: token revoked. Please login again.");
|
|
}
|
|
throw new Error(dashboard.normalizeApiError(data, `Request failed: ${response.status}`));
|
|
}
|
|
return data;
|
|
};
|
|
|
|
/** Fetch with Bearer and retry after refresh. For binary responses (ZIP etc.), does not parse JSON. */
|
|
dashboard.authFetchWithRetry = async function authFetchWithRetry(url, options = {}) {
|
|
const requestOptions = dashboard.withIdempotency(url, options);
|
|
let response = await dashboard.authFetch(url, requestOptions);
|
|
if (response.status === 401 && dashboard.getRefreshToken()) {
|
|
const refreshed = await dashboard.refreshTokens();
|
|
if (refreshed) {
|
|
response = await dashboard.authFetch(url, requestOptions);
|
|
}
|
|
}
|
|
return response;
|
|
};
|
|
|
|
dashboard.filenameFromContentDisposition = function filenameFromContentDisposition(headerValue, fallback) {
|
|
if (!headerValue || typeof headerValue !== "string") return fallback || "download";
|
|
const star = headerValue.match(/filename\*=UTF-8''([^;\s]+)/i);
|
|
if (star && star[1]) {
|
|
try {
|
|
return decodeURIComponent(star[1].trim());
|
|
} catch (_e) {
|
|
return star[1].trim();
|
|
}
|
|
}
|
|
const quoted = headerValue.match(/filename="([^"]+)"/i);
|
|
if (quoted && quoted[1]) return quoted[1];
|
|
const plain = headerValue.match(/filename=([^;\s]+)/i);
|
|
if (plain && plain[1]) return plain[1].trim().replace(/^"|"$/g, "");
|
|
return fallback || "download";
|
|
};
|
|
|
|
dashboard.triggerBlobDownload = function triggerBlobDownload(blob, filename) {
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = objectUrl;
|
|
anchor.download = filename || "download";
|
|
anchor.rel = "noopener";
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(objectUrl);
|
|
};
|
|
|
|
dashboard.initAuthSession = async function initAuthSession() {
|
|
if (!dashboard.getAccessToken() && !["login", "register", "reset-password", "error"].includes(dashboard.page)) {
|
|
window.location.href = "/login";
|
|
return;
|
|
}
|
|
if (dashboard.getAccessToken()) {
|
|
try {
|
|
dashboard.currentUser = await dashboard.apiFetch("/auth/me");
|
|
} catch (_error) {
|
|
}
|
|
}
|
|
if (dashboard.getRefreshToken()) dashboard.startAutoRefresh();
|
|
};
|
|
|
|
/**
|
|
* Fills the panel: list of blocks (help_blocks) with separators, otherwise a single help string.
|
|
* JSON API fields: help, help_blocks (same as Pydantic help_blocks → JSON).
|
|
*/
|
|
dashboard.fillSchemaTipPanel = function fillSchemaTipPanel(panel, schema) {
|
|
if (!panel || !schema) return;
|
|
const blocks = schema.help_blocks;
|
|
if (Array.isArray(blocks) && blocks.length) {
|
|
while (panel.firstChild) {
|
|
panel.removeChild(panel.firstChild);
|
|
}
|
|
panel.classList.add("schema-field-tip-panel--blocks");
|
|
blocks.forEach((block, i) => {
|
|
const d = document.createElement("div");
|
|
d.className = "schema-field-tip-block";
|
|
if (i > 0) d.classList.add("schema-field-tip-block--sep");
|
|
d.textContent = String(block);
|
|
panel.appendChild(d);
|
|
});
|
|
} else if (schema.help) {
|
|
panel.classList.remove("schema-field-tip-panel--blocks");
|
|
panel.textContent = String(schema.help);
|
|
}
|
|
};
|
|
|
|
/** Hint icon like field hints, without an input (e.g. host_vars). helpBlocks — optional. */
|
|
dashboard.insertFieldHint = function insertFieldHint(container, title, helpText, helpBlocks) {
|
|
if (!container) return;
|
|
const hasB = Array.isArray(helpBlocks) && helpBlocks.length;
|
|
if (!hasB && (!helpText || !String(helpText).trim())) return;
|
|
const row = document.createElement("div");
|
|
row.className = "schema-field schema-field--hint-only";
|
|
const label = document.createElement("span");
|
|
label.className = "schema-field-hint-title";
|
|
label.textContent = title;
|
|
const tipWrap = document.createElement("div");
|
|
tipWrap.className = "schema-field-tip-wrap";
|
|
const tipBtn = document.createElement("button");
|
|
tipBtn.type = "button";
|
|
tipBtn.className = "schema-field-tip";
|
|
const safeTitle = String(title || "hint");
|
|
tipBtn.setAttribute("aria-label", "Hint: " + safeTitle);
|
|
tipBtn.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/></svg>";
|
|
const panel = document.createElement("div");
|
|
panel.className = "schema-field-tip-panel";
|
|
panel.setAttribute("role", "tooltip");
|
|
if (hasB) {
|
|
dashboard.fillSchemaTipPanel(panel, { help: helpText, help_blocks: helpBlocks });
|
|
} else {
|
|
panel.textContent = String(helpText);
|
|
}
|
|
tipWrap.appendChild(tipBtn);
|
|
tipWrap.appendChild(panel);
|
|
const inner = document.createElement("div");
|
|
inner.className = "schema-field-label-row";
|
|
inner.appendChild(label);
|
|
inner.appendChild(tipWrap);
|
|
row.appendChild(inner);
|
|
container.appendChild(row);
|
|
};
|
|
|
|
dashboard.buildField = function buildField(name, schema) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "schema-field";
|
|
let element;
|
|
const defaultValue = schema.default;
|
|
const defaultText = defaultValue === undefined || defaultValue === null ? "" : String(defaultValue);
|
|
const hasNewline = defaultText.indexOf("\n") !== -1;
|
|
const useTextarea = schema.type === "string" && (
|
|
schema.textarea === true
|
|
|| schema.multiline === true
|
|
|| hasNewline
|
|
);
|
|
const lineCount = defaultText ? defaultText.split("\n").length : 1;
|
|
const textareaRows = Math.min(40, Math.max(2, lineCount));
|
|
const isWideField = useTextarea || (schema && schema.wide);
|
|
const fieldLabel = schema.label != null && String(schema.label).length ? String(schema.label) : name;
|
|
if (schema.type === "boolean") {
|
|
wrapper.classList.add("schema-field--boolean");
|
|
const safeId = `field-${String(name).replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
element = document.createElement("input");
|
|
element.type = "checkbox";
|
|
element.className = "form-check-input";
|
|
element.id = safeId;
|
|
element.name = name;
|
|
element.checked = Boolean(schema.default);
|
|
const titleLabel = document.createElement("label");
|
|
titleLabel.setAttribute("for", safeId);
|
|
titleLabel.textContent = fieldLabel;
|
|
if (schema.help || (schema.help_blocks && schema.help_blocks.length)) {
|
|
const labelRow = document.createElement("div");
|
|
labelRow.className = "schema-field-label-row";
|
|
labelRow.appendChild(titleLabel);
|
|
const tipWrap = document.createElement("div");
|
|
tipWrap.className = "schema-field-tip-wrap";
|
|
const tipBtn = document.createElement("button");
|
|
tipBtn.type = "button";
|
|
tipBtn.className = "schema-field-tip";
|
|
tipBtn.setAttribute("aria-label", `Hint: ${fieldLabel}`);
|
|
tipBtn.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/></svg>";
|
|
const panel = document.createElement("div");
|
|
panel.className = "schema-field-tip-panel";
|
|
panel.setAttribute("role", "tooltip");
|
|
dashboard.fillSchemaTipPanel(panel, schema);
|
|
tipWrap.appendChild(tipBtn);
|
|
tipWrap.appendChild(panel);
|
|
labelRow.appendChild(tipWrap);
|
|
wrapper.appendChild(labelRow);
|
|
} else {
|
|
wrapper.appendChild(titleLabel);
|
|
}
|
|
const swRow = document.createElement("div");
|
|
swRow.className = "form-check form-switch";
|
|
swRow.appendChild(element);
|
|
wrapper.appendChild(swRow);
|
|
} else if (schema.type === "select") {
|
|
element = document.createElement("select");
|
|
(schema.options || []).forEach((v) => {
|
|
const option = document.createElement("option");
|
|
option.value = v;
|
|
option.textContent = v;
|
|
if (v === schema.default) option.selected = true;
|
|
element.appendChild(option);
|
|
});
|
|
} else {
|
|
if (schema.type === "number") {
|
|
element = document.createElement("input");
|
|
element.type = "number";
|
|
if (schema.default !== undefined && schema.default !== null) element.value = String(schema.default);
|
|
} else if (useTextarea) {
|
|
element = document.createElement("textarea");
|
|
element.rows = textareaRows;
|
|
element.classList.add("field-large");
|
|
if (schema.default !== undefined && schema.default !== null) element.value = String(schema.default);
|
|
} else {
|
|
element = document.createElement("input");
|
|
element.type = "text";
|
|
if (schema.default !== undefined && schema.default !== null) element.value = String(schema.default);
|
|
}
|
|
}
|
|
if (schema.type !== "boolean") {
|
|
const label = document.createElement("label");
|
|
label.textContent = fieldLabel;
|
|
if (schema.help || (schema.help_blocks && schema.help_blocks.length)) {
|
|
const row = document.createElement("div");
|
|
row.className = "schema-field-label-row";
|
|
row.appendChild(label);
|
|
const tipWrap = document.createElement("div");
|
|
tipWrap.className = "schema-field-tip-wrap";
|
|
const tipBtn = document.createElement("button");
|
|
tipBtn.type = "button";
|
|
tipBtn.className = "schema-field-tip";
|
|
tipBtn.setAttribute("aria-label", `Hint: ${fieldLabel}`);
|
|
tipBtn.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/></svg>";
|
|
const panel = document.createElement("div");
|
|
panel.className = "schema-field-tip-panel";
|
|
panel.setAttribute("role", "tooltip");
|
|
dashboard.fillSchemaTipPanel(panel, schema);
|
|
tipWrap.appendChild(tipBtn);
|
|
tipWrap.appendChild(panel);
|
|
row.appendChild(tipWrap);
|
|
wrapper.insertBefore(row, wrapper.firstChild);
|
|
} else {
|
|
wrapper.insertBefore(label, wrapper.firstChild);
|
|
}
|
|
element.name = name;
|
|
wrapper.appendChild(element);
|
|
}
|
|
if (isWideField) wrapper.classList.add("schema-field-wide");
|
|
const desc = document.createElement("small");
|
|
desc.className = "field-description";
|
|
desc.textContent = schema.description || "";
|
|
if (desc.textContent.trim()) wrapper.appendChild(desc);
|
|
return wrapper;
|
|
};
|
|
|
|
dashboard.collectValues = function collectValues(container, schema) {
|
|
const values = {};
|
|
Object.entries(schema.fields || {}).forEach(([name, field]) => {
|
|
const el = container.querySelector(`[name="${name}"]`);
|
|
if (!el) return;
|
|
if (field.type === "boolean") values[name] = el.checked;
|
|
else if (field.type === "number") values[name] = Number(el.value);
|
|
else values[name] = el.value;
|
|
});
|
|
return values;
|
|
};
|
|
|
|
/** While a new page loads: centered spinner, blurred backdrop (in-app links + first paint after DOMContentLoaded). */
|
|
void (function initPageTransitionLoader() {
|
|
const docEl = document.documentElement;
|
|
function showLoader() {
|
|
docEl.classList.add("page-loading");
|
|
docEl.setAttribute("aria-busy", "true");
|
|
}
|
|
function hideLoader() {
|
|
docEl.classList.remove("page-loading");
|
|
docEl.removeAttribute("aria-busy");
|
|
}
|
|
function scheduleHideAfterFirstPaint() {
|
|
requestAnimationFrame(hideLoader);
|
|
}
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", scheduleHideAfterFirstPaint, { once: true });
|
|
} else {
|
|
scheduleHideAfterFirstPaint();
|
|
}
|
|
window.addEventListener("pageshow", (e) => {
|
|
if (e.persisted) hideLoader();
|
|
});
|
|
document.addEventListener("click", (e) => {
|
|
if (e.defaultPrevented || e.button !== 0) return;
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
const t = e.target;
|
|
if (!t || !t.closest) return;
|
|
const a = t.closest("a[href]");
|
|
if (!a) return;
|
|
if (a.getAttribute("data-skip-nav-loader") !== null) return;
|
|
if (a.getAttribute("download") !== null) return;
|
|
if (a.getAttribute("target") === "_blank") return;
|
|
const hrefAttr = a.getAttribute("href");
|
|
if (hrefAttr == null || hrefAttr === "" || hrefAttr.toLowerCase().indexOf("javascript:") === 0) return;
|
|
if (hrefAttr === "#") return;
|
|
var url;
|
|
try {
|
|
url = new URL(a.href, window.location.origin);
|
|
} catch (_e) {
|
|
return;
|
|
}
|
|
if (url.origin !== window.location.origin) return;
|
|
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
|
|
showLoader();
|
|
}, true);
|
|
document.addEventListener("submit", (e) => {
|
|
if (e.defaultPrevented) return;
|
|
const form = e.target;
|
|
if (!form || form.tagName !== "FORM") return;
|
|
if (form.getAttribute("data-skip-nav-loader") !== null) return;
|
|
if (form.getAttribute("target") === "_blank") return;
|
|
const action = form.getAttribute("action");
|
|
if (action && /^(https?:)/i.test(action)) {
|
|
try {
|
|
if (new URL(action).origin !== window.location.origin) return;
|
|
} catch (_e) {
|
|
return;
|
|
}
|
|
}
|
|
showLoader();
|
|
});
|
|
}());
|
|
|
|
dashboard.defaultUserAvatarDataUri =
|
|
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Ccircle cx='32' cy='32' r='32' fill='%2364748b'/%3E%3Cpath d='M32 20c-4.4 0-8 3.6-8 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zm0 20c-6.6 0-12 5.4-12 12h24c0-6.6-5.4-12-12-12z' fill='%23fff'/%3E%3C/svg%3E";
|
|
|
|
/** Sidebar avatar + account dropdown (pinned to bottom of sidebar). */
|
|
dashboard.initSidebarUserMenu = function initSidebarUserMenu() {
|
|
const root = document.getElementById("sidebar-user-menu");
|
|
const toggle = document.getElementById("sidebarUserMenuToggle");
|
|
const dropdown = document.getElementById("sidebarUserMenuDropdown");
|
|
const img = document.getElementById("sidebarUserAvatar");
|
|
if (!root || !toggle || !dropdown || !img) return;
|
|
|
|
const displayNameEl = document.getElementById("sidebarUserDisplayName");
|
|
const usernameEl = document.getElementById("sidebarUserUsername");
|
|
|
|
async function refreshSidebarIdentity() {
|
|
try {
|
|
const me = await dashboard.apiFetch("/auth/me");
|
|
const url = me.avatar_54 || me.avatar_128 || dashboard.defaultUserAvatarDataUri;
|
|
const isData = String(url).indexOf("data:") === 0;
|
|
img.src = isData ? url : `${url}${String(url).indexOf("?") >= 0 ? "&" : "?"}t=${Date.now()}`;
|
|
img.alt = String(me.full_name || me.username || "Account");
|
|
if (displayNameEl) displayNameEl.textContent = me.full_name || me.username || "User";
|
|
if (usernameEl) usernameEl.textContent = me.username ? `@${me.username}` : "";
|
|
} catch (_e) {
|
|
img.src = dashboard.defaultUserAvatarDataUri;
|
|
if (displayNameEl) displayNameEl.textContent = "User";
|
|
if (usernameEl) usernameEl.textContent = "";
|
|
}
|
|
}
|
|
|
|
refreshSidebarIdentity();
|
|
|
|
function closeMenu() {
|
|
dropdown.classList.remove("is-open");
|
|
dropdown.setAttribute("aria-hidden", "true");
|
|
toggle.setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
function openMenu() {
|
|
dropdown.classList.add("is-open");
|
|
dropdown.setAttribute("aria-hidden", "false");
|
|
toggle.setAttribute("aria-expanded", "true");
|
|
}
|
|
|
|
toggle.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (dropdown.classList.contains("is-open")) closeMenu();
|
|
else openMenu();
|
|
});
|
|
|
|
document.addEventListener("click", (e) => {
|
|
if (root.contains(e.target)) return;
|
|
closeMenu();
|
|
});
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") closeMenu();
|
|
});
|
|
|
|
const ddLogout = document.getElementById("sidebarDropdownLogoutBtn");
|
|
if (ddLogout) {
|
|
ddLogout.addEventListener("click", () => {
|
|
closeMenu();
|
|
void dashboard.performLogout();
|
|
});
|
|
}
|
|
|
|
window.refreshSidebarUserAvatar = refreshSidebarIdentity;
|
|
};
|