UI: карточка установленных аддонов на странице кластера; API аддонов и правки стилей

- Отдельная section.card после сводки ресурсов узлов, выравнивание бейджа Helm с заголовком
- Расширение эндпоинтов аддонов, схемы, helm_addons, cluster_addons.js/html
- Обновление api_routes.md
This commit is contained in:
Sergey Antropoff
2026-04-04 14:13:01 +03:00
parent eb063aec20
commit d55e69c756
9 changed files with 1238 additions and 229 deletions
+430 -76
View File
@@ -8,7 +8,9 @@
"use strict";
const body = document.body;
if ((body.getAttribute("data-dashboard-mode") || "") !== "addons") return;
/* 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(/\/$/, "");
@@ -30,6 +32,12 @@
/** Последний успешный снимок статуса (для подписи «Установить» / «Обновить»). */
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;
@@ -149,6 +157,37 @@
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");
@@ -189,11 +228,54 @@
bootstrapMsg.textContent = s;
}
/** Показать карточку «Ход операции Helm» (как create-progress-wrap на странице создания кластера). */
function revealHelmProgressSection() {
/** Известные 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) {
@@ -227,8 +309,15 @@
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");
@@ -237,6 +326,24 @@
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
@@ -322,60 +429,118 @@
return true;
}
var COMPOSE_DEBOUNCE_MS = 600;
/** @type {ReturnType<typeof setTimeout> | null} */
var composeDebounceTimer = null;
/** 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",
};
/**
* Заполнить все textarea: один POST **compose-values-batch** (один helm repo update на сервере).
* Скрыть блок Values и очистить textarea для одного аддона (смена версии / сброс).
* @param {string} addon ключ data-addon / endpoint
*/
async function refreshAllComposeValuesFromServer() {
setBootstrapMsg("");
var si = document.getElementById("addons-ing-version-select");
var pv = document.getElementById("addons-prom-version-select");
var ms = document.getElementById("addons-ms-version-select");
var isel = document.getElementById("addons-istio-version-select");
var ksel = document.getElementById("addons-kiali-version-select");
var gu = document.getElementById("addons-grafana-user");
var gp = document.getElementById("addons-grafana-pass");
var batchBody = {
ingress_chart_version: si && si.value.trim() ? si.value.trim() : null,
kube_prometheus_chart_version: pv && pv.value.trim() ? pv.value.trim() : null,
metrics_server_chart_version: ms && ms.value.trim() ? ms.value.trim() : null,
istio_chart_version: isel && isel.value.trim() ? isel.value.trim() : null,
kiali_chart_version: ksel && ksel.value.trim() ? ksel.value.trim() : null,
grafana_admin_user: gu ? gu.value.trim() || null : null,
grafana_admin_password: gp ? gp.value || null : null,
};
try {
var r = await api("/helm/addons/compose-values-batch", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(batchBody),
});
var ti = document.getElementById("addons-values-yaml-ingress-nginx");
var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack");
var tm = document.getElementById("addons-values-yaml-metrics-server");
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 (ti) ti.value = r.ingress_nginx_values_yaml || "";
if (tp) tp.value = r.kube_prometheus_values_yaml || "";
if (tm) tm.value = r.metrics_server_values_yaml || "";
if (tk) tk.value = r.kiali_values_yaml || "";
if (tb) tb.value = r.istio_base_values_yaml || "";
if (tiod) tiod.value = r.istio_istiod_values_yaml || "";
} catch (e) {
setBootstrapMsg("Не удалось подставить values из чартов (helm show values): " + e.message);
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 scheduleComposeValuesRefresh() {
if (composeDebounceTimer) clearTimeout(composeDebounceTimer);
composeDebounceTimer = setTimeout(function () {
composeDebounceTimer = null;
refreshAllComposeValuesFromServer();
}, COMPOSE_DEBOUNCE_MS);
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) {
@@ -440,7 +605,143 @@
}
}
/**
* В селекте отметить версию из кластера и подписать опцию; при отсутствии в списке репозитория — добавить опцию.
*/
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("");
@@ -449,7 +750,10 @@
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);
await refreshAllComposeValuesFromServer();
resetAllAddonValuesPanels();
chartVersionsReady = true;
var n = getClusterName();
if (n) await maybeSyncInstalledAddonsFromCluster(n);
} catch (e) {
setBootstrapMsg("Список версий чартов недоступен: " + e.message);
}
@@ -501,6 +805,7 @@
updateAddonCardBadges(null);
updateAddonActionButtons();
updateAddonsCardsVisibility();
resetAllAddonValuesPanels();
return;
}
try {
@@ -516,6 +821,13 @@
updateAddonActionButtons();
}
updateAddonsCardsVisibility();
if (chartVersionsReady && getClusterName()) {
try {
await maybeSyncInstalledAddonsFromCluster(getClusterName());
} catch (e2) {
/* уже отражено в maybeSync */
}
}
}
async function runInstall(endpoint, payload) {
@@ -526,12 +838,12 @@
}
const isUpdate = addonEndpointInstalled(endpoint);
const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
revealHelmProgressSection();
revealHelmProgressSection(endpoint);
setAddonFormsDisabled(true);
if (helmHint) {
helmHint.textContent = isUpdate
? "Обновление релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении."
: "Установка релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении.";
? "Обновление релиза «" + hum + "» через helm в контейнере; журнал под этой карточкой аддона."
: "Установка релиза «" + hum + "» через helm в контейнере; журнал под этой карточкой аддона.";
}
updateHelmProgressBar(8, isUpdate ? "Обновление Helm…" : "Установка Helm…");
setHelmLogText(
@@ -603,11 +915,11 @@
danger: true,
});
if (!ok) return;
revealHelmProgressSection();
revealHelmProgressSection(endpoint);
setAddonFormsDisabled(true);
if (helmHint) {
helmHint.textContent =
"Удаление релиза «" + hum + "» (helm uninstall); ниже — полный вывод по завершении.";
"Удаление релиза «" + hum + "» (helm uninstall); журнал под этой карточкой аддона.";
}
updateHelmProgressBar(8, "Удаление Helm…");
setHelmLogText(
@@ -716,6 +1028,13 @@
});
});
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(); });
@@ -725,29 +1044,64 @@
bindConfirmModalControls();
[
"addons-ing-version-select",
"addons-prom-version-select",
"addons-ms-version-select",
"addons-istio-version-select",
"addons-kiali-version-select",
].forEach(function (id) {
/** Смена версии — сбрасываем предпросмотр 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 () { scheduleComposeValuesRefresh(); });
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");
if (grafanaUserEl) grafanaUserEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
if (grafanaPassEl) grafanaPassEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
function onGrafanaCredChange() {
hideAddonValuesPanel("kube-prometheus-stack");
}
if (grafanaUserEl) grafanaUserEl.addEventListener("input", onGrafanaCredChange);
if (grafanaPassEl) grafanaPassEl.addEventListener("input", onGrafanaCredChange);
loadClusters()
.then(function () {
return refreshStatus();
})
.then(function () {
return loadChartVersions();
})
.finally(function () {
/**
* Оверлей убираем после списка кластеров и статуса — без ожидания 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);
}
})();
})();
+121
View File
@@ -1557,6 +1557,125 @@
bindActionTooltipHosts(host);
}
/**
* Скрыть секцию «Установленные аддоны» (#cluster-detail-addons-section) и очистить сетку.
*/
function clearClusterDetailAddonsStrip() {
var strip = document.getElementById("cluster-detail-addons-strip");
var section = document.getElementById("cluster-detail-addons-section");
if (strip) strip.replaceChildren();
if (section) {
section.classList.add("hidden");
section.setAttribute("hidden", "");
section.setAttribute("aria-hidden", "true");
}
}
/**
* Мини-карточки установленных Helm-аддонов в отдельной section.card под hero.
* @param {string} clusterName
* @param {object} st ответ GET /clusters/…/addons/status
*/
function renderClusterDetailAddonsStrip(clusterName, st) {
var strip = document.getElementById("cluster-detail-addons-strip");
var section = document.getElementById("cluster-detail-addons-section");
if (!strip || !section || !st) {
clearClusterDetailAddonsStrip();
return;
}
var items = [];
if (st.ingress_nginx) {
items.push({
title: "ingress-nginx",
ver: st.ingress_nginx_chart_version,
ns: "ingress-nginx",
});
}
if (st.kube_prometheus_stack) {
items.push({
title: "kube-prometheus-stack",
ver: st.kube_prometheus_stack_chart_version,
ns: "monitoring",
});
}
if (st.metrics_server) {
items.push({
title: "metrics-server",
ver: st.metrics_server_chart_version,
ns: "kube-system",
});
}
if (st.istio_mesh_ready) {
var iv = st.istiod_chart_version || st.istio_base_chart_version;
var kv = st.kiali_server_chart_version;
var parts = [];
if (iv) parts.push("Istio " + iv);
if (kv) parts.push("Kiali " + kv);
items.push({
title: "Istio + Kiali",
verLine: parts.length ? parts.join(" · ") : null,
ns: "istio-system",
});
}
strip.replaceChildren();
if (!items.length) {
section.classList.add("hidden");
section.setAttribute("hidden", "");
section.setAttribute("aria-hidden", "true");
return;
}
section.classList.remove("hidden");
section.removeAttribute("hidden");
section.setAttribute("aria-hidden", "false");
items.forEach(function (it) {
var a = document.createElement("a");
a.className = "cluster-detail-addon-mini";
a.href = "/cluster-addons";
a.setAttribute("role", "listitem");
a.setAttribute(
"title",
"Открыть страницу аддонов Helm; выберите кластер «" + clusterName + "» для настройки.",
);
var t = document.createElement("span");
t.className = "cluster-detail-addon-mini__title";
t.textContent = it.title;
a.appendChild(t);
var v = document.createElement("span");
v.className = "cluster-detail-addon-mini__ver";
if (it.verLine) {
v.textContent = it.verLine;
} else if (it.ver) {
v.textContent = "Чарт " + it.ver;
} else {
v.textContent = "Версия чарта не определена";
}
a.appendChild(v);
var n = document.createElement("span");
n.className = "cluster-detail-addon-mini__ns";
n.textContent = "ns: " + it.ns;
a.appendChild(n);
strip.appendChild(a);
});
}
/**
* Загрузить статус аддонов и обновить карточку «Установленные аддоны» (при наличии kubeconfig на диске).
* @param {string} clusterName
* @param {boolean} hasLocalKubeconfig
*/
async function refreshClusterDetailAddonsStrip(clusterName, hasLocalKubeconfig) {
if (!clusterName || !hasLocalKubeconfig) {
clearClusterDetailAddonsStrip();
return;
}
try {
var st = await api("/clusters/" + encodeURIComponent(clusterName) + "/addons/status");
renderClusterDetailAddonsStrip(clusterName, st);
} catch (e) {
clearClusterDetailAddonsStrip();
}
}
/**
* Загрузка ``GET /clusters/{name}/overview`` и заполнение блоков страницы кластера.
* @param {string} name
@@ -1578,6 +1697,7 @@
has_local_kubeconfig: !!d.has_local_kubeconfig,
has_provision_log: !!d.has_provision_log,
});
await refreshClusterDetailAddonsStrip(d.cluster_name, !!d.has_local_kubeconfig);
const cliRaw =
(cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
renderAggregateDonutsToRoot(
@@ -1611,6 +1731,7 @@
fillClusterK8sTablesFromOverview(d);
} catch (e) {
const errText = e && e.message ? e.message : String(e);
clearClusterDetailAddonsStrip();
if (msg) {
msg.textContent =
"Кластер или API недоступны: " +
+196 -9
View File
@@ -2097,6 +2097,150 @@ html[data-theme="light"] .cluster-detail-actions-card {
margin: 0;
}
/* Карточка «Установленные аддоны» на странице кластера (отдельный section.card) */
.cluster-detail-addons-card.hidden {
display: none;
}
.cluster-detail-addons-card__header {
margin-bottom: 1rem;
}
.cluster-detail-addons-card__head-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 0.45rem;
}
/* Бейдж и заголовок в одной линии по вертикали: сброс margin у .cluster-detail-section-title (он задаётся ниже по файлу). */
.cluster-detail-addons-card__head-row h2.cluster-detail-section-title {
margin: 0;
line-height: 1.25;
}
.cluster-detail-addons-card__helm-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
align-self: center;
padding: 0.22rem 0.55rem;
min-height: 1.35em;
font-size: 0.68rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #fff;
border-radius: 6px;
background: linear-gradient(135deg, #0f766e 0%, #0d9488 40%, #14b8a6 100%);
box-shadow: 0 1px 3px rgba(15, 118, 110, 0.35);
}
.cluster-detail-addons-card__title {
margin: 0;
font-size: 1.12rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
.cluster-detail-addons-card__lead {
margin: 0;
max-width: 44rem;
font-size: 0.86rem;
line-height: 1.5;
}
.cluster-detail-addons-card__lead code {
font-size: 0.82em;
}
.cluster-detail-addons-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15.5rem, 1fr));
gap: 0.75rem 1rem;
width: 100%;
box-sizing: border-box;
}
.cluster-detail-addon-mini {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 0.75rem 0.85rem 0.7rem;
min-height: 5.5rem;
border-radius: 10px;
border: 1px solid var(--border);
border-left: 4px solid rgba(59, 130, 246, 0.75);
background: var(--surface);
color: inherit;
text-decoration: none;
box-sizing: border-box;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.15s ease,
background 0.18s ease;
}
html[data-theme="light"] .cluster-detail-addon-mini {
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
}
.cluster-detail-addon-mini:hover {
border-color: rgba(59, 130, 246, 0.4);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.12);
transform: translateY(-2px);
background: rgba(59, 130, 246, 0.04);
}
.cluster-detail-addon-mini:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.65);
outline-offset: 3px;
}
.cluster-detail-addon-mini__title {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-weight: 600;
font-size: 0.82rem;
line-height: 1.3;
color: var(--text);
}
.cluster-detail-addon-mini__ver {
font-size: 0.78rem;
line-height: 1.4;
color: var(--muted);
word-break: break-word;
flex: 1 1 auto;
}
.cluster-detail-addon-mini__ns {
display: inline-flex;
align-items: center;
margin-top: auto;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
color: var(--muted);
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;
}
html[data-theme="light"] .cluster-detail-addon-mini__ns {
background: rgba(15, 23, 42, 0.06);
}
.cluster-detail-addons-card__footer {
margin-top: 1rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border);
}
.cluster-detail-addons-card__cta {
font-size: 0.88rem;
font-weight: 600;
color: rgba(59, 130, 246, 0.95);
text-decoration: none;
transition: color 0.15s ease;
}
.cluster-detail-addons-card__cta:hover {
color: rgba(96, 165, 250, 1);
text-decoration: underline;
}
.cluster-detail-addons-card__cta:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.55);
outline-offset: 2px;
border-radius: 4px;
}
.cluster-k8s-scope-hint {
margin: 0 0 0.85rem;
font-size: 0.86rem;
@@ -2998,26 +3142,27 @@ html[data-theme="light"] .cluster-addon-card__values-textarea {
font-size: 0.85rem;
line-height: 1.45;
}
.cluster-addon-card__actions--equal {
/* Кнопки «Установить / Обновить / Удалить» — по содержимому, не на всю ширину карточки */
.cluster-addon-card__actions--inline {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
width: 100%;
flex-wrap: wrap;
margin-top: 0.85rem;
gap: 0.5rem;
align-items: stretch;
align-items: center;
justify-content: flex-start;
}
.cluster-addon-card__actions--equal .btn-addon-install,
.cluster-addon-card__actions--equal .btn-addon-remove {
flex: 1 1 0;
min-width: 0;
.cluster-addon-card__actions--inline .btn-addon-install,
.cluster-addon-card__actions--inline .btn-addon-remove {
flex: 0 0 auto;
min-width: 7.5rem;
min-height: 2.65rem;
margin-top: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.cluster-addon-card--wide {
@@ -3026,10 +3171,52 @@ html[data-theme="light"] .cluster-addon-card__values-textarea {
.cluster-addon-istio-grid {
margin-top: 0.25rem;
}
.cluster-addon-istio-load-field {
grid-column: 1 / -1;
}
.cluster-addon-card__field-label {
display: block;
margin-bottom: 0.35rem;
font-size: 0.88rem;
font-weight: 600;
color: var(--text);
}
.cluster-addon-version-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.65rem;
}
.cluster-addon-version-row .cluster-addons-version-select {
flex: 1 1 12rem;
width: auto;
min-width: 10rem;
max-width: 22rem;
}
.cluster-addon-card__values-panel {
margin-top: 0.65rem;
}
.btn-addon-load-values {
flex: 0 0 auto;
white-space: nowrap;
}
/* Якорь для блока прогресса до первой операции; пустой, когда секция перенесена в карточку */
.cluster-addons-helm-dock:empty {
display: none;
}
.cluster-addons-helm-dock:not(:empty) {
margin-bottom: 1.5rem;
}
/* Карточка хода Helm на странице аддонов (разметка как у create-progress-wrap + job-log-panel) */
.cluster-addons-helm-progress {
margin-bottom: 1.5rem;
}
.cluster-addon-card > .cluster-addons-helm-progress {
margin-top: 1rem;
margin-bottom: 0;
width: 100%;
box-sizing: border-box;
}
.cluster-addons-helm-progress .job-log-panel {
max-height: min(55vh, 420px);
}