/** * Панель управления кластерами kind (REST /api/v1). * Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная * замена содержимого блоков (статистика, таблицы, плашка среды) — SPA-поведение. * * Автор: Сергей Антропов * Сайт: https://devops.org.ru */ (function () { "use strict"; const body = document.body; const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); /** Интервал автообновления таблиц и среды на панели (мс) */ var AUTO_REFRESH_MS = 3500; /** Опрос GET /jobs/{id} для активного задания (мс) — чаще, чтобы журнал обновлялся живее */ var JOB_POLL_ACTIVE_MS = 650; /** @type {ReturnType | null} */ var autoTimer = null; /** @type {ReturnType | null} */ var pollTimer = null; var createInProgress = false; /** Блокировать форму «Создать» только при создании из формы, не при старт/стоп из таблицы. */ var pollLockCreateForm = false; /** @type {string | null} */ var currentPollJobId = null; /** Полноэкранный спиннер старт/стоп (не используется при создании кластера из формы). */ var clusterLifecycleJobOverlayActive = false; /** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */ var cachedClusterResourcesByName = {}; /** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */ var cachedHealthPayload = null; /** * Кэш GET /versions для проверки тега перед созданием кластера. * null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов. */ var cachedKindestTags = null; /** true, если запрос /versions упал (сеть, 5xx и т.д.). */ var cachedKindestVersionsLoadError = false; /** true, если сервер вернул skipped (например KIND_K8S_SKIP_VERSION_LIST). */ var cachedKindestTagsSkipped = false; /** Режим модалки подтверждения: только кнопка «Понятно» (Escape закрывает как OK). */ var confirmModalIsAlert = false; function isHomePage() { return (document.body.getAttribute("data-dashboard-mode") || "home") === "home"; } /** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */ function isClustersListPage() { return document.body.getAttribute("data-dashboard-mode") === "clusters"; } function isCreatePage() { return document.body.getAttribute("data-dashboard-mode") === "create"; } /** Страница одного кластера: ``/cluster/<имя>``. */ function isClusterPage() { return document.body.getAttribute("data-dashboard-mode") === "cluster"; } /** Имя кластера с ``body[data-cluster-name]`` (страница кластера). */ function getClusterPageName() { const raw = document.body.getAttribute("data-cluster-name"); return raw && raw.trim() ? raw.trim() : null; } /** Скрыть полноэкранный спиннер первой загрузки страницы кластера. */ function hideClusterPageLoadingOverlay() { const el = document.getElementById("cluster-page-loading-overlay"); if (!el) return; el.classList.add("hidden"); el.setAttribute("aria-busy", "false"); document.body.classList.remove("cluster-page-loading"); } /** Скрыть спиннер первой загрузки главной панели. */ function hideHomePageLoadingOverlay() { const el = document.getElementById("home-page-loading-overlay"); if (!el) return; el.classList.add("hidden"); el.setAttribute("aria-busy", "false"); document.body.classList.remove("dashboard-home-loading"); } /** Скрыть спиннер первой загрузки страницы «Кластеры». */ function hideClustersPageLoadingOverlay() { const el = document.getElementById("clusters-page-loading-overlay"); if (!el) return; el.classList.add("hidden"); el.setAttribute("aria-busy", "false"); el.setAttribute("aria-hidden", "true"); document.body.classList.remove("dashboard-clusters-loading"); } /** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */ function showClusterLifecycleJobOverlay(labelText) { const el = document.getElementById("cluster-lifecycle-job-overlay"); const lab = document.getElementById("cluster-lifecycle-job-label"); if (!el) return; clusterLifecycleJobOverlayActive = true; if (lab) lab.textContent = labelText || "Выполняется операция…"; el.classList.remove("hidden"); el.setAttribute("aria-busy", "true"); el.setAttribute("aria-hidden", "false"); document.body.classList.add("cluster-lifecycle-job-busy"); } function hideClusterLifecycleJobOverlay() { const el = document.getElementById("cluster-lifecycle-job-overlay"); if (!el) return; clusterLifecycleJobOverlayActive = false; el.classList.add("hidden"); el.setAttribute("aria-busy", "false"); el.setAttribute("aria-hidden", "true"); document.body.classList.remove("cluster-lifecycle-job-busy"); } function formatApiError(data, fallback) { if (!data) return fallback; if (typeof data.detail === "string") return data.detail; if (Array.isArray(data.detail)) { return data.detail .map(function (x) { return (x.msg || x) + (x.loc ? " (" + x.loc.join(".") + ")" : ""); }) .join("; "); } return fallback; } /** * @param {string} path * @param {RequestInit} [opts] */ async function api(path, opts) { const url = path.startsWith("http") ? path : API + path; const r = await fetch(url, opts); const text = await r.text(); var data; try { data = text ? JSON.parse(text) : null; } catch (e) { data = { raw: text }; } if (!r.ok) { const msg = formatApiError(data, text || r.statusText); const err = new Error(msg); err.status = r.status; throw err; } return data; } function escapeHtml(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } /** * Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig). * @param {string} clusterName */ function isProvisionLogModalOpen() { const el = document.getElementById("provision-log-modal-overlay"); return !!(el && !el.classList.contains("hidden")); } function closeProvisionLogModal() { const el = document.getElementById("provision-log-modal-overlay"); if (el) el.classList.add("hidden"); } function isJobLogModalOpen() { const el = document.getElementById("job-log-modal-overlay"); return !!(el && !el.classList.contains("hidden")); } function closeJobLogModal() { const el = document.getElementById("job-log-modal-overlay"); if (el) el.classList.add("hidden"); } /** * Открыть модалку с текстовым журналом завершённого задания (GET /jobs/{id}). * @param {string} jobId */ function openJobLogModal(jobId) { const overlay = document.getElementById("job-log-modal-overlay"); const sub = document.getElementById("job-log-modal-sub"); const pre = document.getElementById("job-log-modal-body"); const titleEl = document.getElementById("job-log-modal-title"); if (!overlay || !pre) return; pre.textContent = "Загрузка журнала…"; if (sub) sub.textContent = "Задание: " + jobId; if (titleEl) titleEl.textContent = "Журнал задания"; overlay.classList.remove("hidden"); api("/jobs/" + encodeURIComponent(jobId)) .then(function (job) { if (titleEl) { titleEl.textContent = "Журнал задания · " + (job.status || "") + (job.kind ? " · " + job.kind : ""); } if (sub) { var parts = ["Задание: " + jobId]; if (job.cluster_name) parts.push("Кластер: " + job.cluster_name); if (job.message) parts.push(job.message); sub.textContent = parts.join(" · "); } var lines = job.progress_log || []; pre.textContent = lines.length ? lines.join("\n") : job.message || "(журнал пуст)"; }) .catch(function (e) { pre.textContent = "Не удалось загрузить журнал: " + (e && e.message ? e.message : String(e)); }); } /** * Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке. * @param {string} clusterName */ function openProvisionLogModal(clusterName) { const overlay = document.getElementById("provision-log-modal-overlay"); const sub = document.getElementById("provision-log-modal-sub"); const pre = document.getElementById("provision-log-body"); if (!overlay || !pre) return; pre.textContent = "Загрузка…"; if (sub) sub.textContent = "Кластер: " + clusterName; overlay.classList.remove("hidden"); api("/clusters/" + encodeURIComponent(clusterName) + "/provision-log") .then(function (data) { pre.textContent = JSON.stringify(data, null, 2); }) .catch(function (e) { pre.textContent = "Ошибка: " + e.message; }); } function downloadKubeconfig(clusterName) { const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig"; const a = document.createElement("a"); a.href = url; a.download = "kubeconfig-" + clusterName + ".yaml"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } /** * Скачать kubeconfig для kubectl из другого контейнера: host.docker.internal:порт + tls-server-name (см. GET …/kubeconfig/docker). * @param {string} clusterName */ function downloadKubeconfigDocker(clusterName) { const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig/docker"; const a = document.createElement("a"); a.href = url; a.download = "kubeconfig-docker-" + clusterName + ".yaml"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } /** SVG-иконки действий (stroke, currentColor). */ var ICONS = { state: '', play: '', stop: '', download: '', /** kubeconfig для контейнера: apiserver по имени control-plane в сети Docker */ kubeDocker: '', trash: '', logFile: '', restart: '', edit: '', }; /** @type {ReturnType | null} */ var actionTooltipHideTimer = null; function getActionTooltipEl() { return document.getElementById("action-tooltip"); } /** Скрыть плавающую подсказку (#action-tooltip, position: fixed). */ function hideActionTooltip() { if (actionTooltipHideTimer) { clearTimeout(actionTooltipHideTimer); actionTooltipHideTimer = null; } const el = getActionTooltipEl(); if (!el) return; el.classList.add("hidden"); el.textContent = ""; el.style.visibility = ""; } /** * Показать подсказку у иконки (полный текст, без обрезки overflow таблицы). * @param {HTMLElement} host элемент .icon-tooltip-host */ function showActionTooltip(host) { const el = getActionTooltipEl(); const text = host.getAttribute("data-tooltip"); if (!el || !text) return; if (actionTooltipHideTimer) { clearTimeout(actionTooltipHideTimer); actionTooltipHideTimer = null; } el.textContent = text; const margin = 10; const maxW = Math.min(352, window.innerWidth - margin * 2); el.style.maxWidth = maxW + "px"; el.classList.remove("hidden"); var w = el.offsetWidth; var h = el.offsetHeight; var r = host.getBoundingClientRect(); var left = r.left + r.width / 2 - w / 2; if (left < margin) left = margin; if (left + w > window.innerWidth - margin) left = Math.max(margin, window.innerWidth - w - margin); var top = r.top - h - margin; if (top < margin) top = r.bottom + margin; if (top + h > window.innerHeight - margin) { top = Math.max(margin, window.innerHeight - h - margin); } el.style.left = left + "px"; el.style.top = top + "px"; } /** * Подписать .icon-tooltip-host внутри root на hover/focus (повторно безопасно). * @param {ParentNode | null} root */ function bindActionTooltipHosts(root) { if (!root) return; root.querySelectorAll(".icon-tooltip-host").forEach(function (host) { if (host.dataset.actionTooltipBound === "1") return; host.dataset.actionTooltipBound = "1"; host.addEventListener("mouseenter", function () { showActionTooltip(host); }); host.addEventListener("mouseleave", function () { actionTooltipHideTimer = setTimeout(hideActionTooltip, 120); }); host.addEventListener("focusin", function () { showActionTooltip(host); }); host.addEventListener("focusout", function (ev) { var rel = ev.relatedTarget; if (!rel || !host.contains(rel)) hideActionTooltip(); }); }); } /** * Кнопка-иконка в обёртке; подсказка — data-tooltip (см. #action-tooltip в base.html). * @param {string} svgHtml * @param {string} tooltip * @param {string} [classExtra] например icon-btn--danger * @param {((ev: Event) => void) | null} onClick * @param {boolean} [isDisabled] */ function iconActionButton(svgHtml, tooltip, classExtra, onClick, isDisabled) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "icon-btn" + (classExtra ? " " + classExtra : ""); btn.innerHTML = svgHtml; btn.setAttribute("aria-label", tooltip); if (isDisabled) { btn.disabled = true; } else if (onClick) { btn.addEventListener("click", onClick); } const host = document.createElement("span"); host.className = "icon-tooltip-host"; host.setAttribute("data-tooltip", tooltip); host.appendChild(btn); return host; } function showToast(message, isError) { const el = document.getElementById("toast"); if (!el) return; el.textContent = message; el.classList.remove("hidden", "toast-error", "toast-ok"); el.classList.add(isError ? "toast-error" : "toast-ok"); clearTimeout(el._hideT); el._hideT = setTimeout(function () { el.classList.add("hidden"); }, 4500); } /** * Карточки среды в блоке статистики (как плитки «Кластеров в kind»). * @param {string} label подпись снизу (CAPS в стиле stats-tile__label) * @param {string} valueText крупный текст сверху * @param {boolean} isOk * @param {string | null} [detail] доп. строка мелким шрифтом */ function buildStatsEnvCard(label, valueText, isOk, detail) { const tile = document.createElement("div"); tile.className = "stats-tile stats-env-card " + (isOk ? "stats-env-card--ok" : "stats-env-card--bad"); const valEl = document.createElement("span"); valEl.className = "stats-tile__value"; valEl.textContent = valueText; const labEl = document.createElement("span"); labEl.className = "stats-tile__label"; labEl.textContent = label; tile.appendChild(valEl); tile.appendChild(labEl); if (detail) { const dEl = document.createElement("span"); dEl.className = "stats-env-card__detail muted"; dEl.textContent = detail.slice(0, 400); tile.appendChild(dEl); } return tile; } /** Нормализованное имя CLI для подписей (docker → Docker, podman → Podman). */ function titleCaseContainerCli(cliRaw) { const s = String(cliRaw || "docker").trim() || "docker"; return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); } /** Текст под заголовком «Статистика»: через какой движок идут узлы и метрики. */ function updateStatsEngineHint(cliRaw) { const el = document.getElementById("stats-engine-hint"); if (!el) return; const name = titleCaseContainerCli(cliRaw); el.textContent = "Узлы kind (контейнеры control-plane и worker) и метрики ниже собираются через " + name + "."; el.classList.remove("hidden"); } function hideStatsEngineHint() { const el = document.getElementById("stats-engine-hint"); if (!el) return; el.classList.add("hidden"); el.textContent = ""; } /** * Скрыть ряд среды и метрик, показать одну карточку ошибки на всю карточку «Статистика». * @param {"Health"|"Статистика"} sourceLabel подпись под «Ошибка» */ function coverStatsCardWithError(sourceLabel, message) { hideStatsEngineHint(); const failureWrap = document.getElementById("stats-failure-wrap"); const env = document.getElementById("stats-env"); const metricsGrid = document.getElementById("stats-grid"); const resCard = document.getElementById("home-resources-summary-card"); if (failureWrap) { failureWrap.classList.remove("hidden"); failureWrap.replaceChildren(); const tile = document.createElement("div"); tile.className = "stats-tile stats-env-card stats-env-card--bad stats-failure-card"; const valEl = document.createElement("span"); valEl.className = "stats-tile__value"; valEl.textContent = "Ошибка"; const labEl = document.createElement("span"); labEl.className = "stats-tile__label"; labEl.textContent = sourceLabel; const dEl = document.createElement("span"); dEl.className = "stats-env-card__detail muted"; dEl.textContent = message || ""; tile.appendChild(valEl); tile.appendChild(labEl); tile.appendChild(dEl); failureWrap.appendChild(tile); } if (env) env.classList.add("hidden"); if (metricsGrid) metricsGrid.classList.add("hidden"); if (resCard) resCard.classList.add("hidden"); } /** Показать снова ряд среды и сетку метрик (после успешных запросов). */ function revealStatsCardBody() { const failureWrap = document.getElementById("stats-failure-wrap"); const env = document.getElementById("stats-env"); const metricsGrid = document.getElementById("stats-grid"); const resCard = document.getElementById("home-resources-summary-card"); if (failureWrap) { failureWrap.classList.add("hidden"); failureWrap.replaceChildren(); } if (env) env.classList.remove("hidden"); if (metricsGrid) metricsGrid.classList.remove("hidden"); if (resCard) resCard.classList.remove("hidden"); } /** * Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` — первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»). */ function paintStatsEnvRow() { const el = document.getElementById("stats-env"); if (!el) return; if (!cachedHealthPayload) { return; } el.className = "stats-env-cards"; el.replaceChildren(); const h = cachedHealthPayload; const cliRaw = String(h.container_cli || "docker").trim() || "docker"; const cliTitle = titleCaseContainerCli(cliRaw); const apiOk = !!h.container_engine_ok; el.appendChild( buildStatsEnvCard( cliTitle + " API", apiOk ? "OK" : "Недоступно", apiOk, !apiOk && h.container_engine_detail ? String(h.container_engine_detail) : null, ), ); const kindOk = !!h.kind_in_path; el.appendChild(buildStatsEnvCard("Kind", kindOk ? "OK" : "нет в PATH", kindOk, null)); const kubectlOk = !!h.kubectl_in_path; el.appendChild( buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null), ); } async function loadHealth() { const el = document.getElementById("stats-env"); if (!el) return; try { const h = await api("/health"); cachedHealthPayload = h; revealStatsCardBody(); paintStatsEnvRow(); updateStatsEngineHint(h.container_cli); } catch (e) { cachedHealthPayload = null; el.replaceChildren(); coverStatsCardWithError("Health", e.message || String(e)); } } /** * Отрисовка донатов в заданный корень (главная: ``#aggregate-donuts``, страница кластера: ``#cluster-page-aggregate-donuts``). * @param {HTMLElement | null} root * @param {object | null | undefined} agg * @param {string | null | undefined} resourcesError * @param {string | undefined} containerCliRaw docker/podman для подписи доната RAM */ function renderAggregateDonutsToRoot(root, agg, resourcesError, containerCliRaw) { if (!root) return; root.replaceChildren(); if (resourcesError) { const p = document.createElement("p"); p.className = "muted aggregate-donuts-placeholder"; p.textContent = "Метрики узлов недоступны: " + resourcesError; root.appendChild(p); return; } if (!agg || !agg.nodes_count) { const p2 = document.createElement("p"); p2.className = "muted aggregate-donuts-placeholder"; p2.textContent = "Нет запущенных узлов — диаграммы появятся после старта кластера."; root.appendChild(p2); return; } function donutItem(title, ringPct, centerLabel, subLine) { const p = Math.max(0, Math.min(100, Number(ringPct) || 0)); const wrap = document.createElement("div"); wrap.className = "donut-item"; wrap.setAttribute("role", "group"); wrap.setAttribute("aria-label", title); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 110 110"); svg.setAttribute("class", "donut-svg"); svg.setAttribute("aria-hidden", "true"); const track = document.createElementNS("http://www.w3.org/2000/svg", "circle"); track.setAttribute("class", "donut-track"); track.setAttribute("cx", "55"); track.setAttribute("cy", "55"); track.setAttribute("r", "40"); track.setAttribute("fill", "none"); track.setAttribute("stroke-width", "9"); track.setAttribute("pathLength", "100"); const fill = document.createElementNS("http://www.w3.org/2000/svg", "circle"); fill.setAttribute("class", "donut-fill"); fill.setAttribute("cx", "55"); fill.setAttribute("cy", "55"); fill.setAttribute("r", "40"); fill.setAttribute("fill", "none"); fill.setAttribute("stroke-width", "9"); fill.setAttribute("pathLength", "100"); fill.setAttribute("stroke-dasharray", p + " " + (100 - p)); fill.setAttribute("transform", "rotate(-90 55 55)"); svg.appendChild(track); svg.appendChild(fill); wrap.appendChild(svg); const meta = document.createElement("div"); meta.className = "donut-meta"; const tEl = document.createElement("span"); tEl.className = "donut-meta__title"; tEl.textContent = title; meta.appendChild(tEl); const vEl = document.createElement("span"); vEl.className = "donut-meta__val"; vEl.textContent = centerLabel || "—"; meta.appendChild(vEl); if (subLine) { const sEl = document.createElement("span"); sEl.className = "donut-meta__sub muted"; sEl.textContent = subLine; meta.appendChild(sEl); } wrap.appendChild(meta); return wrap; } const ramEngine = titleCaseContainerCli(containerCliRaw); const grid = document.createElement("div"); grid.className = "donut-grid"; grid.appendChild( donutItem("CPU (ср.)", agg.cpu_ring, String(agg.cpu_label || "—"), "узлов: " + agg.nodes_count), ); grid.appendChild( donutItem("% RAM (" + ramEngine + ")", agg.memory_percent_ring, String(agg.memory_percent_label || "—"), ""), ); grid.appendChild( donutItem("RAM used/limit", agg.memory_used_ratio_ring, String(agg.memory_used_ratio_label || "—"), ""), ); grid.appendChild( donutItem("Сеть (всего)", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"), ); grid.appendChild( donutItem("Диск (всего)", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"), ); root.appendChild(grid); } /** * SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»). */ function renderAggregateDonuts(agg, resourcesError, containerCliRaw) { renderAggregateDonutsToRoot( document.getElementById("aggregate-donuts"), agg, resourcesError, containerCliRaw, ); } /** * Доля из строки вида «12.3%» для мини-полосы (0–100). * @param {unknown} value * @returns {number | null} */ function parseLoosePercent(value) { if (value == null || value === "") return null; const m = String(value).match(/([\d.,]+)\s*%/); if (!m) return null; const x = parseFloat(m[1].replace(",", ".")); if (Number.isNaN(x)) return null; return Math.max(0, Math.min(100, x)); } /** Множители суффиксов Docker/Podman (как в ``container_resource_stats.py``). */ var DOCKER_SUFFIX_TO_BYTES = { b: 1, kb: 1e3, kib: 1024, mb: 1e6, mib: Math.pow(1024, 2), gb: 1e9, gib: Math.pow(1024, 3), tb: 1e12, tib: Math.pow(1024, 4), pb: 1e15, pib: Math.pow(1024, 5), }; /** * Разбор одного значения вроде ``512MiB``, ``800kB`` в байты. * @param {string} chunk * @returns {number | null} */ function parseDockerSizeBytes(chunk) { const t = String(chunk || "") .trim() .toLowerCase() .replace(/\s/g, ""); if (!t) return null; const m = t.match(/^([\d.]+)([a-z]+)$/); if (!m) return null; const mult = DOCKER_SUFFIX_TO_BYTES[m[2]]; if (mult == null) return null; const v = parseFloat(m[1]); if (Number.isNaN(v)) return null; return v * mult; } /** * Сумма байт по NetIO/BlockIO (чтение + запись, обе части до ``/``). * @param {string | null | undefined} raw * @returns {number | null} */ function parseIoPairSumBytes(raw) { if (raw == null || String(raw).indexOf("/") < 0) return null; const parts = String(raw).split("/"); const ba = parseDockerSizeBytes(parts[0]); const bb = parseDockerSizeBytes(parts[1]); if (ba == null && bb == null) return null; return (ba || 0) + (bb || 0); } /** * Процент занятой памяти: левая часть MemUsage / правая (лимит контейнера). * @param {string | null | undefined} memoryUsage * @returns {number | null} */ function memoryUsedRatioPercent(memoryUsage) { if (memoryUsage == null || String(memoryUsage).indexOf("/") < 0) return null; const pair = String(memoryUsage).split("/"); const used = parseDockerSizeBytes(pair[0]); const limit = parseDockerSizeBytes(pair[1]); if (used == null || limit == null || limit <= 0) return null; return Math.min(100, (100 * used) / limit); } /** * Карточка одного узла: все метрики ``docker stats`` полосами, как CPU/RAM. * Сеть и диск нормализуются к максимуму среди узлов этого кластера (сравнение нагрузки). * @param {object} n KindNodeResourceStat * @param {{ netPct: number, blkPct: number }} rel */ function buildClusterResourceNodeCard(n, rel) { const card = document.createElement("article"); card.className = "cluster-node-res-card"; const head = document.createElement("div"); head.className = "cluster-node-res-card__head"; const code = document.createElement("code"); code.textContent = n.container_name || "—"; head.appendChild(code); card.appendChild(head); const bars = document.createElement("div"); bars.className = "cluster-node-res-card__bars"; /** * @param {string} label * @param {number | null | undefined} pct 0–100 * @param {string} [hint] краткое значение справа * @param {string} [rowTitle] подсказка при наведении на строку */ function addBar(label, pct, hint, rowTitle) { const row = document.createElement("div"); row.className = "cluster-node-res-card__bar-row"; if (rowTitle) row.title = rowTitle; const lab = document.createElement("span"); lab.className = "cluster-node-res-card__bar-label"; lab.textContent = label; const track = document.createElement("div"); track.className = "cluster-node-res-card__bar-track"; const fill = document.createElement("div"); fill.className = "cluster-node-res-card__bar-fill"; const p = pct != null && !Number.isNaN(pct) ? pct : 0; fill.style.width = Math.min(100, Math.max(0, p)) + "%"; track.appendChild(fill); row.appendChild(lab); row.appendChild(track); const hi = document.createElement("span"); hi.className = "cluster-node-res-card__bar-hint muted"; hi.textContent = hint != null && hint !== "" ? hint : "—"; row.appendChild(hi); bars.appendChild(row); } const cpuP = parseLoosePercent(n.cpu_percent); addBar("CPU", cpuP, n.cpu_percent || "—", "Загрузка CPU (docker stats CPUPerc)"); const ramP = parseLoosePercent(n.memory_percent); addBar("RAM %", ramP, n.memory_percent || "—", "Процент памяти от лимита контейнера (MemPerc)"); const memRatio = memoryUsedRatioPercent(n.memory_usage); addBar( "Память", memRatio, n.memory_usage || "—", "Использовано / лимит контейнера (MemUsage): доля в процентах", ); addBar( "Сеть", rel.netPct, n.net_io || "—", "Суммарный сетевой I/O (вход+выход); полоса относительно максимума среди узлов кластера", ); addBar( "Диск", rel.blkPct, n.block_io || "—", "Суммарный блочный I/O; полоса относительно максимума среди узлов кластера", ); card.appendChild(bars); return card; } /** * Карточки узлов в контейнер (страница кластера), без ``
``. * @param {HTMLElement | null} parent * @param {object | null | undefined} cr поле ``cluster_resources`` из overview */ function appendClusterNodeCardsRow(parent, cr) { if (!parent) return; parent.replaceChildren(); if (!cr || !(cr.nodes && cr.nodes.length)) { const p = document.createElement("p"); p.className = "muted cluster-nodes-resources__empty"; p.textContent = cr && cr.note ? cr.note : "Нет данных: узлы остановлены или метрики недоступны."; parent.appendChild(p); return; } const nodes = cr.nodes; const netBytes = nodes.map(function (node) { return parseIoPairSumBytes(node.net_io) || 0; }); const blkBytes = nodes.map(function (node) { return parseIoPairSumBytes(node.block_io) || 0; }); const maxNet = Math.max(1, Math.max.apply(null, netBytes)); const maxBlk = Math.max(1, Math.max.apply(null, blkBytes)); nodes.forEach(function (n, i) { parent.appendChild( buildClusterResourceNodeCard(n, { netPct: (100 * netBytes[i]) / maxNet, blkPct: (100 * blkBytes[i]) / maxBlk, }), ); }); } /** * Карточка мета справа: kind, kubeconfig, состояние, версия образа, workers (сетка плиток). * @param {HTMLElement | null} el * @param {object} d ответ ``GET /clusters/{name}/overview`` */ function paintClusterDetailMeta(el, d) { if (!el) return; el.replaceChildren(); const card = document.createElement("div"); card.className = "cluster-detail-meta-card"; const grid = document.createElement("div"); grid.className = "cluster-detail-meta-grid"; /** * Одна плитка: подпись сверху, значение снизу. * @param {string} label * @param {HTMLElement} valueEl * @param {boolean} [fullRow] на всю ширину сетки (последняя плитка при нечётном числе) */ function addTile(label, valueEl, fullRow) { const tile = document.createElement("div"); tile.className = "cluster-meta-tile" + (fullRow ? " cluster-meta-tile--wide" : ""); const lab = document.createElement("span"); lab.className = "cluster-meta-tile__label muted"; lab.textContent = label; const val = document.createElement("div"); val.className = "cluster-meta-tile__value"; val.appendChild(valueEl); tile.appendChild(lab); tile.appendChild(val); grid.appendChild(tile); } function badgeYesNo(yes) { const s = document.createElement("span"); s.className = yes ? "badge badge-ok cluster-meta-badge" : "badge cluster-meta-badge"; s.textContent = yes ? "да" : "нет"; return s; } addTile("kind", badgeYesNo(!!d.registered_in_kind)); addTile("kubeconfig", badgeYesNo(!!d.has_local_kubeconfig)); const stateBadge = document.createElement("span"); stateBadge.className = "badge cluster-meta-badge"; if (d.registered_in_kind) { if (d.kind_nodes_running) { stateBadge.classList.add("badge-ok"); stateBadge.textContent = "Запущен"; stateBadge.title = "Контейнеры узлов запущены"; } else { stateBadge.classList.add("badge-stopped"); stateBadge.textContent = "Остановлен"; stateBadge.title = "Зарегистрирован в kind, узлы остановлены"; } } else { stateBadge.classList.add("cluster-meta-badge--muted"); stateBadge.textContent = "—"; stateBadge.title = "Нет в kind — только локальный каталог"; } addTile("Состояние", stateBadge); const ver = (d.meta && (d.meta.kubernetes_version_tag || d.meta.node_image)) || "—"; const codeVer = document.createElement("code"); codeVer.className = "cluster-meta-version-tag"; codeVer.textContent = String(ver); addTile("Версия образа", codeVer); const workersEl = document.createElement("span"); workersEl.className = "cluster-meta-workers"; workersEl.textContent = d.meta && d.meta.worker_nodes != null ? String(d.meta.worker_nodes) : "—"; addTile("Workers", workersEl, false); card.appendChild(grid); el.appendChild(card); } /** * @param {HTMLElement} root * @param {string} text */ function clusterK8sShowMessage(root, text) { if (!root) return; root.replaceChildren(); const p = document.createElement("p"); p.className = "cluster-k8s-msg muted"; p.textContent = text; root.appendChild(p); } /** * Ячейка: текст (через textContent) или готовый DOM-элемент (кнопка). * @param {HTMLElement} root * @param {string[]} headers * @param {Array>} rows */ function clusterK8sBuildTable(root, headers, rows) { if (!root) return; root.replaceChildren(); if (!rows.length) { clusterK8sShowMessage(root, "Нет объектов."); return; } const wrap = document.createElement("div"); wrap.className = "cluster-k8s-table-scroll"; const table = document.createElement("table"); table.className = "cluster-k8s-table"; const thead = document.createElement("thead"); const trh = document.createElement("tr"); headers.forEach(function (h) { const th = document.createElement("th"); th.textContent = h; trh.appendChild(th); }); thead.appendChild(trh); table.appendChild(thead); const tbody = document.createElement("tbody"); rows.forEach(function (cells) { const tr = document.createElement("tr"); cells.forEach(function (cell) { const td = document.createElement("td"); if (cell != null && typeof cell === "object" && cell.nodeType === 1) { td.appendChild(cell); } else { td.textContent = cell == null ? "" : String(cell); } tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(tbody); wrap.appendChild(table); root.appendChild(wrap); } /** * @param {object | null | undefined} block поле k8s_* из overview * @param {string | null} kubeErr * @param {HTMLElement | null} root * @param {function(HTMLElement, object[]): void} renderRows */ function clusterK8sRenderBlock(block, kubeErr, root, renderRows) { if (!root) return; if (kubeErr) { clusterK8sShowMessage(root, kubeErr); return; } if (!block || !block.ok) { clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны."); return; } const items = block.items || []; renderRows(root, items); } /** * Блоки K8s на странице кластера с фильтром по namespace (ключ → поле overview → id корня таблицы). * Автор: Сергей Антропов — https://devops.org.ru */ var CLUSTER_K8S_NS_BLOCKS = [ { key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" }, { key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" }, { key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" }, { key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" }, { key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" }, { key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" }, { key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" }, { key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" }, { key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" }, { key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" }, ]; /** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */ var lastClusterK8sOverview = null; /** Однократная привязка делегирования change на фильтры namespace. */ var clusterNsFiltersBound = false; /** * Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview). * @param {object|undefined|null} block * @returns {object[]} */ function k8sBlockItemsArray(block) { if (!block || typeof block !== "object") return []; var items = block.items; return Array.isArray(items) ? items : []; } /** * Отбор объектов по выбранному namespace (пустая строка — все). * @param {object[]|undefined} items * @param {string} ns * @returns {object[]} */ function k8sFilterItemsByNamespace(items, ns) { if (!ns) return items || []; return (items || []).filter(function (it) { return ((it.metadata && it.metadata.namespace) || "") === ns; }); } /** * Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта. * Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк). * @param {object[]|undefined} items * @returns {object[]} */ function k8sSortNamespacedItems(items) { if (!items || !items.length) return items || []; return items.slice().sort(function (a, b) { var nsA = (a.metadata && a.metadata.namespace) || ""; var nsB = (b.metadata && b.metadata.namespace) || ""; var c = String(nsA).localeCompare(String(nsB), "ru"); if (c !== 0) return c; var nA = (a.metadata && a.metadata.name) || ""; var nB = (b.metadata && b.metadata.name) || ""; return String(nA).localeCompare(String(nB), "ru"); }); } /** * Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace`` * из таблиц ресурсов (pods/deployments/…); без дублей, сортировка по ru. * Автор: Сергей Антропов — https://devops.org.ru * @param {object} d ответ cluster overview * @returns {string[]} */ function k8sNamespaceNamesFromOverview(d) { var seen = Object.create(null); var ordered = []; function addName(raw) { if (raw == null) return; var s = String(raw).trim(); if (!s || seen[s]) return; seen[s] = true; ordered.push(s); } var nsBlock = d.k8s_namespaces; if (nsBlock && nsBlock.ok) { k8sBlockItemsArray(nsBlock).forEach(function (it) { var meta = it && it.metadata; addName(meta && meta.name); }); } for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) { var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop]; if (!blk || !blk.ok) continue; k8sBlockItemsArray(blk).forEach(function (it) { var meta = it && it.metadata; addName(meta && meta.namespace); }); } ordered.sort(function (a, b) { return a.localeCompare(b, "ru"); }); return ordered; } /** * Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-. * @param {object | null | undefined} block * @param {string | null} kubeErr * @param {HTMLElement | null} root * @param {string} filterKey * @param {function(HTMLElement, object[]): void} renderFn */ function clusterK8sRenderBlockNs(block, kubeErr, root, filterKey, renderFn) { if (!root) return; var sel = document.getElementById("cluster-k8s-filter-" + filterKey); var ns = sel && sel.value ? sel.value : ""; if (kubeErr) { clusterK8sShowMessage(root, kubeErr); return; } if (!block || !block.ok) { clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны."); return; } var items = k8sFilterItemsByNamespace(k8sBlockItemsArray(block), ns); items = k8sSortNamespacedItems(items); renderFn(root, items); } /** * Заполняет option во всех селектах фильтра; сохраняет прежнее value, если namespace ещё в списке. * @param {string[]} namespaceNames */ function clusterK8sPopulateNsSelects(namespaceNames) { CLUSTER_K8S_NS_BLOCKS.forEach(function (row) { var sel = document.getElementById("cluster-k8s-filter-" + row.key); if (!sel) return; var prev = (sel.value || "").trim(); sel.replaceChildren(); var o0 = document.createElement("option"); o0.value = ""; o0.textContent = "Все namespace"; sel.appendChild(o0); namespaceNames.forEach(function (name) { var o = document.createElement("option"); o.value = name; o.textContent = name; sel.appendChild(o); }); if (prev && namespaceNames.indexOf(prev) >= 0) sel.value = prev; else sel.value = ""; }); } function k8sNodeReady(item) { const cond = (item.status && item.status.conditions) || []; for (var i = 0; i < cond.length; i++) { if (cond[i].type === "Ready") return cond[i].status === "True" ? "True" : "False"; } return "—"; } function k8sNodeRoles(item) { const labels = (item.metadata && item.metadata.labels) || {}; if (labels["node-role.kubernetes.io/control-plane"] || labels["node-role.kubernetes.io/master"]) return "control-plane"; return "worker"; } function k8sNodeInternalIP(item) { const addrs = (item.status && item.status.addresses) || []; for (var i = 0; i < addrs.length; i++) { if (addrs[i].type === "InternalIP") return addrs[i].address || "—"; } return "—"; } function renderK8sNodesTable(root, items) { const rows = items.map(function (it) { const name = (it.metadata && it.metadata.name) || "—"; const ver = (it.status && it.status.nodeInfo && it.status.nodeInfo.kubeletVersion) || "—"; return [name, k8sNodeReady(it), k8sNodeRoles(it), String(ver), k8sNodeInternalIP(it)]; }); clusterK8sBuildTable(root, ["Имя", "Ready", "Роль", "Версия", "Internal IP"], rows); } function k8sPodReadySummary(item) { const cst = (item.status && item.status.containerStatuses) || []; if (!cst.length) return "—"; var ready = 0; cst.forEach(function (c) { if (c.ready) ready++; }); return String(ready) + "/" + String(cst.length); } /** * Рестарт пода: DELETE на API; контроллер пересоздаёт Pod. * @param {string} clusterName * @param {string} namespace * @param {string} podName */ async function restartClusterPod(clusterName, namespace, podName) { const ok = await openConfirmModal({ title: "Перезапустить под?", message: "Namespace «" + namespace + "», под «" + podName + "»: под будет удалён; при наличии контроллера (Deployment и т.д.) будет создан новый экземпляр.", confirmLabel: "Рестарт", danger: false, }); if (!ok) return; try { await api("/clusters/" + encodeURIComponent(clusterName) + "/pods/restart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ namespace: namespace, pod: podName }), }); showToast("Под отправлен на пересоздание", false); await loadClusterOverview(clusterName); } catch (e) { showToast(e.message || "Ошибка рестарта", true); } } function renderK8sPodsTable(root, items, clusterName) { const tip = "Перезапуск пода: удаление; при наличии контроллера (Deployment и т.д.) будет создан новый Pod."; const rows = items.map(function (it) { const meta = it.metadata || {}; const ns = meta.namespace || "—"; const name = meta.name || "—"; const phase = (it.status && it.status.phase) || "—"; const node = (it.spec && it.spec.nodeName) || "—"; const host = document.createElement("span"); host.className = "icon-tooltip-host"; host.setAttribute("data-tooltip", tip); const btn = document.createElement("button"); btn.type = "button"; btn.className = "icon-btn icon-btn--secondary btn-k8s-restart--solo"; btn.innerHTML = ICONS.restart; btn.setAttribute("aria-label", tip); btn.addEventListener("click", function () { restartClusterPod(clusterName, ns, name); }); host.appendChild(btn); return [ns, name, k8sPodReadySummary(it), String(phase), String(node), host]; }); clusterK8sBuildTable(root, ["Namespace", "Под", "Ready", "Фаза", "Узел", ""], rows); bindActionTooltipHosts(root); } function renderK8sNamespacesTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const st = it.status || {}; return [meta.name || "—", String(st.phase || "—")]; }); clusterK8sBuildTable(root, ["Имя", "Фаза"], rows); } function renderK8sReplicaSetsTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const st = it.status || {}; const spec = it.spec || {}; const want = spec.replicas != null ? spec.replicas : "—"; const cur = st.replicas != null ? st.replicas : 0; const ready = st.readyReplicas != null ? st.readyReplicas : 0; return [ meta.namespace || "—", meta.name || "—", String(ready) + " / " + String(cur) + " / " + String(want), ]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready / Текущие / Задано"], rows); } function renderK8sJobsTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const st = it.status || {}; const spec = it.spec || {}; const want = spec.completions != null ? spec.completions : "—"; const succ = st.succeeded != null ? st.succeeded : 0; const act = st.active != null ? st.active : 0; const fail = st.failed != null ? st.failed : 0; return [ meta.namespace || "—", meta.name || "—", String(succ) + " / " + String(want), "активн.:" + act + ", неудач:" + fail, ]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Успешно / всего", "Активные / неудач"], rows); } function renderK8sCronJobsTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const spec = it.spec || {}; const susp = spec.suspend ? "да" : "нет"; return [meta.namespace || "—", meta.name || "—", String(spec.schedule || "—"), susp]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Schedule", "Suspend"], rows); } function renderK8sPvcsTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const st = it.status || {}; const spec = it.spec || {}; const req = spec.resources && spec.resources.requests && spec.resources.requests.storage; const cap = st.capacity && st.capacity.storage; const size = cap || req || "—"; return [meta.namespace || "—", meta.name || "—", String(st.phase || "—"), String(size)]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Фаза", "Размер"], rows); } function renderK8sDeploymentsTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const st = it.status || {}; const spec = it.spec || {}; const want = spec.replicas != null ? spec.replicas : "—"; const ready = st.readyReplicas != null ? st.readyReplicas : 0; return [ meta.namespace || "—", meta.name || "—", String(ready) + " / " + String(want), String(st.updatedReplicas != null ? st.updatedReplicas : "—"), ]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready", "Up-to-date"], rows); } function renderK8sStsDsTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const st = it.status || {}; const spec = it.spec || {}; const want = spec.replicas != null ? spec.replicas : "—"; const ready = st.readyReplicas != null ? st.readyReplicas : 0; return [meta.namespace || "—", meta.name || "—", String(ready) + " / " + String(want)]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready"], rows); } function renderK8sServicesTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const spec = it.spec || {}; const st = spec.type || "—"; const ports = (spec.ports || []) .map(function (p) { return (p.port || "") + (p.protocol ? "/" + p.protocol : ""); }) .join(", "); return [meta.namespace || "—", meta.name || "—", String(st), ports || "—"]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Тип", "Порты"], rows); } function renderK8sIngressesTable(root, items) { const rows = items.map(function (it) { const meta = it.metadata || {}; const rules = (it.spec && it.spec.rules) || []; const hosts = rules .map(function (r) { return r.host || ""; }) .filter(Boolean) .join(", "); return [meta.namespace || "—", meta.name || "—", hosts || "—"]; }); clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows); } /** * Фабрика функций рендера по ключу фильтра (для подов нужен clusterName). * @param {string} cname * @returns {Record} */ function buildClusterK8sNsFilterRenderMap(cname) { return { pods: function (root, items) { renderK8sPodsTable(root, items, cname); }, deployments: renderK8sDeploymentsTable, statefulsets: renderK8sStsDsTable, daemonsets: renderK8sStsDsTable, replicasets: renderK8sReplicaSetsTable, jobs: renderK8sJobsTable, cronjobs: renderK8sCronJobsTable, services: renderK8sServicesTable, ingresses: renderK8sIngressesTable, pvcs: renderK8sPvcsTable, }; } /** * Перерисовка одной таблицы после смены select (без нового API-запроса). * @param {string} key ключ data-cluster-k8s-ns-filter */ function refillClusterK8sNsFilter(key) { if (!lastClusterK8sOverview) return; var d = lastClusterK8sOverview; var kubeErr = d.kubeconfig_error || null; var cname = d.cluster_name || getClusterPageName() || ""; var renderByKey = buildClusterK8sNsFilterRenderMap(cname); var renderFn = renderByKey[key]; if (!renderFn) return; for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) { if (CLUSTER_K8S_NS_BLOCKS[i].key !== key) continue; var row = CLUSTER_K8S_NS_BLOCKS[i]; clusterK8sRenderBlockNs(d[row.prop], kubeErr, document.getElementById(row.rootId), key, renderFn); return; } } /** Делегирование: один listener на document для всех .cluster-k8s-ns-select. */ function ensureClusterNsFilterListeners() { if (clusterNsFiltersBound) return; document.addEventListener("change", function (ev) { var t = ev.target; if (!t || !t.matches || !t.matches("select.cluster-k8s-ns-select[data-cluster-k8s-ns-filter]")) return; var key = t.getAttribute("data-cluster-k8s-ns-filter"); if (key) refillClusterK8sNsFilter(key); }); clusterNsFiltersBound = true; } /** * Таблицы Kubernetes из полей ``k8s_*`` ответа overview. * @param {object} d */ function fillClusterK8sTablesFromOverview(d) { lastClusterK8sOverview = d; const kubeErr = d.kubeconfig_error || null; const cname = d.cluster_name || getClusterPageName() || ""; ensureClusterNsFilterListeners(); clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable); clusterK8sRenderBlock( d.k8s_namespaces, kubeErr, document.getElementById("cluster-k8s-namespaces-root"), renderK8sNamespacesTable, ); const renderByKey = buildClusterK8sNsFilterRenderMap(cname); CLUSTER_K8S_NS_BLOCKS.forEach(function (row) { clusterK8sRenderBlockNs( d[row.prop], kubeErr, document.getElementById(row.rootId), row.key, renderByKey[row.key], ); }); /* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */ clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d)); } /** * Кнопки действий над кластером — те же, что в таблице на главной. * @param {HTMLElement} host * @param {{ name: string, registered_in_kind: boolean, kind_nodes_running: boolean, has_local_kubeconfig: boolean, has_provision_log?: boolean }} c */ function appendClusterActionButtons(host, c) { host.replaceChildren(); const name = c.name; host.appendChild( iconActionButton( ICONS.state, "Состояние: узлы и поды", "", function () { openWorkloadsModal(name); }, false, ), ); host.appendChild( iconActionButton( ICONS.edit, "Редактировать kind-config.yaml и meta.json", "icon-btn--secondary", function () { window.location.href = "/cluster/" + encodeURIComponent(name) + "/edit"; }, false, ), ); if (c.registered_in_kind && c.kind_nodes_running) { host.appendChild( iconActionButton( ICONS.stop, "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется", "icon-btn--secondary", function () { stopCluster(name); }, false, ), ); } else { host.appendChild( iconActionButton( ICONS.play, "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне", "", function () { startCluster(name); }, false, ), ); } host.appendChild( iconActionButton( ICONS.download, c.has_local_kubeconfig ? "Скачать kubeconfig для kubectl на хосте (localhost:порт)" : "Kubeconfig ещё нет — появится после создания или подъёма кластера", "icon-btn--secondary", c.has_local_kubeconfig ? function () { downloadKubeconfig(name); } : null, !c.has_local_kubeconfig, ), ); host.appendChild( iconActionButton( ICONS.kubeDocker, c.has_local_kubeconfig ? "Скачать kubeconfig для kubectl из другого контейнера: host.docker.internal:порт + tls-server-name (как в веб-UI; на Linux — extra_hosts host-gateway)" : "Kubeconfig ещё нет — появится после создания или подъёма кластера", "icon-btn--secondary", c.has_local_kubeconfig ? function () { downloadKubeconfigDocker(name); } : null, !c.has_local_kubeconfig, ), ); if (c.has_provision_log) { host.appendChild( iconActionButton( ICONS.logFile, "Журнал развёртывания: полный JSON последнего создания или старта кластера", "icon-btn--secondary", function () { openProvisionLogModal(name); }, false, ), ); } host.appendChild( iconActionButton( ICONS.trash, "Удалить кластер (kind delete) и каталог clusters/<имя>/", "icon-btn--danger", function () { deleteCluster(name); }, false, ), ); bindActionTooltipHosts(host); } /** * Скрыть секцию «Установленные аддоны» (#cluster-detail-addons-section) и очистить сетку. */ function clearClusterDetailAddonsStrip() { var strip = document.getElementById("cluster-detail-addons-strip"); var section = document.getElementById("cluster-detail-addons-section"); if (strip) strip.replaceChildren(); if (section) { section.classList.add("hidden"); section.setAttribute("hidden", ""); section.setAttribute("aria-hidden", "true"); } } /** * Мини-карточки установленных Helm-аддонов в отдельной section.card после «Ресурсы узлов». * @param {string} clusterName * @param {object} st ответ GET /clusters/…/addons/status */ function renderClusterDetailAddonsStrip(clusterName, st) { var strip = document.getElementById("cluster-detail-addons-strip"); var section = document.getElementById("cluster-detail-addons-section"); if (!strip || !section || !st) { clearClusterDetailAddonsStrip(); return; } var items = []; if (st.ingress_nginx) { items.push({ title: "ingress-nginx", ver: st.ingress_nginx_chart_version, ns: "ingress-nginx", }); } if (st.kube_prometheus_stack) { items.push({ title: "kube-prometheus-stack", ver: st.kube_prometheus_stack_chart_version, ns: "monitoring", }); } if (st.metrics_server) { items.push({ title: "metrics-server", ver: st.metrics_server_chart_version, ns: "kube-system", }); } if (st.istio_mesh_ready) { var iv = st.istiod_chart_version || st.istio_base_chart_version; var kv = st.kiali_server_chart_version; var parts = []; if (iv) parts.push("Istio " + iv); if (kv) parts.push("Kiali " + kv); items.push({ title: "Istio + Kiali", verLine: parts.length ? parts.join(" · ") : null, ns: "istio-system", }); } strip.replaceChildren(); if (!items.length) { section.classList.add("hidden"); section.setAttribute("hidden", ""); section.setAttribute("aria-hidden", "true"); return; } section.classList.remove("hidden"); section.removeAttribute("hidden"); section.setAttribute("aria-hidden", "false"); items.forEach(function (it) { var a = document.createElement("a"); a.className = "cluster-detail-addon-mini"; a.href = "/cluster-addons"; a.setAttribute("role", "listitem"); a.setAttribute( "title", "Открыть страницу аддонов Helm; выберите кластер «" + clusterName + "» для настройки.", ); var t = document.createElement("span"); t.className = "cluster-detail-addon-mini__title"; t.textContent = it.title; a.appendChild(t); var v = document.createElement("span"); v.className = "cluster-detail-addon-mini__ver"; if (it.verLine) { v.textContent = it.verLine; } else if (it.ver) { v.textContent = "Чарт " + it.ver; } else { v.textContent = "Версия чарта не определена"; } a.appendChild(v); var n = document.createElement("span"); n.className = "cluster-detail-addon-mini__ns"; n.textContent = "ns: " + it.ns; a.appendChild(n); strip.appendChild(a); }); } /** * Загрузить статус аддонов и обновить карточку «Установленные аддоны» (при наличии kubeconfig на диске). * @param {string} clusterName * @param {boolean} hasLocalKubeconfig */ async function refreshClusterDetailAddonsStrip(clusterName, hasLocalKubeconfig) { if (!clusterName || !hasLocalKubeconfig) { clearClusterDetailAddonsStrip(); return; } try { var st = await api("/clusters/" + encodeURIComponent(clusterName) + "/addons/status"); renderClusterDetailAddonsStrip(clusterName, st); } catch (e) { clearClusterDetailAddonsStrip(); } } /** * Загрузка ``GET /clusters/{name}/overview`` и заполнение блоков страницы кластера. * @param {string} name */ async function loadClusterOverview(name) { const msg = document.getElementById("cluster-overview-msg"); const hint = document.getElementById("cluster-kube-global-hint"); try { const d = await api("/clusters/" + encodeURIComponent(name) + "/overview"); if (msg) msg.textContent = ""; if (d.cluster_resources && d.cluster_resources.cluster_name) { cachedClusterResourcesByName[d.cluster_resources.cluster_name] = d.cluster_resources; } paintClusterDetailMeta(document.getElementById("cluster-detail-meta"), d); appendClusterActionButtons(document.getElementById("cluster-detail-toolbar"), { name: d.cluster_name, registered_in_kind: !!d.registered_in_kind, kind_nodes_running: !!d.kind_nodes_running, has_local_kubeconfig: !!d.has_local_kubeconfig, has_provision_log: !!d.has_provision_log, }); await refreshClusterDetailAddonsStrip(d.cluster_name, !!d.has_local_kubeconfig); const cliRaw = (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker"; renderAggregateDonutsToRoot( document.getElementById("cluster-page-aggregate-donuts"), d.aggregate_resources, d.resources_error, cliRaw, ); appendClusterNodeCardsRow(document.getElementById("cluster-page-node-cards"), d.cluster_resources); const kubeErr = d.kubeconfig_error || null; const clusterStoppedInKind = !!d.registered_in_kind && !d.kind_nodes_running; if (hint) { if (kubeErr) { hint.textContent = kubeErr; hint.classList.remove("hidden"); if (clusterStoppedInKind) { hint.classList.add("cluster-kube-hint--info"); } else { hint.classList.remove("cluster-kube-hint--info"); } } else { hint.textContent = ""; hint.classList.add("hidden"); hint.classList.remove("cluster-kube-hint--info"); } } if (msg && clusterStoppedInKind) { msg.textContent = "Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели)."; } fillClusterK8sTablesFromOverview(d); } catch (e) { const errText = e && e.message ? e.message : String(e); clearClusterDetailAddonsStrip(); if (msg) { msg.textContent = "Кластер или API недоступны: " + errText + ". Проверьте сеть, имя кластера и что узлы kind запущены."; } if (hint) { hint.textContent = ""; hint.classList.add("hidden"); } [ "cluster-k8s-nodes-root", "cluster-k8s-namespaces-root", "cluster-k8s-pods-root", "cluster-k8s-deployments-root", "cluster-k8s-statefulsets-root", "cluster-k8s-daemonsets-root", "cluster-k8s-replicasets-root", "cluster-k8s-jobs-root", "cluster-k8s-cronjobs-root", "cluster-k8s-services-root", "cluster-k8s-ingresses-root", "cluster-k8s-pvcs-root", ].forEach(function (id) { const el = document.getElementById(id); if (el) { clusterK8sShowMessage( el, "Данные Kubernetes недоступны (ошибка загрузки сводки). " + errText, ); } }); } finally { hideClusterPageLoadingOverlay(); } } /** После старт/стоп/создания — обновить страницу кластера, если открыта та же запись. */ async function refreshClusterPageIfNeeded(affectedClusterName) { if (!isClusterPage() || !affectedClusterName) return; const n = getClusterPageName(); if (n && n === affectedClusterName) { await loadClusterOverview(n); } } async function loadStats() { const grid = document.getElementById("stats-grid"); const donutsRoot = document.getElementById("aggregate-donuts"); const needGrid = !!grid; const needDonuts = !!donutsRoot; if (!needGrid && !needDonuts) return; try { const s = await api("/stats"); if (needGrid) { revealStatsCardBody(); paintStatsEnvRow(); const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker"; updateStatsEngineHint(cliForHint); const dirVal = s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined ? String(s.local_cluster_dirs_count) : "—"; const rows = [ ["Каталогов", dirVal], ["Кластеров в kind", s.kind_clusters_count], ["Сумма workers (meta)", s.total_workers_from_meta ?? 0], ["Заданий в памяти", s.jobs_total], ["Заданий с ошибкой", s.jobs_recent_failed], ["Аддонов", s.helm_addons_installable_count ?? 0], ]; grid.replaceChildren(); rows.forEach(function (kv) { const tile = document.createElement("div"); tile.className = "stats-tile"; const valEl = document.createElement("span"); valEl.className = "stats-tile__value"; valEl.textContent = String(kv[1]); const labEl = document.createElement("span"); labEl.className = "stats-tile__label"; labEl.textContent = kv[0]; tile.appendChild(valEl); tile.appendChild(labEl); grid.appendChild(tile); }); } cachedClusterResourcesByName = {}; if (needDonuts) { renderAggregateDonuts( s.aggregate_cluster_resources, s.cluster_resources_error, s.container_cli || "docker", ); } } catch (e) { if (needGrid) { coverStatsCardWithError("Статистика", e.message || String(e)); grid.replaceChildren(); } cachedClusterResourcesByName = {}; const donuts = document.getElementById("aggregate-donuts"); if (donuts) { donuts.replaceChildren(); const ph = document.createElement("p"); ph.className = "muted aggregate-donuts-placeholder"; ph.textContent = "Сводка по узлам недоступна (ошибка загрузки статистики)."; donuts.appendChild(ph); } } } async function loadVersions() { const sel = document.getElementById("version-select"); const verInput = document.getElementById("kubernetes_version"); if (!sel || !verInput) return; cachedKindestTags = null; cachedKindestVersionsLoadError = false; cachedKindestTagsSkipped = false; sel.innerHTML = ""; try { const data = await api("/versions"); cachedKindestTagsSkipped = !!data.skipped; sel.innerHTML = ""; if (!data.tags || !data.tags.length) { cachedKindestTags = []; sel.innerHTML = ""; return; } cachedKindestTags = data.tags.slice(); const opt0 = document.createElement("option"); opt0.value = ""; opt0.textContent = "— выберите тег —"; sel.appendChild(opt0); /* Весь список с бэкенда: первым идёт latest, далее семверы от новых к старым (раньше slice(0,100) отрезал 1.19 и др.) */ data.tags.forEach(function (t) { const o = document.createElement("option"); o.value = t; o.textContent = t === "latest" ? "latest (самый новый образ kindest/node)" : t; sel.appendChild(o); }); sel.onchange = function () { if (!sel.value) return; verInput.value = sel.value === "latest" ? "latest" : sel.value.replace(/^v/, ""); }; } catch (e) { cachedKindestVersionsLoadError = true; cachedKindestTags = null; sel.innerHTML = ""; } } /** * Имя кластера: DNS-подмножество, как на бэкенде (a-z0-9-, не длиннее 63). * @param {string} name */ function isValidClusterName(name) { const s = String(name || "").trim(); if (!s || s.length > 63) return false; return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(s); } /** * Число worker-нод 0…20; иначе null. * @param {unknown} raw * @returns {number | null} */ function parseWorkersBounded(raw) { const n = parseInt(String(raw === undefined || raw === null ? "" : raw), 10); if (Number.isNaN(n) || n < 0 || n > 20) return null; return n; } /** * Нормализация тега kindest/node для сравнения с каталогом (latest или vX.Y.Z). * @param {string} raw */ function normalizeKindestTagForLookup(raw) { let s = String(raw || "").trim().toLowerCase(); if (!s) return ""; if (s === "latest" || s === "vlatest") return "latest"; s = s.replace(/^v/, ""); return "v" + s; } /** * Проверка поля версии перед POST /clusters: каталог доступен и тег в списке. * @param {string} kubernetesVersionRaw * @returns {{ ok: true } | { ok: false, modalTitle: string, modalMessage: string }} */ function validateCreateFormKubernetesVersion(kubernetesVersionRaw) { const normalized = normalizeKindestTagForLookup(kubernetesVersionRaw); if (!normalized) { return { ok: false, modalTitle: "Нет версии Kubernetes", modalMessage: "Заполните поле «Версия Kubernetes / тег node» (например 1.29.4, v1.29.4 или latest) или выберите значение в списке «Тег образа».", }; } if (cachedKindestVersionsLoadError) { return { ok: false, modalTitle: "Каталог тегов недоступен", modalMessage: "Не удалось загрузить список тегов kindest/node с сервера. Проверьте соединение и обновите страницу. Пока каталог не загружен, нельзя убедиться, что образ существует.", }; } if (cachedKindestTags === null) { return { ok: false, modalTitle: "Список тегов загружается", modalMessage: "Подождите, пока в поле «Тег образа» завершится загрузка (не должно оставаться «— загрузка —»), и повторите отправку формы.", }; } if (!cachedKindestTags.length) { const skipHint = cachedKindestTagsSkipped ? " На сервере отключена выдача списка (переменная KIND_K8S_SKIP_VERSION_LIST)." : ""; return { ok: false, modalTitle: "Каталог тегов недоступен", modalMessage: "Приложение не получило ни одного тега kindest/node для проверки." + skipHint + " Создание кластера с непроверенным тегом отключено. Включите загрузку каталога или обратитесь к администратору.", }; } const canonSet = new Set(cachedKindestTags.map(normalizeKindestTagForLookup)); if (canonSet.has(normalized)) { return { ok: true }; } return { ok: false, modalTitle: "Тег не найден в каталоге", modalMessage: "Значение «" + String(kubernetesVersionRaw).trim() + "» отсутствует в списке доступных тегов kindest/node в приложении. Выберите тег из списка «Тег образа» или введите поддерживаемый семвер (см. подпись поля и Docker Hub).", }; } async function loadClusters() { const tbody = document.querySelector("#tbl-clusters tbody"); const msg = document.getElementById("list-msg"); if (!tbody) return; try { const rows = await api("/clusters"); const frag = document.createDocumentFragment(); rows.forEach(function (c) { const tr = document.createElement("tr"); const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—"; const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—"; const nameEsc = escapeHtml(c.name); var stateCell = ""; if (c.registered_in_kind) { if (c.kind_nodes_running) { stateCell = "Запущен"; } else { stateCell = "Остановлен"; } } else { stateCell = ""; } tr.innerHTML = "" + nameEsc + "" + "" + (c.registered_in_kind ? "да" : "нет") + "" + "" + (c.has_local_kubeconfig ? "да" : "нет") + "" + "" + escapeHtml(String(ver)) + "" + "" + escapeHtml(String(wn)) + "" + "" + stateCell + "" + ""; const td = tr.querySelector(".actions-toolbar"); appendClusterActionButtons(td, { name: c.name, registered_in_kind: !!c.registered_in_kind, kind_nodes_running: !!c.kind_nodes_running, has_local_kubeconfig: !!c.has_local_kubeconfig, has_provision_log: !!c.has_provision_log, }); frag.appendChild(tr); }); tbody.replaceChildren(frag); bindActionTooltipHosts(document.getElementById("tbl-clusters")); if (msg) { msg.textContent = rows.length ? "" : "Кластеров пока нет."; } } catch (e) { if (msg) msg.textContent = "Ошибка списка: " + e.message; } } /** * Формат времени как на странице «Журнал» (ISO → «YYYY-MM-DD HH:MM:SS.sss Z»). * @param {string} iso */ function formatJournalUtcDisplay(iso) { if (!iso) return "—"; try { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z"); } catch (e) { return iso; } } /** * Класс плашки статуса записи журнала (success / failed / cancelled). * @param {string} st */ function journalStatusPillClass(st) { const s = (st || "").toLowerCase(); if (s === "success") return "journal-status-pill journal-status-pill--ok"; if (s === "failed") return "journal-status-pill journal-status-pill--err"; if (s === "cancelled") return "journal-status-pill journal-status-pill--warn"; return "journal-status-pill"; } /** * Раскрытие строки лога — как на /journal (режим заданий по кластеру). * Автор: Сергей Антропов — https://devops.org.ru */ function bindCreateJobsJournalToggle() { const tbl = document.getElementById("tbl-jobs"); if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return; tbl.setAttribute("data-create-jobs-log-bound", "1"); tbl.addEventListener( "click", function (ev) { const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null; if (!btn || !tbl.contains(btn) || btn.disabled) return; const tr = btn.closest("tr.journal-data-row"); if (!tr) return; const trDetails = tr.nextElementSibling; if (!trDetails || !trDetails.classList.contains("journal-details-row")) return; const pre = trDetails.querySelector("pre.journal-log-pre"); if (!pre) return; const open = trDetails.classList.contains("hidden"); trDetails.classList.toggle("hidden", !open); pre.hidden = !open; btn.setAttribute("aria-expanded", open ? "true" : "false"); btn.textContent = open ? "Скрыть лог" : "Показать лог"; }, false, ); } /** * Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания * (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе). */ async function loadJobs() { const tbody = document.querySelector("#tbl-jobs tbody"); const msg = document.getElementById("jobs-msg"); if (!tbody) return; try { const data = await api("/journal/recent?limit=30&offset=0"); const entries = Array.isArray(data.entries) ? data.entries : []; const frag = document.createDocumentFragment(); entries.forEach(function (e) { const tr = document.createElement("tr"); tr.className = "journal-data-row"; const cluster = e.source_cluster || e.cluster_name || "—"; const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc); const kind = e.kind || ""; const message = (e.message || "").trim(); const lines = Array.isArray(e.log_lines) ? e.log_lines : []; const logText = lines.join("\n"); const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11); tr.innerHTML = "" + escapeHtml(finished) + "" + "" + escapeHtml(cluster) + "" + "" + escapeHtml(kind) + "" + "" + escapeHtml(e.status || "") + "" + "" + escapeHtml(message || "—") + "" + "" + "" + ""; frag.appendChild(tr); if (logText) { const trDetails = document.createElement("tr"); trDetails.className = "journal-details-row hidden"; trDetails.innerHTML = "" + "" + ""; const pre = trDetails.querySelector("pre"); if (pre) pre.textContent = logText; frag.appendChild(trDetails); } else { const btn = tr.querySelector(".btn-journal-toggle"); if (btn) { btn.disabled = true; btn.textContent = "Нет строк"; } } }); tbody.replaceChildren(frag); if (msg) { if (!entries.length) { msg.textContent = "Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами)."; } else { const total = typeof data.total === "number" ? data.total : entries.length; msg.textContent = total > entries.length ? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах." : ""; } } } catch (e) { if (msg) msg.textContent = "Журнал заданий: " + e.message; } } async function openWorkloadsModal(name) { const overlay = document.getElementById("modal-overlay"); const sub = document.getElementById("modal-sub"); const nodes = document.getElementById("modal-nodes"); const pods = document.getElementById("modal-pods"); const spin = document.getElementById("modal-spinner"); if (!overlay) return; hideActionTooltip(); document.getElementById("modal-title").textContent = "Кластер «" + name + "»"; sub.textContent = ""; nodes.textContent = ""; pods.textContent = ""; if (spin) spin.classList.remove("hidden"); overlay.classList.remove("hidden"); document.body.classList.add("modal-open"); try { const w = await api("/clusters/" + encodeURIComponent(name) + "/workloads"); if (w.error) { sub.textContent = w.error; return; } sub.textContent = "Узлы: код " + w.nodes_rc + " · Поды: код " + w.pods_rc; nodes.textContent = w.nodes_output || "(пусто)"; pods.textContent = w.pods_output || "(пусто)"; } catch (e) { sub.textContent = "Ошибка: " + e.message; } finally { if (spin) spin.classList.add("hidden"); } } /** Колбэк ожидающего Promise от openConfirmModal (одно окно за раз). */ var confirmModalResolver = null; function isConfirmModalOpen() { const el = document.getElementById("confirm-modal-overlay"); return !!(el && !el.classList.contains("hidden")); } /** * Закрыть модалку подтверждения и вернуть результат в Promise. * @param {boolean} confirmed */ function closeConfirmModal(confirmed) { const ov = document.getElementById("confirm-modal-overlay"); const cancelBtn = document.getElementById("confirm-modal-cancel"); if (ov) ov.classList.add("hidden"); document.body.classList.remove("modal-open"); if (cancelBtn) cancelBtn.classList.remove("hidden"); if (confirmModalResolver) { var fn = confirmModalResolver; confirmModalResolver = null; var out = confirmModalIsAlert ? true : !!confirmed; confirmModalIsAlert = false; fn(out); } else { confirmModalIsAlert = false; } } /** * Показать модальное подтверждение (вместо window.confirm). * При opts.alert — одна кнопка «Понятно», клик по фону и Escape закрывают как подтверждение. * * @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: boolean }} opts * @returns {Promise} */ function openConfirmModal(opts) { return new Promise(function (resolve) { const ov = document.getElementById("confirm-modal-overlay"); const titleEl = document.getElementById("confirm-modal-title"); const msgEl = document.getElementById("confirm-modal-message"); const okBtn = document.getElementById("confirm-modal-ok"); const cancelBtn = document.getElementById("confirm-modal-cancel"); if (!ov || !titleEl || !msgEl || !okBtn) { resolve(false); return; } /* Снять предыдущее ожидание без логики «alert = всегда true» (иначе Promise подтверждения ломается). */ if (confirmModalResolver) { const prevFn = confirmModalResolver; confirmModalResolver = null; confirmModalIsAlert = false; prevFn(false); ov.classList.add("hidden"); document.body.classList.remove("modal-open"); if (cancelBtn) cancelBtn.classList.remove("hidden"); } confirmModalResolver = resolve; confirmModalIsAlert = !!opts.alert; titleEl.textContent = opts.title || "Подтвердите действие"; msgEl.textContent = opts.message || ""; okBtn.textContent = opts.confirmLabel || (confirmModalIsAlert ? "Понятно" : "Подтвердить"); okBtn.className = opts.danger ? "btn-danger" : ""; if (cancelBtn) { cancelBtn.classList.toggle("hidden", confirmModalIsAlert); } ov.classList.remove("hidden"); document.body.classList.add("modal-open"); okBtn.focus(); }); } function closeModal() { hideActionTooltip(); const overlay = document.getElementById("modal-overlay"); if (overlay) overlay.classList.add("hidden"); document.body.classList.remove("modal-open"); } async function stopCluster(name) { const ok = await openConfirmModal({ title: "Остановить узлы кластера?", message: "Кластер «" + name + "»: узлы будут остановлены. Сведения о кластере сохранятся — позже можно снова нажать «Старт».", confirmLabel: "Остановить", danger: false, }); if (!ok) return; const url = API + "/clusters/" + encodeURIComponent(name) + "/stop"; try { const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } }); const text = await r.text(); var data = {}; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = {}; } if (r.status === 202 && data.job_id) { setProgressHint(name, "stop"); pollJob(data.job_id, { lockCreateForm: false, lifecycleOverlayLabel: "Останавливаем узлы кластера «" + name + "»…", }); return; } if (!r.ok) { showToast(formatApiError(data, text || r.statusText), true); return; } showToast(String(data.summary || "Готово"), false); await loadClusters(); await loadStats(); await loadHealth(); await refreshClusterPageIfNeeded(name); } catch (e) { showToast(e.message, true); } } /** * Старт: либо docker start узлов (кластер уже в kind), либо фоновый kind create по kind-config.yaml. */ async function startCluster(name) { const url = API + "/clusters/" + encodeURIComponent(name) + "/start"; const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } }); const text = await r.text(); var data = {}; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = {}; } if (r.status === 202 && data.job_id) { var sm = data.mode || "kind_config"; if (sm === "containers") setProgressHint(name, "start_containers"); else if (sm === "kind_config_reapply") setProgressHint(name, "start_reapply"); else setProgressHint(name, "start_config"); var startLab = sm === "containers" ? "Запуск узлов кластера «" + name + "»…" : sm === "kind_config_reapply" ? "Пересоздание кластера «" + name + "» в kind…" : "Подъём кластера «" + name + "» по конфигурации…"; pollJob(data.job_id, { lockCreateForm: false, lifecycleOverlayLabel: startLab }); return; } if (!r.ok) { showToast(formatApiError(data, text || r.statusText), true); return; } showToast(String(data.summary || "Готово"), false); await loadClusters(); await loadStats(); await loadHealth(); await refreshClusterPageIfNeeded(name); } async function deleteCluster(name) { const ok = await openConfirmModal({ title: "Удалить кластер?", message: "Кластер «" + name + "»: будут выполнены kind delete и удаление каталога clusters/" + name + "/ на томе данных. Действие необратимо.", confirmLabel: "Удалить", danger: true, }); if (!ok) return; const msg = document.getElementById("list-msg"); if (msg) msg.textContent = "Удаление…"; try { const res = await api("/clusters/" + encodeURIComponent(name), { method: "DELETE" }); if (msg) msg.textContent = res.summary || "Готово."; showToast("Кластер «" + name + "» удалён", false); if (isClusterPage() && getClusterPageName() === name) { window.location.href = "/"; return; } await loadClusters(); await loadStats(); await loadJobs(); } catch (e) { if (msg) msg.textContent = "Ошибка удаления: " + e.message; showToast(e.message, true); } } function setCreateFormDisabled(disabled) { const form = document.getElementById("form-create"); if (!form) return; const btn = form.querySelector('[type="submit"]'); if (btn) { btn.disabled = disabled; btn.textContent = disabled ? "Создание…" : "Создать кластер"; } form.querySelectorAll("input, select").forEach(function (el) { el.disabled = disabled; }); } function showCreateProgress(show) { const wrap = document.getElementById("create-progress-wrap"); if (!wrap) return; wrap.classList.toggle("hidden", !show); wrap.setAttribute("aria-hidden", show ? "false" : "true"); if (!show) { const logEl = document.getElementById("job-log-panel"); if (logEl) logEl.textContent = ""; } } /** * Подсказка над прогресс-баром (без технических имён команд). * @param {string | null} clusterName * @param {"create"|"start_config"|"start_reapply"|"start_containers"|"stop"} [mode] */ function setProgressHint(clusterName, mode) { const hint = document.getElementById("create-progress-hint"); if (!hint) return; mode = mode || "create"; var named = clusterName ? "«" + clusterName + "»" : ""; if (mode === "stop") { hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже."; } else if (mode === "start_containers") { hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже."; } else if (mode === "start_reapply") { hint.textContent = "Пересоздаём кластер " + named + " в kind по обновлённому kind-config.yaml (сначала удаление записи, затем создание). Журнал ниже."; } else if (mode === "start_config") { hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже."; } else { hint.textContent = "Создаём кластер" + (named ? " " + named : "") + ". Сначала при необходимости скачивается образ, затем создаются узлы — всё в журнале ниже."; } } function updateCreateProgressFromJob(j) { const bar = document.getElementById("create-progress-bar"); const track = document.getElementById("create-progress-track"); const label = document.getElementById("create-progress-label"); var pct = j.progress_percent != null ? Number(j.progress_percent) : 0; if (isNaN(pct)) pct = 0; pct = Math.max(0, Math.min(100, pct)); var stage = j.progress_stage || (j.status === "queued" ? "В очереди…" : "…"); if (bar) bar.style.width = pct + "%"; if (track) { track.setAttribute("aria-valuenow", String(pct)); } if (label) label.textContent = stage; } function stopPollJob() { hideClusterLifecycleJobOverlay(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } currentPollJobId = null; createInProgress = false; if (pollLockCreateForm) { setCreateFormDisabled(false); pollLockCreateForm = false; } showCreateProgress(false); } async function requestCancelJob() { if (!currentPollJobId) return; try { await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" }); showToast("Запрос на прерывание отправлен", false); } catch (e) { showToast(e.message || "Не удалось отменить", true); } } /** * @param {string} jobId * @param {{ lockCreateForm?: boolean, lifecycleOverlayLabel?: string }} [opts] * ``lifecycleOverlayLabel`` — полноэкранный спиннер (старт/стоп с главной или страницы кластера). */ function pollJob(jobId, opts) { opts = opts || {}; pollLockCreateForm = !!opts.lockCreateForm; const details = document.getElementById("job-details"); const msg = document.getElementById("create-msg"); const logEl = document.getElementById("job-log-panel"); if (logEl) logEl.textContent = ""; if (details) { details.classList.remove("hidden"); details.open = false; } if (pollTimer) clearInterval(pollTimer); createInProgress = true; currentPollJobId = jobId; if (pollLockCreateForm) { setCreateFormDisabled(true); } if (opts.lifecycleOverlayLabel) { showClusterLifecycleJobOverlay(opts.lifecycleOverlayLabel); } showCreateProgress(true); updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" }); const tick = async function () { try { const j = await api("/jobs/" + jobId); const preEl = document.getElementById("job-json"); if (preEl) preEl.textContent = JSON.stringify(j, null, 2); updateCreateProgressFromJob(j); if (clusterLifecycleJobOverlayActive) { const lab = document.getElementById("cluster-lifecycle-job-label"); if (lab) { var st = j.progress_stage; if (!st && (j.status === "queued" || j.status === "running")) st = "В очереди…"; if (st) lab.textContent = st; } } if (logEl) { if (j.progress_log && j.progress_log.length) { logEl.removeAttribute("data-placeholder"); logEl.textContent = j.progress_log.join("\n"); logEl.scrollTop = logEl.scrollHeight; } else if (j.status === "running" || j.status === "queued") { logEl.setAttribute("data-placeholder", "1"); logEl.textContent = "Журнал подгружается… Строки скачивания и создания узлов появляются по мере вывода команды."; } } if (j.status === "success" || j.status === "failed" || j.status === "cancelled") { stopPollJob(); setProgressHint(null, "create"); if (msg) { if (j.status === "success") { if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу."; else if (j.kind === "start_cluster_reapply") msg.textContent = "Кластер пересоздан в kind по обновлённому конфигу."; else if (j.kind === "stop_containers") msg.textContent = "Узлы кластера остановлены."; else if (j.kind === "start_containers") msg.textContent = "Узлы кластера запущены."; else msg.textContent = "Кластер создан."; } else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена."; else msg.textContent = "Ошибка: " + (j.message || ""); } if (j.status === "success") { if (j.kind === "start_cluster") showToast("Кластер запущен", false); else if (j.kind === "start_cluster_reapply") showToast("Кластер пересоздан по новому конфигу", false); else if (j.kind === "stop_containers") showToast("Узлы остановлены", false); else if (j.kind === "start_containers") showToast("Узлы запущены", false); else showToast("Кластер создан", false); } else if (j.status === "cancelled") showToast(j.message || "Отменено", false); else showToast(j.message || "Ошибка", true); await loadClusters(); await loadStats(); await loadJobs(); await loadHealth(); await refreshClusterPageIfNeeded(j.cluster_name || ""); } } catch (e) { if (msg) msg.textContent = "Ошибка опроса задания: " + e.message; } }; tick(); pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS); } async function refreshLists() { try { await loadHealth(); if (isHomePage() || isClustersListPage()) { await loadStats(); } if (isClustersListPage()) { await loadClusters(); } if (isCreatePage()) { await loadJobs(); } if (isClusterPage()) { const n = getClusterPageName(); if (n) await loadClusterOverview(n); } } catch (e) { /* loadHealth/loadStats/loadClusters сами сообщают об ошибках в UI */ } } function init() { const form = document.getElementById("form-create"); if (form) { form.addEventListener("submit", async function (ev) { ev.preventDefault(); if (createInProgress) return; const msg = document.getElementById("create-msg"); const details = document.getElementById("job-details"); if (msg) msg.textContent = ""; if (details) details.classList.add("hidden"); setProgressHint(null, "create"); const fd = new FormData(form); const nameRaw = String(fd.get("name") || "").trim(); const k8sVer = String(fd.get("kubernetes_version") || "").trim(); const workersParsed = parseWorkersBounded(fd.get("workers")); if (!isValidClusterName(nameRaw)) { await openConfirmModal({ alert: true, title: "Некорректное имя кластера", message: "Имя должно состоять из строчных латинских букв, цифр и дефиса (a-z, 0-9, -), начинаться и заканчиваться буквой или цифрой, длина не более 63 символов. Пример: dev, my-cluster-1.", confirmLabel: "Понятно", }); return; } if (workersParsed === null) { await openConfirmModal({ alert: true, title: "Некорректное число worker-нод", message: "Укажите целое число worker-нод от 0 до 20 включительно.", confirmLabel: "Понятно", }); return; } const verCheck = validateCreateFormKubernetesVersion(k8sVer); if (!verCheck.ok) { await openConfirmModal({ alert: true, title: verCheck.modalTitle, message: verCheck.modalMessage, confirmLabel: "Понятно", }); return; } const body = { name: nameRaw, kubernetes_version: k8sVer, workers: workersParsed, }; try { const res = await api("/clusters", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (msg) msg.textContent = "Задание: " + res.job_id; pollJob(res.job_id, { lockCreateForm: true }); } catch (e) { if (msg) msg.textContent = "Ошибка: " + e.message; showToast(e.message, true); } }); } const btnCancel = document.getElementById("btn-cancel-create"); if (btnCancel) { btnCancel.addEventListener("click", function () { requestCancelJob(); }); } const btnJobsRefresh = document.getElementById("btn-jobs-refresh"); if (btnJobsRefresh) { btnJobsRefresh.addEventListener("click", function () { loadJobs(); }); } if (document.getElementById("tbl-jobs")) { bindCreateJobsJournalToggle(); } const mClose = document.getElementById("modal-close"); if (mClose) mClose.addEventListener("click", closeModal); const overlay = document.getElementById("modal-overlay"); if (overlay) { overlay.addEventListener("click", function (ev) { if (ev.target === overlay) closeModal(); }); } document.addEventListener("keydown", function (ev) { if (ev.key !== "Escape") return; if (isConfirmModalOpen()) { closeConfirmModal(confirmModalIsAlert ? true : false); return; } if (isJobLogModalOpen()) { closeJobLogModal(); return; } if (isProvisionLogModalOpen()) { closeProvisionLogModal(); return; } hideActionTooltip(); closeModal(); }); const confirmOv = document.getElementById("confirm-modal-overlay"); const confirmCancel = document.getElementById("confirm-modal-cancel"); const confirmOk = document.getElementById("confirm-modal-ok"); if (confirmCancel) { confirmCancel.addEventListener("click", function () { closeConfirmModal(false); }); } if (confirmOk) { confirmOk.addEventListener("click", function () { closeConfirmModal(true); }); } if (confirmOv) { confirmOv.addEventListener("click", function (ev) { if (ev.target === confirmOv) closeConfirmModal(confirmModalIsAlert ? true : false); }); } const provLogOv = document.getElementById("provision-log-modal-overlay"); const provLogClose = document.getElementById("provision-log-modal-close"); if (provLogClose) { provLogClose.addEventListener("click", closeProvisionLogModal); } if (provLogOv) { provLogOv.addEventListener("click", function (ev) { if (ev.target === provLogOv) closeProvisionLogModal(); }); } const jobLogOv = document.getElementById("job-log-modal-overlay"); const jobLogClose = document.getElementById("job-log-modal-close"); if (jobLogClose) { jobLogClose.addEventListener("click", closeJobLogModal); } if (jobLogOv) { jobLogOv.addEventListener("click", function (ev) { if (ev.target === jobLogOv) closeJobLogModal(); }); } window.addEventListener( "scroll", function () { hideActionTooltip(); }, true, ); window.addEventListener("resize", hideActionTooltip); autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS); (async function bootstrap() { try { await loadHealth(); if (isHomePage()) { await loadStats(); } if (isClustersListPage()) { await loadStats(); await loadClusters(); } if (isCreatePage()) { await loadJobs(); } if (isClusterPage()) { const n = getClusterPageName(); if (n) await loadClusterOverview(n); else hideClusterPageLoadingOverlay(); } } catch (e) { const errText = e && e.message ? e.message : String(e); const lm = document.getElementById("list-msg"); if (isHomePage() && lm) { lm.textContent = "Ошибка загрузки панели: " + errText; } if (isClustersListPage() && lm) { lm.textContent = "Ошибка загрузки: " + errText; } } finally { hideHomePageLoadingOverlay(); hideClustersPageLoadingOverlay(); } })(); if (isCreatePage()) { loadVersions(); } bindActionTooltipHosts(document.getElementById("modal-overlay")); bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay")); bindActionTooltipHosts(document.getElementById("job-log-modal-overlay")); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();