/** * Панель управления кластерами kind (REST /api/v1). * Автообновление списков и health; прогресс и отмена создания кластера. * * Автор: Сергей Антропов * Сайт: https://devops.org.ru */ (function () { "use strict"; const body = document.body; const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); /** Интервал опроса списков и среды (мс) */ var AUTO_REFRESH_MS = 3500; /** Интервал опроса задания создания (мс) */ var JOB_POLL_MS = 1500; /** @type {ReturnType | null} */ var autoTimer = null; /** @type {ReturnType | null} */ var pollTimer = null; var createInProgress = false; /** @type {string | null} */ var currentPollJobId = 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 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 setBusy(section, busy) { const el = document.querySelector("[data-busy='" + section + "']"); if (!el) return; if (busy) { el.setAttribute("aria-busy", "true"); } else { el.removeAttribute("aria-busy"); } el.classList.toggle("is-loading", busy); } 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; } } async function loadStats() { const dl = document.getElementById("stats-dl"); const errEl = document.getElementById("stats-err"); if (!dl) return; errEl.classList.add("hidden"); dl.innerHTML = ""; try { const s = await api("/stats"); 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], ]; rows.forEach(function (kv) { const dt = document.createElement("dt"); dt.textContent = kv[0]; const dd = document.createElement("dd"); dd.textContent = String(kv[1]); dl.appendChild(dt); dl.appendChild(dd); }); } catch (e) { errEl.textContent = "Статистика: " + e.message; errEl.classList.remove("hidden"); } } 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; setBusy("clusters", true); tbody.innerHTML = ""; if (msg) msg.textContent = ""; try { const rows = await api("/clusters"); 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); const dlHref = API + "/clusters/" + encodeURIComponent(c.name) + "/kubeconfig"; tr.innerHTML = "" + nameEsc + "" + "" + (c.registered_in_kind ? "да" : "нет") + "" + "" + (c.has_local_kubeconfig ? "да" : "нет") + "" + "" + escapeHtml(String(ver)) + "" + "" + escapeHtml(String(wn)) + "" + ""; const td = tr.querySelector(".actions"); const b1 = document.createElement("button"); b1.type = "button"; b1.className = "btn-small"; b1.textContent = "Состояние"; b1.addEventListener("click", function () { openWorkloadsModal(c.name); }); td.appendChild(b1); if (c.has_local_kubeconfig) { const a = document.createElement("a"); a.href = dlHref; a.className = "btn-secondary btn-small"; a.download = "kubeconfig-" + c.name + ".yaml"; a.textContent = "kubeconfig"; a.title = "Скачать kubeconfig"; td.appendChild(a); } const b2 = document.createElement("button"); b2.type = "button"; b2.className = "btn-small btn-danger"; b2.textContent = "Удалить"; b2.addEventListener("click", function () { deleteCluster(c.name); }); td.appendChild(b2); tbody.appendChild(tr); }); if (!rows.length && msg) msg.textContent = "Кластеров пока нет."; } catch (e) { if (msg) msg.textContent = "Ошибка списка: " + e.message; } finally { setBusy("clusters", false); } } async function loadJobs() { const tbody = document.querySelector("#tbl-jobs tbody"); const msg = document.getElementById("jobs-msg"); if (!tbody) return; setBusy("jobs", true); tbody.innerHTML = ""; if (msg) msg.textContent = ""; try { const rows = await api("/jobs?limit=30"); rows.forEach(function (j) { const tr = document.createElement("tr"); const st = escapeHtml(j.status || ""); var cellMsg = (j.message || "").slice(0, 160); if ((j.status === "running" || j.status === "queued") && j.progress_stage) { cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); } tr.innerHTML = "" + "" + escapeHtml(j.cluster_name || "—") + "" + "" + st + "" + "" + escapeHtml(cellMsg) + ""; tbody.appendChild(tr); }); if (!rows.length && msg) { msg.textContent = "Заданий ещё не было (или контейнер перезапускали)."; } } catch (e) { if (msg) msg.textContent = "Задания: " + e.message; } finally { setBusy("jobs", false); } } async function openWorkloadsModal(name) { const overlay = document.getElementById("modal-overlay"); const sub = document.getElementById("modal-sub"); const nodes = document.getElementById("modal-nodes"); const pods = document.getElementById("modal-pods"); const spin = document.getElementById("modal-spinner"); if (!overlay) return; document.getElementById("modal-title").textContent = "Кластер «" + name + "»"; sub.textContent = ""; nodes.textContent = ""; pods.textContent = ""; if (spin) spin.classList.remove("hidden"); overlay.classList.remove("hidden"); document.body.classList.add("modal-open"); try { const w = await api("/clusters/" + encodeURIComponent(name) + "/workloads"); if (w.error) { sub.textContent = w.error; return; } sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc; nodes.textContent = w.nodes_output || "(пусто)"; pods.textContent = w.pods_output || "(пусто)"; } catch (e) { sub.textContent = "Ошибка: " + e.message; } finally { if (spin) spin.classList.add("hidden"); } } function closeModal() { const overlay = document.getElementById("modal-overlay"); if (overlay) overlay.classList.add("hidden"); document.body.classList.remove("modal-open"); } async function deleteCluster(name) { if (!confirm("Удалить кластер «" + name + "» и папку clusters/" + name + "?")) 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"); } 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; setCreateFormDisabled(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); } } function pollJob(jobId) { const details = document.getElementById("job-details"); const msg = document.getElementById("create-msg"); if (details) { details.classList.remove("hidden"); details.open = false; } if (pollTimer) clearInterval(pollTimer); createInProgress = true; currentPollJobId = jobId; 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 (j.status === "success" || j.status === "failed" || j.status === "cancelled") { stopPollJob(); if (msg) { if (j.status === "success") msg.textContent = "Кластер создан."; else if (j.status === "cancelled") msg.textContent = j.message || "Создание отменено."; else msg.textContent = "Ошибка: " + (j.message || ""); } if (j.status === "success") showToast("Кластер создан", false); else if (j.status === "cancelled") showToast(j.message || "Отменено", false); else showToast(j.message || "Ошибка создания", true); await loadClusters(); await loadStats(); await loadJobs(); } } catch (e) { if (msg) msg.textContent = "Ошибка опроса задания: " + e.message; } }; tick(); pollTimer = setInterval(tick, JOB_POLL_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"); 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); } 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 mClose = document.getElementById("modal-close"); if (mClose) mClose.addEventListener("click", closeModal); const overlay = document.getElementById("modal-overlay"); if (overlay) { overlay.addEventListener("click", function (ev) { if (ev.target === overlay) closeModal(); }); } document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") closeModal(); }); autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS); loadHealth(); loadStats(); loadVersions(); loadClusters(); loadJobs(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();