/** * Панель управления кластерами 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; 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); } function setStatusBannerClass(ok, degraded) { const el = document.getElementById("status-banner"); if (!el) return; var cls = "hero-panel-status muted "; if (ok) cls += "ok"; else if (degraded) cls += "degraded"; else cls += "err"; el.className = cls; } async function loadHealth() { const el = document.getElementById("status-banner"); if (!el) return; try { const h = await api("/health"); const ok = h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path; setStatusBannerClass(ok, !ok); var lines = "Среда: "; lines += escapeHtml(String(h.container_cli || "?")); lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен"); lines += " · kind: " + (h.kind_in_path ? "да" : "нет"); lines += " · kubectl: " + (h.kubectl_in_path ? "да" : "нет"); if (!h.container_engine_ok && h.container_engine_detail) { lines += "
" + escapeHtml(String(h.container_engine_detail).slice(0, 400)) + ""; } el.innerHTML = lines; } catch (e) { setStatusBannerClass(false, false); el.textContent = "Не удалось запросить health: " + e.message; } } /** * Блок «Ресурсы узлов» из cluster_resources (CPU/RAM/I/O из docker stats). */ function renderStatsResources(s) { const wrap = document.getElementById("stats-resources"); if (!wrap) return; wrap.replaceChildren(); if (s.cluster_resources_error) { const p = document.createElement("p"); p.className = "muted stats-resources-msg"; p.textContent = "Ресурсы узлов: " + s.cluster_resources_error; wrap.appendChild(p); return; } const list = s.cluster_resources || []; if (!list.length) { const p2 = document.createElement("p"); p2.className = "muted stats-resources-msg"; p2.textContent = "Нет кластеров в kind — метрики узлов пусты."; wrap.appendChild(p2); return; } const h3 = document.createElement("h3"); h3.className = "stats-resources-heading"; h3.textContent = "Ресурсы узлов (CPU, RAM, I/O)"; wrap.appendChild(h3); list.forEach(function (cr) { const sec = document.createElement("section"); sec.className = "stats-cluster-res"; const h4 = document.createElement("h4"); h4.className = "stats-cluster-res__title"; h4.textContent = cr.cluster_name; sec.appendChild(h4); const nodes = cr.nodes || []; if (!nodes.length) { const pn = document.createElement("p"); pn.className = "muted stats-cluster-res__note"; pn.textContent = cr.note || "Нет запущенных контейнеров узлов."; sec.appendChild(pn); } else { nodes.forEach(function (n) { const row = document.createElement("div"); row.className = "stats-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); sec.appendChild(row); }); } wrap.appendChild(sec); }); } async function loadStats() { const dl = document.getElementById("stats-dl"); const errEl = document.getElementById("stats-err"); if (!dl) return; try { const s = await api("/stats"); errEl.classList.add("hidden"); const rows = [ ["Кластеров в kind", s.kind_clusters_count], ["Локальных каталогов", s.local_cluster_dirs_count], ["Сумма workers (meta)", s.total_workers_from_meta != null ? s.total_workers_from_meta : "—"], ["Заданий в памяти", s.jobs_total], ["Заданий с ошибкой", s.jobs_recent_failed], ]; const frag = document.createDocumentFragment(); rows.forEach(function (kv) { const dt = document.createElement("dt"); dt.textContent = kv[0]; const dd = document.createElement("dd"); dd.textContent = String(kv[1]); frag.appendChild(dt); frag.appendChild(dd); }); dl.replaceChildren(frag); renderStatsResources(s); } catch (e) { errEl.textContent = "Статистика: " + e.message; errEl.classList.remove("hidden"); const wrap = document.getElementById("stats-resources"); if (wrap) wrap.replaceChildren(); } } 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); tr.innerHTML = "" + nameEsc + "" + "" + (c.registered_in_kind ? "да" : "нет") + "" + "" + (c.has_local_kubeconfig ? "да" : "нет") + "" + "" + escapeHtml(String(ver)) + "" + "" + escapeHtml(String(wn)) + "" + ""; const td = tr.querySelector(".actions-toolbar"); td.appendChild( iconActionButton( ICONS.state, "Состояние: узлы и поды (kubectl get nodes / pods)", "", function () { openWorkloadsModal(c.name); }, false, ), ); td.appendChild( iconActionButton( ICONS.play, "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне", "", function () { startCluster(c.name); }, false, ), ); if (c.registered_in_kind) { td.appendChild( iconActionButton( ICONS.stop, "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется", "icon-btn--secondary", function () { stopCluster(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); }); 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); } function refreshLists() { loadHealth(); loadStats(); loadClusters(); loadJobs(); } 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); loadHealth(); loadStats(); loadVersions(); loadClusters(); loadJobs(); bindActionTooltipHosts(document.getElementById("modal-overlay")); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();