- Kiali: убран login, anonymous по умолчанию; удалены поля логина/пароля из UI и API - Журнал Helm: install/upgrade/delete, message и колонка в journal.js - Аддоны: values свёрнуты при подгрузке для установленных - GET …/kubeconfig/docker: host.docker.internal:порт + tls-server-name; кнопка в UI - apply_apiserver_endpoint_to_kubeconfig_file; KIND_K8S_APISERVER_GATEWAY_HOST в compose/env.example - README и api_routes.md обновлены
1110 lines
43 KiB
JavaScript
1110 lines
43 KiB
JavaScript
/**
|
||
* Страница 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<boolean>}
|
||
*/
|
||
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<unknown>} 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<string, unknown>} 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 = "";
|
||
}
|
||
|
||
/**
|
||
* Показать панель YAML values. Для установленного аддона при подгрузке values — блок <details> свёрнут.
|
||
* @param {string} addon
|
||
* @param {{ collapseDetails?: boolean } | undefined} opts
|
||
*/
|
||
function showAddonValuesPanel(addon, opts) {
|
||
opts = opts || {};
|
||
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");
|
||
var det = panel.querySelector("details.cluster-addon-card__values-details");
|
||
if (det) {
|
||
det.open = opts.collapseDetails ? false : true;
|
||
}
|
||
}
|
||
|
||
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, { collapseDetails: addonEndpointInstalled(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", { collapseDetails: true });
|
||
}
|
||
|
||
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", { collapseDetails: true });
|
||
}
|
||
|
||
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", { collapseDetails: true });
|
||
}
|
||
|
||
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", { collapseDetails: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Версии в селектах + 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 isel = document.getElementById("addons-istio-version-select");
|
||
const ksel = document.getElementById("addons-kiali-version-select");
|
||
const payload = {};
|
||
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);
|
||
}
|
||
})();
|
||
})();
|