Files
RoleForge/app/static/js/core.js
Sergey Antropoff 01d598eea5 - Админка: настройка pull-реестра (Hub / Harbor / Nexus) в БД, шифрование секретов;
обновлён /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.
2026-05-06 07:52:29 +03:00

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
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 &amp; 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;
};