/** * Страница Helm-аддонов: GET /api/v1/clusters, статус и POST/DELETE …/addons/*. * * Автор: Сергей Антропов * Сайт: https://devops.org.ru */ (function () { "use strict"; const body = document.body; /* trim: лишний пробел/перенос в шаблоне ломал бы проверку — скрипт выходил бы до bootstrap и оверлей не снимался */ var dashMode = (body.getAttribute("data-dashboard-mode") || body.dataset.dashboardMode || "").trim(); if (dashMode !== "addons") return; const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); /** data-endpoint кнопки → поле ответа GET …/addons/status */ var ENDPOINT_TO_STATUS_KEY = { "ingress-nginx": "ingress_nginx", "kube-prometheus-stack": "kube_prometheus_stack", "metrics-server": "metrics_server", "istio-kiali": "istio_mesh_ready", }; var ADDON_HUMAN_LABEL = { "ingress-nginx": "ingress-nginx", "kube-prometheus-stack": "kube-prometheus-stack", "metrics-server": "metrics-server", "istio-kiali": "Istio + Kiali", }; /** Последний успешный снимок статуса (для подписи «Установить» / «Обновить»). */ var lastAddonsStatus = null; /** Список версий из GET /helm/chart-versions уже загружен — можно мержить версии из кластера. */ var chartVersionsReady = false; /** Суффикс подписи для версии, совпадающей с установленной в кластере. */ var INSTALLED_VER_SUFFIX = " (текущая установленная версия)"; /** @type {((value: boolean) => void) | null} */ var confirmModalResolver = null; var confirmModalIsAlert = false; function isConfirmModalOpen() { const el = document.getElementById("confirm-modal-overlay"); return !!(el && !el.classList.contains("hidden")); } 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; } } /** * Модальное подтверждение (разметка из partials/dashboard_modals.html). * @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; } 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 bindConfirmModalControls() { 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); }); } document.addEventListener("keydown", function (ev) { if (ev.key !== "Escape" || !isConfirmModalOpen()) return; closeConfirmModal(false); }); } /** * @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 = typeof data.detail === "string" ? data.detail : Array.isArray(data.detail) ? data.detail.map(function (x) { return x.msg || JSON.stringify(x); }).join("; ") : text || r.statusText; const err = new Error(msg); err.status = r.status; throw err; } return data; } /** * Обёртка над Promise: если запрос «висит» (сеть, прокси), await иначе не завершится и finally не выполнится. * @param {Promise} promise * @param {number} ms * @param {string} timeoutMessage */ function promiseWithTimeout(promise, ms, timeoutMessage) { return new Promise(function (resolve, reject) { var finished = false; var to = setTimeout(function () { if (finished) return; finished = true; reject(new Error(timeoutMessage)); }, ms); Promise.resolve(promise).then( function (v) { if (finished) return; finished = true; clearTimeout(to); resolve(v); }, function (err) { if (finished) return; finished = true; clearTimeout(to); reject(err); }, ); }); } const sel = document.getElementById("addons-cluster-select"); const statusBar = document.getElementById("addons-status-bar"); const addonsCardsWrap = document.getElementById("addons-cards-wrap"); const helmWrap = document.getElementById("addons-helm-progress-wrap"); const helmHint = document.getElementById("addons-helm-progress-hint"); const helmBar = document.getElementById("addons-helm-progress-bar"); const helmTrack = document.getElementById("addons-helm-progress-track"); const helmLabel = document.getElementById("addons-helm-progress-label"); const helmLogPanel = document.getElementById("addons-helm-log-panel"); const bootstrapMsg = document.getElementById("addons-bootstrap-msg"); function getClusterName() { if (!sel || !sel.value) return ""; return String(sel.value).trim(); } /** Карточки аддонов — только при выбранном кластере в селекте. */ function updateAddonsCardsVisibility() { if (!addonsCardsWrap) return; var n = getClusterName(); addonsCardsWrap.classList.toggle("hidden", !n); addonsCardsWrap.setAttribute("aria-hidden", n ? "false" : "true"); } /** * Предупреждения загрузки списка кластеров / версий чартов (не путать с журналом Helm). * @param {string} text пустая строка — скрыть блок */ function setBootstrapMsg(text) { if (!bootstrapMsg) return; var s = (text || "").trim(); if (!s) { bootstrapMsg.classList.add("hidden"); bootstrapMsg.textContent = ""; return; } bootstrapMsg.classList.remove("hidden"); bootstrapMsg.textContent = s; } /** Известные data-addon карточек (только они — безопасный перенос DOM без произвольного селектора). */ var ADDON_CARD_MOUNT_KEYS = { "ingress-nginx": true, "kube-prometheus-stack": true, "metrics-server": true, "istio-kiali": true, }; /** * Переносит блок «Ход операции Helm» в конец карточки выбранного аддона (под кнопки). * Если карточку не нашли — оставляем/возвращаем в #addons-helm-progress-dock. * @param {string} endpoint то же, что data-endpoint / data-addon */ function mountHelmProgressUnderAddon(endpoint) { if (!helmWrap) return; var dock = document.getElementById("addons-helm-progress-dock"); var ep = typeof endpoint === "string" ? endpoint.trim() : ""; var card = null; if (ep && ADDON_CARD_MOUNT_KEYS[ep]) { card = document.querySelector('.cluster-addon-card[data-addon="' + ep + '"]'); } var parent = card || dock; if (!parent) return; parent.appendChild(helmWrap); } /** * Показать блок «Ход операции Helm»; при переданном endpoint — сразу под соответствующей карточкой. * @param {string} [endpoint] */ function revealHelmProgressSection(endpoint) { if (endpoint) { mountHelmProgressUnderAddon(endpoint); } if (!helmWrap) return; helmWrap.classList.remove("hidden"); helmWrap.setAttribute("aria-hidden", "false"); requestAnimationFrame(function () { try { helmWrap.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (e1) { try { helmWrap.scrollIntoView(true); } catch (e2) { /* старые браузеры */ } } }); } function updateHelmProgressBar(pct, stage) { var p = Math.max(0, Math.min(100, Number(pct) || 0)); if (helmBar) helmBar.style.width = p + "%"; if (helmTrack) helmTrack.setAttribute("aria-valuenow", String(p)); if (helmLabel) helmLabel.textContent = stage || "—"; } /** Полный текст журнала в панели с теми же классами, что «Журнал задания» при создании кластера. */ function setHelmLogText(text) { if (!helmLogPanel) return; helmLogPanel.textContent = text || ""; helmLogPanel.scrollTop = helmLogPanel.scrollHeight; } /** * Блокировка форм на время синхронного запроса Helm (без полноэкранного спиннера). * @param {boolean} disabled */ function setAddonFormsDisabled(disabled) { document .querySelectorAll( "#addons-cards-wrap .cluster-addon-card button, #addons-cards-wrap .cluster-addon-card input, #addons-cards-wrap .cluster-addon-card select, #addons-cards-wrap .cluster-addon-card textarea", ) .forEach(function (el) { el.disabled = !!disabled; }); if (sel) sel.disabled = !!disabled; var refBtn = document.getElementById("addons-refresh-status"); if (refBtn) refBtn.disabled = !!disabled; } /** Таймер «последней надежды», если async bootstrap не дошёл до hide (ошибка до finally, зависший fetch). */ var addonsOverlayFailsafeTimer = null; /** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */ function hideAddonsPageLoadingOverlay() { if (addonsOverlayFailsafeTimer !== null) { clearTimeout(addonsOverlayFailsafeTimer); addonsOverlayFailsafeTimer = null; } const el = document.getElementById("addons-page-loading-overlay"); if (!el) return; el.classList.add("hidden"); el.setAttribute("aria-busy", "false"); el.setAttribute("aria-hidden", "true"); document.body.classList.remove("cluster-addons-page-loading"); } /** Страховка на случай зависшего fetch без ответа/reject. */ function armAddonsOverlayFailsafe(ms) { if (addonsOverlayFailsafeTimer !== null) { clearTimeout(addonsOverlayFailsafeTimer); addonsOverlayFailsafeTimer = null; } var delay = typeof ms === "number" && ms > 0 ? ms : 40000; addonsOverlayFailsafeTimer = setTimeout(function () { addonsOverlayFailsafeTimer = null; var el = document.getElementById("addons-page-loading-overlay"); if (!el || el.classList.contains("hidden")) return; hideAddonsPageLoadingOverlay(); setBootstrapMsg( "Превышено время ожидания ответа сервера. Проверьте сеть, прокси и доступность /api/v1, затем обновите страницу.", ); }, delay); } /** * Текстовое сообщение в полосе статуса (нет кластера / ошибка). * @param {string} text */ function renderAddonStatusMessage(text) { if (!statusBar) return; statusBar.className = "cluster-addons-status-bar cluster-addons-status-bar--message muted"; statusBar.replaceChildren(); const p = document.createElement("p"); p.className = "cluster-addons-status-msg"; p.textContent = text; statusBar.appendChild(p); } /** Максимальная длина поля values_yaml на сервере (см. Pydantic max_length). */ var VALUES_YAML_MAX_LEN = 131072; /** * Полоса под селектором кластера: только сообщения (нет кластера / ошибка), без сетки статусов. */ function clearAddonToolbarStatusBar() { if (!statusBar) return; statusBar.className = "cluster-addons-status-bar"; statusBar.replaceChildren(); } /** * Бейджи «Установлен» / «Не установлен» в правом верхнем углу карточки аддона. * @param {object | null | undefined} st ответ GET …/addons/status или null при сбросе */ function updateAddonCardBadges(st) { document.querySelectorAll(".cluster-addon-card[data-addon]").forEach(function (card) { var da = card.getAttribute("data-addon") || ""; var key = ENDPOINT_TO_STATUS_KEY[da]; var on = !!(st && key && st[key]); var badge = card.querySelector(".cluster-addon-card__badge"); if (!badge) return; badge.textContent = on ? "Установлен" : "Не установлен"; badge.className = "cluster-addon-card__badge" + (on ? " cluster-addon-card__badge--on" : " cluster-addon-card__badge--off"); var hum = ADDON_HUMAN_LABEL[da] || da; badge.setAttribute("aria-label", hum + ": " + (on ? "установлен" : "не установлен")); }); } /** * Прикрепить к телу POST YAML из textarea (полные values чарта). Для istio-kiali — три поля. * Пустой textarea → поле не отправляется (сервер соберёт defaults или пропустит -f). * @param {string} ep data-endpoint * @param {Record} payload * @returns {boolean} */ function attachAddonYamlToPayload(ep, payload) { if (ep === "istio-kiali") { var triple = [ { id: "addons-values-yaml-istio-kiali", key: "values_yaml" }, { id: "addons-values-yaml-istio-base", key: "istio_base_values_yaml" }, { id: "addons-values-yaml-istio-istiod", key: "istio_istiod_values_yaml" }, ]; for (var i = 0; i < triple.length; i++) { var ta = document.getElementById(triple[i].id); if (!ta) continue; var s = String(ta.value || "").trim(); if (!s) continue; if (s.length > VALUES_YAML_MAX_LEN) { alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов)."); return false; } payload[triple[i].key] = s; } return true; } var id = "addons-values-yaml-" + ep; var ta2 = document.getElementById(id); if (!ta2) return true; var s2 = String(ta2.value || "").trim(); if (!s2) return true; if (s2.length > VALUES_YAML_MAX_LEN) { alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов)."); return false; } payload.values_yaml = s2; return true; } /** id панели с YAML (скрыта до первой загрузки по кнопке). */ var ADDON_VALUES_PANEL_ID = { "ingress-nginx": "addons-values-panel-ingress-nginx", "kube-prometheus-stack": "addons-values-panel-kube-prometheus-stack", "metrics-server": "addons-values-panel-metrics-server", "istio-kiali": "addons-values-panel-istio-kiali", }; /** * Скрыть блок Values и очистить textarea для одного аддона (смена версии / сброс). * @param {string} addon ключ data-addon / endpoint */ function hideAddonValuesPanel(addon) { var pid = ADDON_VALUES_PANEL_ID[addon]; if (pid) { var panel = document.getElementById(pid); if (panel) { panel.classList.add("hidden"); panel.setAttribute("hidden", "hidden"); panel.setAttribute("aria-hidden", "true"); } } if (addon === "istio-kiali") { ["addons-values-yaml-istio-kiali", "addons-values-yaml-istio-base", "addons-values-yaml-istio-istiod"].forEach( function (tid) { var ta = document.getElementById(tid); if (ta) ta.value = ""; }, ); return; } var ta = document.getElementById("addons-values-yaml-" + addon); if (ta) ta.value = ""; } function showAddonValuesPanel(addon) { var pid = ADDON_VALUES_PANEL_ID[addon]; if (!pid) return; var panel = document.getElementById(pid); if (!panel) return; panel.classList.remove("hidden"); panel.removeAttribute("hidden"); panel.setAttribute("aria-hidden", "false"); } function resetAllAddonValuesPanels() { Object.keys(ADDON_VALUES_PANEL_ID).forEach(function (a) { hideAddonValuesPanel(a); }); } /** * POST /helm/addons/compose-values для одной карточки: версии и поля Grafana читаются в момент нажатия кнопки. * @param {string} addon * @param {HTMLButtonElement | null} triggerBtn */ async function loadComposeValuesForAddon(addon, triggerBtn) { setBootstrapMsg(""); var body = { addon: addon }; if (addon === "ingress-nginx") { var si = document.getElementById("addons-ing-version-select"); if (si && si.value.trim()) body.chart_version = si.value.trim(); } else if (addon === "kube-prometheus-stack") { var pv = document.getElementById("addons-prom-version-select"); var gu = document.getElementById("addons-grafana-user"); var gp = document.getElementById("addons-grafana-pass"); if (pv && pv.value.trim()) body.chart_version = pv.value.trim(); if (gu) body.grafana_admin_user = gu.value.trim() || null; if (gp) body.grafana_admin_password = gp.value || null; } else if (addon === "metrics-server") { var ms = document.getElementById("addons-ms-version-select"); if (ms && ms.value.trim()) body.chart_version = ms.value.trim(); } else if (addon === "istio-kiali") { var isel = document.getElementById("addons-istio-version-select"); var ksel = document.getElementById("addons-kiali-version-select"); if (isel && isel.value.trim()) body.istio_chart_version = isel.value.trim(); if (ksel && ksel.value.trim()) body.kiali_chart_version = ksel.value.trim(); } else { return; } var prevText = triggerBtn ? triggerBtn.textContent : ""; if (triggerBtn) { triggerBtn.disabled = true; triggerBtn.textContent = "Загрузка…"; } try { var r = await api("/helm/addons/compose-values", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify(body), }); if (addon === "istio-kiali") { var tk = document.getElementById("addons-values-yaml-istio-kiali"); var tb = document.getElementById("addons-values-yaml-istio-base"); var tiod = document.getElementById("addons-values-yaml-istio-istiod"); if (tk) tk.value = r.values_yaml || ""; if (tb) tb.value = r.istio_base_values_yaml || ""; if (tiod) tiod.value = r.istio_istiod_values_yaml || ""; } else { var t = document.getElementById("addons-values-yaml-" + addon); if (t) t.value = r.values_yaml || ""; } showAddonValuesPanel(addon); } catch (e) { setBootstrapMsg("Не удалось загрузить values (helm show values): " + e.message); } finally { if (triggerBtn) { triggerBtn.disabled = false; triggerBtn.textContent = prevText || "Загрузить values"; } } } function addonEndpointInstalled(endpoint) { var key = ENDPOINT_TO_STATUS_KEY[endpoint]; return !!(lastAddonsStatus && key && lastAddonsStatus[key]); } /** * Текст «Установить»/«Обновить», видимость «Удалить» только при установленном аддоне; равная ширина — в CSS. */ function updateAddonActionButtons() { document.querySelectorAll(".cluster-addon-card__actions").forEach(function (row) { var installBtn = row.querySelector(".btn-addon-install"); var removeBtn = row.querySelector(".btn-addon-remove"); if (!installBtn) return; var ep = installBtn.getAttribute("data-endpoint") || ""; var on = addonEndpointInstalled(ep); installBtn.textContent = on ? "Обновить" : "Установить"; var hum = ADDON_HUMAN_LABEL[ep] || ep; installBtn.setAttribute("aria-label", (on ? "Обновить " : "Установить ") + hum); if (removeBtn) { if (on) { removeBtn.classList.remove("hidden"); removeBtn.removeAttribute("hidden"); removeBtn.setAttribute("aria-label", "Удалить " + hum); } else { removeBtn.classList.add("hidden"); removeBtn.setAttribute("hidden", "hidden"); removeBtn.removeAttribute("aria-label"); } } }); } /** * Заполнить выпадающий список версиями чарта; первая опция — «последняя». * @param {HTMLSelectElement | null} selectEl * @param {string[] | undefined} versions */ function fillVersionSelect(selectEl, versions) { if (!selectEl) return; const cur = selectEl.value; selectEl.innerHTML = ""; const o0 = document.createElement("option"); o0.value = ""; o0.textContent = "Последняя (из репозитория)"; selectEl.appendChild(o0); (versions || []).forEach(function (v) { if (!v) return; const o = document.createElement("option"); o.value = v; o.textContent = v; selectEl.appendChild(o); }); if (cur) { for (let i = 0; i < selectEl.options.length; i++) { if (selectEl.options[i].value === cur) { selectEl.value = cur; break; } } } } /** * В селекте отметить версию из кластера и подписать опцию; при отсутствии в списке репозитория — добавить опцию. */ function mergeInstalledChartVersion(selectEl, version, isInstalled) { if (!selectEl || !isInstalled || !version) return; var v = String(version).trim(); if (!v) return; var found = false; for (var i = 0; i < selectEl.options.length; i++) { var o = selectEl.options[i]; if (o.value === v) { o.textContent = v + INSTALLED_VER_SUFFIX; found = true; break; } } if (!found) { var nu = document.createElement("option"); nu.value = v; nu.textContent = v + INSTALLED_VER_SUFFIX; selectEl.appendChild(nu); } selectEl.value = v; } /** Подставить в селекты версии из lastAddonsStatus (после fillVersionSelect). */ function mergeChartVersionsFromStatus(st) { if (!st) return; mergeInstalledChartVersion( document.getElementById("addons-ing-version-select"), st.ingress_nginx_chart_version, st.ingress_nginx, ); mergeInstalledChartVersion( document.getElementById("addons-prom-version-select"), st.kube_prometheus_stack_chart_version, st.kube_prometheus_stack, ); mergeInstalledChartVersion( document.getElementById("addons-ms-version-select"), st.metrics_server_chart_version, st.metrics_server, ); var istioVer = st.istiod_chart_version || st.istio_base_chart_version; mergeInstalledChartVersion( document.getElementById("addons-istio-version-select"), istioVer, st.istio_mesh_ready, ); mergeInstalledChartVersion( document.getElementById("addons-kiali-version-select"), st.kiali_server_chart_version, st.istio_mesh_ready, ); } /** * Заполнить textarea из GET …/addons/installed-values; снять панели с аддонов, которые не установлены. * @param {object} iv ответ API */ function applyInstalledValuesPayload(iv) { var st = lastAddonsStatus; if (!iv || !st) return; if (!st.ingress_nginx) { hideAddonValuesPanel("ingress-nginx"); } else if (iv.ingress_nginx && iv.ingress_nginx.values_yaml) { var ti = document.getElementById("addons-values-yaml-ingress-nginx"); if (ti) ti.value = iv.ingress_nginx.values_yaml; showAddonValuesPanel("ingress-nginx"); } if (!st.kube_prometheus_stack) { hideAddonValuesPanel("kube-prometheus-stack"); } else if (iv.kube_prometheus_stack && iv.kube_prometheus_stack.values_yaml) { var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack"); if (tp) tp.value = iv.kube_prometheus_stack.values_yaml; showAddonValuesPanel("kube-prometheus-stack"); } if (!st.metrics_server) { hideAddonValuesPanel("metrics-server"); } else if (iv.metrics_server && iv.metrics_server.values_yaml) { var tm = document.getElementById("addons-values-yaml-metrics-server"); if (tm) tm.value = iv.metrics_server.values_yaml; showAddonValuesPanel("metrics-server"); } if (!st.istio_mesh_ready) { hideAddonValuesPanel("istio-kiali"); } else if (iv.istio_kiali) { var ik = iv.istio_kiali; if (ik.kiali_values_yaml) { var tk = document.getElementById("addons-values-yaml-istio-kiali"); if (tk) tk.value = ik.kiali_values_yaml; } if (ik.istio_base_values_yaml) { var tb = document.getElementById("addons-values-yaml-istio-base"); if (tb) tb.value = ik.istio_base_values_yaml; } if (ik.istiod_values_yaml) { var tiod = document.getElementById("addons-values-yaml-istio-istiod"); if (tiod) tiod.value = ik.istiod_values_yaml; } if (ik.kiali_values_yaml || ik.istio_base_values_yaml || ik.istiod_values_yaml) { showAddonValuesPanel("istio-kiali"); } } } /** * Версии в селектах + YAML из кластера для установленных аддонов (после статуса и списка версий репозитория). * @param {string} clusterName */ async function maybeSyncInstalledAddonsFromCluster(clusterName) { if (!clusterName || !lastAddonsStatus) return; mergeChartVersionsFromStatus(lastAddonsStatus); var st = lastAddonsStatus; var any = st.ingress_nginx || st.kube_prometheus_stack || st.metrics_server || st.istio_mesh_ready; if (!any) { resetAllAddonValuesPanels(); return; } try { var iv = await api("/clusters/" + encodeURIComponent(clusterName) + "/addons/installed-values"); applyInstalledValuesPayload(iv); } catch (e) { setBootstrapMsg("Не удалось загрузить values из кластера: " + e.message); } } async function loadChartVersions() { setBootstrapMsg("Загрузка списка версий Helm-чартов (helm repo update, может занять до минуты)…"); try { const d = await api("/helm/chart-versions"); setBootstrapMsg(""); fillVersionSelect(document.getElementById("addons-ing-version-select"), d.ingress_nginx); fillVersionSelect(document.getElementById("addons-prom-version-select"), d.kube_prometheus_stack); fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server); fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio); fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server); resetAllAddonValuesPanels(); chartVersionsReady = true; var n = getClusterName(); if (n) await maybeSyncInstalledAddonsFromCluster(n); } catch (e) { setBootstrapMsg("Список версий чартов недоступен: " + e.message); } } async function loadClusters() { if (!sel) return; try { const rows = await api("/clusters"); setBootstrapMsg(""); sel.innerHTML = ""; const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; }); if (!withKube.length) { const o = document.createElement("option"); o.value = ""; o.textContent = "Нет кластеров с kubeconfig на диске"; sel.appendChild(o); return; } const o0 = document.createElement("option"); o0.value = ""; o0.textContent = "— выберите кластер —"; sel.appendChild(o0); withKube.forEach(function (c) { const o = document.createElement("option"); o.value = c.name; o.textContent = c.name + (c.kind_nodes_running ? "" : " (узлы остановлены)"); sel.appendChild(o); }); } catch (e) { sel.innerHTML = ""; const o = document.createElement("option"); o.value = ""; o.textContent = "Ошибка загрузки списка"; sel.appendChild(o); setBootstrapMsg("Ошибка GET /clusters: " + e.message); } } async function refreshStatus() { const n = getClusterName(); if (!statusBar) { updateAddonsCardsVisibility(); return; } if (!n) { lastAddonsStatus = null; clearAddonToolbarStatusBar(); updateAddonCardBadges(null); updateAddonActionButtons(); updateAddonsCardsVisibility(); resetAllAddonValuesPanels(); return; } try { const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status"); lastAddonsStatus = st; clearAddonToolbarStatusBar(); updateAddonCardBadges(st); updateAddonActionButtons(); } catch (e) { lastAddonsStatus = null; renderAddonStatusMessage("Статус недоступен: " + e.message); updateAddonCardBadges(null); updateAddonActionButtons(); } updateAddonsCardsVisibility(); if (chartVersionsReady && getClusterName()) { try { await maybeSyncInstalledAddonsFromCluster(getClusterName()); } catch (e2) { /* уже отражено в maybeSync */ } } } async function runInstall(endpoint, payload) { const n = getClusterName(); if (!n) { alert("Выберите кластер."); return; } const isUpdate = addonEndpointInstalled(endpoint); const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint; revealHelmProgressSection(endpoint); setAddonFormsDisabled(true); if (helmHint) { helmHint.textContent = isUpdate ? "Обновление релиза «" + hum + "» через helm в контейнере; журнал под этой карточкой аддона." : "Установка релиза «" + hum + "» через helm в контейнере; журнал под этой карточкой аддона."; } updateHelmProgressBar(8, isUpdate ? "Обновление Helm…" : "Установка Helm…"); setHelmLogText( "Кластер: «" + n + "»\n" + (isUpdate ? "POST (обновление)" : "POST (установка)") + " /api/v1/clusters/…/addons/" + endpoint + "\n\nОжидаем завершение команды (несколько минут — норма)…", ); try { const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: payload !== undefined ? JSON.stringify(payload) : "{}", }); var okLines = [ "Кластер: «" + n + "»", (isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint, "", r.message || "OK", ]; if (r.log) { okLines.push(""); okLines.push(String(r.log)); } setHelmLogText(okLines.join("\n")); updateHelmProgressBar(100, "Готово"); if (helmHint) { helmHint.textContent = "Операция завершена. Полный журнал добавлен в файл helm_addon_log.json в каталоге кластера (история записей)."; } await refreshStatus(); } catch (e) { var failLines = [ "Кластер: «" + n + "»", (isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint, "", "Ошибка: " + e.message, ]; setHelmLogText(failLines.join("\n")); updateHelmProgressBar(100, "Ошибка"); if (helmHint) { helmHint.textContent = "Запрос не удался. При ответе сервера об ошибке Helm в журнале кластера могла появиться запись со статусом failed."; } } finally { setAddonFormsDisabled(false); } } async function runDelete(endpoint) { const n = getClusterName(); if (!n) { alert("Выберите кластер."); return; } const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint; const ok = await openConfirmModal({ title: "Удалить аддон?", message: "Кластер «" + n + "»: снять релиз «" + hum + "» с кластера (helm uninstall). Действие необратимо для установленных компонентов.", confirmLabel: "Удалить", danger: true, }); if (!ok) return; revealHelmProgressSection(endpoint); setAddonFormsDisabled(true); if (helmHint) { helmHint.textContent = "Удаление релиза «" + hum + "» (helm uninstall); журнал под этой карточкой аддона."; } updateHelmProgressBar(8, "Удаление Helm…"); setHelmLogText( "Кластер: «" + n + "»\nDELETE /api/v1/clusters/…/addons/" + endpoint + "\n\nОжидаем завершение команды…", ); try { const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { method: "DELETE", headers: { Accept: "application/json" }, }); var okDel = [ "Кластер: «" + n + "»", "DELETE /addons/" + endpoint, "", r.message || "OK", ]; if (r.log) { okDel.push(""); okDel.push(String(r.log)); } setHelmLogText(okDel.join("\n")); updateHelmProgressBar(100, "Готово"); if (helmHint) { helmHint.textContent = "Операция завершена. Запись добавлена в helm_addon_log.json в каталоге кластера (история)."; } await refreshStatus(); } catch (e) { var failDel = [ "Кластер: «" + n + "»", "DELETE /addons/" + endpoint, "", "Ошибка: " + e.message, ]; setHelmLogText(failDel.join("\n")); updateHelmProgressBar(100, "Ошибка"); if (helmHint) { helmHint.textContent = "Запрос не удался. При ошибке Helm на сервере в журнале кластера могла появиться запись со статусом failed."; } } finally { setAddonFormsDisabled(false); } } document.querySelectorAll(".btn-addon-install").forEach(function (btn) { btn.addEventListener("click", function () { const ep = btn.getAttribute("data-endpoint") || ""; if (ep === "ingress-nginx") { const sel = document.getElementById("addons-ing-version-select"); const v = sel && sel.value.trim(); const payload = {}; if (v) payload.chart_version = v; if (!attachAddonYamlToPayload(ep, payload)) return; runInstall(ep, payload); return; } if (ep === "kube-prometheus-stack") { const u = document.getElementById("addons-grafana-user"); const p = document.getElementById("addons-grafana-pass"); const pv = document.getElementById("addons-prom-version-select"); const user = u ? u.value.trim() : "admin"; const pw = p ? p.value : ""; if (pw.length < 8) { alert("Задайте пароль Grafana не короче 8 символов."); return; } const payload = { grafana_admin_user: user, grafana_admin_password: pw }; if (pv && pv.value.trim()) payload.chart_version = pv.value.trim(); if (!attachAddonYamlToPayload(ep, payload)) return; runInstall(ep, payload); return; } if (ep === "metrics-server") { const ms = document.getElementById("addons-ms-version-select"); const payload = {}; if (ms && ms.value.trim()) payload.chart_version = ms.value.trim(); if (!attachAddonYamlToPayload(ep, payload)) return; runInstall(ep, payload); return; } if (ep === "istio-kiali") { const u = document.getElementById("addons-kiali-user"); const p = document.getElementById("addons-kiali-pass"); const isel = document.getElementById("addons-istio-version-select"); const ksel = document.getElementById("addons-kiali-version-select"); const user = u ? u.value.trim() : "kiali-admin"; const pw = p ? p.value : ""; if (pw.length < 8) { alert("Задайте пароль Kiali не короче 8 символов."); return; } const payload = { kiali_username: user, kiali_password: pw }; if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim(); if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim(); if (!attachAddonYamlToPayload(ep, payload)) return; runInstall(ep, payload); } }); }); document.querySelectorAll(".btn-addon-remove").forEach(function (btn) { btn.addEventListener("click", function () { const ep = btn.getAttribute("data-endpoint") || ""; if (ep) runDelete(ep); }); }); document.querySelectorAll(".btn-addon-load-values").forEach(function (btn) { btn.addEventListener("click", function () { var ad = btn.getAttribute("data-addon") || ""; if (ad) loadComposeValuesForAddon(ad, btn); }); }); const refreshBtn = document.getElementById("addons-refresh-status"); if (refreshBtn) refreshBtn.addEventListener("click", function () { refreshStatus(); }); if (sel) { sel.addEventListener("change", function () { refreshStatus(); }); } bindConfirmModalControls(); /** Смена версии — сбрасываем предпросмотр YAML (устаревший контент). */ var versionSelectToAddon = { "addons-ing-version-select": "ingress-nginx", "addons-prom-version-select": "kube-prometheus-stack", "addons-ms-version-select": "metrics-server", "addons-istio-version-select": "istio-kiali", "addons-kiali-version-select": "istio-kiali", }; Object.keys(versionSelectToAddon).forEach(function (id) { var el = document.getElementById(id); if (el) { el.addEventListener("change", function () { hideAddonValuesPanel(versionSelectToAddon[id]); }); } }); /* Логин/пароль Grafana: если правили после загрузки YAML — скрываем блок, чтобы не путать с устаревшим предпросмотром. */ var grafanaUserEl = document.getElementById("addons-grafana-user"); var grafanaPassEl = document.getElementById("addons-grafana-pass"); function onGrafanaCredChange() { hideAddonValuesPanel("kube-prometheus-stack"); } if (grafanaUserEl) grafanaUserEl.addEventListener("input", onGrafanaCredChange); if (grafanaPassEl) grafanaPassEl.addEventListener("input", onGrafanaCredChange); /** * Оверлей убираем после списка кластеров и статуса — без ожидания GET /helm/chart-versions. * Кластеры/статус оборачиваем в таймаут: иначе при «висящем» fetch await не завершается и finally не вызывается. */ armAddonsOverlayFailsafe(45000); (async function bootstrapAddonsPage() { try { await promiseWithTimeout( loadClusters(), 30000, "Таймаут: список кластеров не получен за 30 с. Проверьте GET /api/v1/clusters.", ); await promiseWithTimeout( refreshStatus(), 30000, "Таймаут: статус аддонов не получен за 30 с.", ); } catch (e) { var msg0 = e && e.message ? e.message : String(e); setBootstrapMsg(msg0); } finally { hideAddonsPageLoadingOverlay(); } try { await promiseWithTimeout( loadChartVersions(), 180000, "Таймаут: версии Helm-чартов не получены за 3 мин. (helm repo update).", ); } catch (e) { var msg1 = e && e.message ? e.message : String(e); setBootstrapMsg("Не удалось загрузить версии чартов: " + msg1); } })(); })();