/** * Панель управления кластерами 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; /** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */ var currentModalClusterName = null; /** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */ var cachedClusterResourcesByName = {}; /** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */ var cachedHealthPayload = null; /** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */ var cachedLocalClusterDirsCount = 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"; } 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 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; } function pad2(n) { return n < 10 ? "0" + n : String(n); } /** * Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC). * @param {string} iso */ function formatJobTimeUtc(iso) { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return ( pad2(d.getUTCDate()) + "." + pad2(d.getUTCMonth() + 1) + "." + String(d.getUTCFullYear()).slice(-2) + " " + pad2(d.getUTCHours()) + ":" + pad2(d.getUTCMinutes()) + " UTC" ); } /** * Скачать 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"); } /** * Загрузить ``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); } /** SVG-иконки действий (stroke, currentColor). */ var ICONS = { state: '', play: '', stop: '', download: '', trash: '', logFile: '', restart: '', }; /** @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"); 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"); } /** Показать снова ряд среды и сетку метрик (после успешных запросов). */ function revealStatsCardBody() { const failureWrap = document.getElementById("stats-failure-wrap"); const env = document.getElementById("stats-env"); const metricsGrid = document.getElementById("stats-grid"); if (failureWrap) { failureWrap.classList.add("hidden"); failureWrap.replaceChildren(); } if (env) env.classList.remove("hidden"); if (metricsGrid) metricsGrid.classList.remove("hidden"); } /** Карточка-метрика в ряду среды (без зелёного/красного статуса), напр. «Каталогов». */ function buildStatsEnvMetricCard(label, valueText) { const tile = document.createElement("div"); tile.className = "stats-tile stats-env-card stats-env-card--metric"; 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); return tile; } /** * Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats). */ 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), ); const dirVal = cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—"; el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal)); } 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); } 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); } /** * Таблицы Kubernetes из полей ``k8s_*`` ответа overview. * @param {object} d */ function fillClusterK8sTablesFromOverview(d) { const kubeErr = d.kubeconfig_error || null; const cname = d.cluster_name || getClusterPageName() || ""; clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable); clusterK8sRenderBlock( d.k8s_namespaces, kubeErr, document.getElementById("cluster-k8s-namespaces-root"), renderK8sNamespacesTable, ); clusterK8sRenderBlock(d.k8s_pods, kubeErr, document.getElementById("cluster-k8s-pods-root"), function (root, items) { renderK8sPodsTable(root, items, cname); }); clusterK8sRenderBlock( d.k8s_deployments, kubeErr, document.getElementById("cluster-k8s-deployments-root"), renderK8sDeploymentsTable, ); clusterK8sRenderBlock( d.k8s_statefulsets, kubeErr, document.getElementById("cluster-k8s-statefulsets-root"), renderK8sStsDsTable, ); clusterK8sRenderBlock( d.k8s_daemonsets, kubeErr, document.getElementById("cluster-k8s-daemonsets-root"), renderK8sStsDsTable, ); clusterK8sRenderBlock( d.k8s_replicasets, kubeErr, document.getElementById("cluster-k8s-replicasets-root"), renderK8sReplicaSetsTable, ); clusterK8sRenderBlock(d.k8s_jobs, kubeErr, document.getElementById("cluster-k8s-jobs-root"), renderK8sJobsTable); clusterK8sRenderBlock( d.k8s_cronjobs, kubeErr, document.getElementById("cluster-k8s-cronjobs-root"), renderK8sCronJobsTable, ); clusterK8sRenderBlock( d.k8s_services, kubeErr, document.getElementById("cluster-k8s-services-root"), renderK8sServicesTable, ); clusterK8sRenderBlock( d.k8s_ingresses, kubeErr, document.getElementById("cluster-k8s-ingresses-root"), renderK8sIngressesTable, ); clusterK8sRenderBlock(d.k8s_pvcs, kubeErr, document.getElementById("cluster-k8s-pvcs-root"), renderK8sPvcsTable); } /** * Кнопки действий над кластером — те же, что в таблице на главной. * @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, "Состояние: узлы и поды (kubectl get nodes / pods)", "", function () { openWorkloadsModal(name); }, 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 на хосте" : "Kubeconfig ещё нет — появится после создания или подъёма кластера", "icon-btn--secondary", c.has_local_kubeconfig ? function () { downloadKubeconfig(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); } /** * Загрузка ``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, }); 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; if (hint) { if (kubeErr) { hint.textContent = kubeErr; hint.classList.remove("hidden"); } else { hint.textContent = ""; hint.classList.add("hidden"); } } fillClusterK8sTablesFromOverview(d); } catch (e) { const errText = e && e.message ? e.message : String(e); 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"); if (!grid) return; try { const s = await api("/stats"); revealStatsCardBody(); cachedLocalClusterDirsCount = s.local_cluster_dirs_count; paintStatsEnvRow(); const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker"; updateStatsEngineHint(cliForHint); const rows = [ ["Кластеров в kind", s.kind_clusters_count], ["Сумма workers (meta)", s.total_workers_from_meta ?? 0], ["Заданий в памяти", s.jobs_total], ["Заданий с ошибкой", s.jobs_recent_failed], ]; 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 = {}; renderAggregateDonuts( s.aggregate_cluster_resources, s.cluster_resources_error, s.container_cli || "docker", ); } catch (e) { 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).", }; } function jobBadgeClass(status) { if (status === "success") return "badge badge-ok"; if (status === "failed") return "badge badge-err"; if (status === "running") return "badge badge-run"; if (status === "cancelled") return "badge badge-cancelled"; return "badge"; } 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; } } /** * Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена). */ async function clearCompletedJobs() { const ok = await openConfirmModal({ title: "Очистить завершённые задания?", message: "Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.", confirmLabel: "Очистить", danger: false, }); if (!ok) return; const msg = document.getElementById("jobs-msg"); try { const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } }); const text = await r.text(); var data = {}; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = {}; } if (!r.ok) { showToast(formatApiError(data, text || r.statusText), true); return; } var n = data.removed != null ? data.removed : 0; if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать."; showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false); await loadJobs(); await loadStats(); } catch (e) { if (msg) msg.textContent = "Ошибка: " + e.message; showToast(e.message, true); } } async function loadJobs() { const tbody = document.querySelector("#tbl-jobs tbody"); const msg = document.getElementById("jobs-msg"); if (!tbody) return; try { const rows = await api("/jobs?limit=30"); const frag = document.createDocumentFragment(); rows.forEach(function (j) { const tr = document.createElement("tr"); const st = escapeHtml(j.status || ""); var kindTag = ""; if (j.kind === "start_cluster") kindTag = "[старт] "; else if (j.kind === "stop_containers") kindTag = "[стоп] "; else if (j.kind === "start_containers") kindTag = "[запуск узлов] "; var cellMsg = kindTag + (j.message || "").slice(0, 140); if ((j.status === "running" || j.status === "queued") && j.progress_stage) { cellMsg = kindTag + j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); } const iso = j.created_at_utc || ""; const timeLabel = formatJobTimeUtc(iso); tr.innerHTML = "" + "" + escapeHtml(j.cluster_name || "—") + "" + "" + st + "" + "" + escapeHtml(cellMsg) + ""; frag.appendChild(tr); }); tbody.replaceChildren(frag); if (msg) { msg.textContent = rows.length ? "" : "Заданий ещё не было (или контейнер перезапускали)."; } } 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"); const modalDlWrap = document.getElementById("modal-dl-wrap"); if (!overlay) return; hideActionTooltip(); currentModalClusterName = name; document.getElementById("modal-title").textContent = "Кластер «" + name + "»"; sub.textContent = ""; nodes.textContent = ""; pods.textContent = ""; if (modalDlWrap) modalDlWrap.classList.add("hidden"); 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 = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc; nodes.textContent = w.nodes_output || "(пусто)"; pods.textContent = w.pods_output || "(пусто)"; if (modalDlWrap) modalDlWrap.classList.remove("hidden"); } 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"); const modalDlWrap = document.getElementById("modal-dl-wrap"); if (modalDlWrap) modalDlWrap.classList.add("hidden"); currentModalClusterName = null; 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 }); 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 setProgressHint(name, "start_config"); pollJob(data.job_id, { lockCreateForm: false }); 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_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_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() { 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 }} [opts] */ 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); } 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 (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 === "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 === "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()) { await loadStats(); 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 btnClearJobs = document.getElementById("btn-jobs-clear"); if (btnClearJobs) { btnClearJobs.addEventListener("click", function () { clearCompletedJobs(); }); } const mClose = document.getElementById("modal-close"); if (mClose) mClose.addEventListener("click", closeModal); const modalBtnKube = document.getElementById("modal-btn-kubeconfig"); if (modalBtnKube) { modalBtnKube.addEventListener("click", function () { if (currentModalClusterName) downloadKubeconfig(currentModalClusterName); }); } 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 (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(); }); } 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(); 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; } } finally { hideHomePageLoadingOverlay(); } })(); if (isCreatePage()) { loadVersions(); } bindActionTooltipHosts(document.getElementById("modal-overlay")); bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay")); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();