/** * Панель управления кластерами 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; function isHomePage() { return (document.body.getAttribute("data-dashboard-mode") || "home") === "home"; } function isCreatePage() { return document.body.getAttribute("data-dashboard-mode") === "create"; } 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 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: '', }; /** @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)); } } /** * SVG-донаты по полю aggregate_cluster_resources (главная страница). * @param {object | null | undefined} agg * @param {string | null | undefined} resourcesError * @param {string | undefined} containerCliRaw имя CLI из /stats (docker/podman) для подписи доната RAM */ function renderAggregateDonuts(agg, resourcesError, containerCliRaw) { const root = document.getElementById("aggregate-donuts"); 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); } /** * Раскрывающийся блок метрик узлов под строкой таблицы кластеров. * @param {string} clusterName */ function buildClusterResourcesDetails(clusterName) { const cr = cachedClusterResourcesByName[clusterName]; const details = document.createElement("details"); details.className = "cluster-nodes-resources"; const summ = document.createElement("summary"); summ.textContent = "Ресурсы узлов (CPU, RAM, сеть, диск)"; details.appendChild(summ); 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 : "Нет данных: узлы остановлены или метрики недоступны."; details.appendChild(p); return details; } cr.nodes.forEach(function (n) { const row = document.createElement("div"); row.className = "stats-node-res cluster-row-node-res"; const code = document.createElement("code"); code.textContent = n.container_name; row.appendChild(code); const ul = document.createElement("ul"); ul.className = "stats-node-res__metrics"; function addMetric(label, val) { if (val == null || val === "") return; const li = document.createElement("li"); const strong = document.createElement("strong"); strong.textContent = label + ": "; li.appendChild(strong); li.appendChild(document.createTextNode(String(val))); ul.appendChild(li); } addMetric("CPU", n.cpu_percent); addMetric("Память", n.memory_usage); addMetric("% RAM", n.memory_percent); addMetric("Сеть", n.net_io); addMetric("Диск", n.block_io); addMetric("PID", n.pids != null ? String(n.pids) : null); row.appendChild(ul); details.appendChild(row); }); return details; } 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 = {}; (s.cluster_resources || []).forEach(function (cr) { cachedClusterResourcesByName[cr.cluster_name] = cr; }); 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; sel.innerHTML = ""; try { const data = await api("/versions"); sel.innerHTML = ""; if (!data.tags || !data.tags.length) { sel.innerHTML = ""; return; } const opt0 = document.createElement("option"); opt0.value = ""; opt0.textContent = "— выберите тег —"; sel.appendChild(opt0); data.tags.slice(0, 100).forEach(function (t) { const o = document.createElement("option"); o.value = t; o.textContent = t; sel.appendChild(o); }); sel.onchange = function () { if (sel.value) verInput.value = sel.value.replace(/^v/, ""); }; } catch (e) { sel.innerHTML = ""; } } 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"); td.appendChild( iconActionButton( ICONS.state, "Состояние: узлы и поды (kubectl get nodes / pods)", "", function () { openWorkloadsModal(c.name); }, false, ), ); if (c.registered_in_kind && c.kind_nodes_running) { td.appendChild( iconActionButton( ICONS.stop, "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется", "icon-btn--secondary", function () { stopCluster(c.name); }, false, ), ); } else { td.appendChild( iconActionButton( ICONS.play, "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне", "", function () { startCluster(c.name); }, false, ), ); } td.appendChild( iconActionButton( ICONS.download, c.has_local_kubeconfig ? "Скачать kubeconfig для kubectl на хосте" : "Kubeconfig ещё нет — появится после создания или подъёма кластера", "icon-btn--secondary", c.has_local_kubeconfig ? function () { downloadKubeconfig(c.name); } : null, !c.has_local_kubeconfig, ), ); td.appendChild( iconActionButton( ICONS.trash, "Удалить кластер (kind delete) и каталог clusters/<имя>/", "icon-btn--danger", function () { deleteCluster(c.name); }, false, ), ); frag.appendChild(tr); if (isHomePage()) { const trExp = document.createElement("tr"); trExp.className = "cluster-resources-expand-row"; const tdExp = document.createElement("td"); tdExp.colSpan = 7; tdExp.className = "cluster-resources-expand-cell"; tdExp.appendChild(buildClusterResourcesDetails(c.name)); trExp.appendChild(tdExp); frag.appendChild(trExp); } }); 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"); if (ov) ov.classList.add("hidden"); document.body.classList.remove("modal-open"); if (confirmModalResolver) { var fn = confirmModalResolver; confirmModalResolver = null; fn(!!confirmed); } } /** * Показать модальное подтверждение (вместо window.confirm). * @param {{ title?: string, message: string, confirmLabel?: string, danger?: 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"); if (!ov || !titleEl || !msgEl || !okBtn) { resolve(false); return; } if (confirmModalResolver) { closeConfirmModal(false); } confirmModalResolver = resolve; titleEl.textContent = opts.title || "Подтвердите действие"; msgEl.textContent = opts.message || ""; okBtn.textContent = opts.confirmLabel || "Подтвердить"; okBtn.className = opts.danger ? "btn-danger" : ""; 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(); } 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(); } 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); 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(); } } 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(); } } 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 body = { name: String(fd.get("name") || "").trim(), kubernetes_version: String(fd.get("kubernetes_version") || "").trim(), workers: parseInt(String(fd.get("workers") || "0"), 10), }; 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(false); 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(false); }); } 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(); } } catch (e) { /* см. сообщения в блоках */ } })(); if (isCreatePage()) { loadVersions(); } bindActionTooltipHosts(document.getElementById("modal-overlay")); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();