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
+65 -4
View File
@@ -22,7 +22,8 @@ from fastapi import APIRouter, Body, HTTPException
from core.cluster_lifecycle import validate_cluster_name
from core.helm_addons import (
HelmAddonError,
addon_status,
collect_installed_addons_values,
helm_addon_snapshot,
require_helm_binary,
install_ingress_nginx,
install_istio_and_kiali,
@@ -36,8 +37,11 @@ from core.helm_addons import (
from core.kind_guard import kind_cluster_lock
from core.provision_log import write_cluster_helm_addon_log
from models.schemas import (
ClusterAddonsInstalledValuesResponse,
ClusterAddonsStatusResponse,
HelmAddonApplyResponse,
InstalledAddonValuesBlob,
IstioKialiInstalledValuesBlob,
IngressNginxInstallRequest,
IstioKialiInstallRequest,
KubePrometheusStackInstallRequest,
@@ -151,10 +155,10 @@ async def get_cluster_addons_status(name: str) -> ClusterAddonsStatusResponse:
except HelmAddonError as e:
raise _map_helm_error(e) from e
try:
st = await asyncio.to_thread(addon_status, n)
st = await asyncio.to_thread(helm_addon_snapshot, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
mesh = bool(st.get("istio_base") and st.get("istiod") and st.get("kiali"))
mesh = bool(st.get("istio_mesh_ready"))
return ClusterAddonsStatusResponse(
cluster_name=n,
ingress_nginx=bool(st.get("ingress_nginx")),
@@ -162,8 +166,65 @@ async def get_cluster_addons_status(name: str) -> ClusterAddonsStatusResponse:
metrics_server=bool(st.get("metrics_server")),
istio_base=bool(st.get("istio_base")),
istiod=bool(st.get("istiod")),
kiali_server=bool(st.get("kiali")),
kiali_server=bool(st.get("kiali_server")),
istio_mesh_ready=mesh,
ingress_nginx_chart_version=st.get("ingress_nginx_chart_version"),
kube_prometheus_stack_chart_version=st.get("kube_prometheus_stack_chart_version"),
metrics_server_chart_version=st.get("metrics_server_chart_version"),
istiod_chart_version=st.get("istiod_chart_version"),
istio_base_chart_version=st.get("istio_base_chart_version"),
kiali_server_chart_version=st.get("kiali_server_chart_version"),
)
@router.get(
"/clusters/{name}/addons/installed-values",
response_model=ClusterAddonsInstalledValuesResponse,
summary="Values установленных аддонов из кластера (helm get values --all)",
)
async def get_cluster_addons_installed_values(name: str) -> ClusterAddonsInstalledValuesResponse:
"""
Для каждого установленного логического аддона возвращает версию чарта (как в ``helm list``)
и полный YAML эффективных values (``helm get values --all -o yaml``) для правки перед upgrade.
"""
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
try:
require_helm_binary()
except HelmAddonError as e:
raise _map_helm_error(e) from e
try:
raw = await asyncio.to_thread(collect_installed_addons_values, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
def blob(key: str) -> InstalledAddonValuesBlob | None:
b = raw.get(key)
if not isinstance(b, dict):
return None
return InstalledAddonValuesBlob(
chart_version=b.get("chart_version"),
values_yaml=str(b.get("values_yaml") or ""),
)
istio = raw.get("istio_kiali")
istio_model: IstioKialiInstalledValuesBlob | None = None
if isinstance(istio, dict):
istio_model = IstioKialiInstalledValuesBlob(
istio_chart_version=istio.get("istio_chart_version"),
kiali_chart_version=istio.get("kiali_chart_version"),
istio_base_values_yaml=str(istio.get("istio_base_values_yaml") or ""),
istiod_values_yaml=str(istio.get("istiod_values_yaml") or ""),
kiali_values_yaml=str(istio.get("kiali_values_yaml") or ""),
)
return ClusterAddonsInstalledValuesResponse(
cluster_name=n,
ingress_nginx=blob("ingress_nginx"),
kube_prometheus_stack=blob("kube_prometheus_stack"),
metrics_server=blob("metrics_server"),
istio_kiali=istio_model,
)
+181 -11
View File
@@ -358,18 +358,188 @@ def helm_list_all(cluster_name: str) -> list[dict[str, Any]]:
return []
def addon_status(cluster_name: str) -> dict[str, bool]:
"""Какие аддоны установлены (по имени релиза и namespace)."""
lst = helm_list_all(cluster_name)
names_ns = {(str(x.get("name", "")), str(x.get("namespace", ""))) for x in lst if isinstance(x, dict)}
return {
"ingress_nginx": ("ingress-nginx", "ingress-nginx") in names_ns,
"kube_prometheus_stack": ("kube-prometheus-stack", "monitoring") in names_ns,
"metrics_server": ("metrics-server", "kube-system") in names_ns,
"istio_base": ("istio-base", "istio-system") in names_ns,
"istiod": ("istiod", "istio-system") in names_ns,
"kiali": ("kiali-server", "istio-system") in names_ns,
def helm_release_entry(
lst: list[dict[str, Any]],
release: str,
namespace: str,
) -> dict[str, Any] | None:
"""Одна запись ``helm list -o json`` по имени релиза и namespace."""
for x in lst:
if not isinstance(x, dict):
continue
if str(x.get("name", "")) == release and str(x.get("namespace", "")) == namespace:
return x
return None
def _chart_version_from_list_field(chart: str, release_name: str) -> str | None:
"""
Поле ``chart`` в выводе ``helm list`` имеет вид ``<имя_чарта>-<версия>`` (например ``ingress-nginx-4.11.2``).
Для релиза Istio base в списке часто ``base-1.22.3``.
"""
chart = (chart or "").strip()
if not chart:
return None
prefixes: dict[str, str] = {
"ingress-nginx": "ingress-nginx-",
"kube-prometheus-stack": "kube-prometheus-stack-",
"metrics-server": "metrics-server-",
"istio-base": "base-",
"istiod": "istiod-",
"kiali-server": "kiali-server-",
}
p = prefixes.get(release_name)
if p and chart.startswith(p):
v = chart[len(p) :].strip()
return v or None
return chart
def helm_get_values_all_yaml(cluster_name: str, release: str, namespace: str) -> str:
"""
Эффективные values релиза: ``helm get values --all -o yaml`` (для правки при upgrade).
"""
kcfg = str(_kubeconfig_runtime_path(cluster_name))
rc, out, err = _run_cmd(
[
"helm",
"get",
"values",
release,
"-n",
namespace,
"--all",
"-o",
"yaml",
"--kubeconfig",
kcfg,
],
timeout=120,
)
if rc != 0:
raise HelmAddonError(
(err or out or f"helm get values {release}/{namespace} код {rc}")[:8000],
exit_code=rc,
)
return (out or "").strip()
_VALUES_YAML_API_MAX = 131_072
def trim_values_yaml_for_api(raw: str) -> str:
"""Ограничение размера YAML в JSON-ответе API."""
if len(raw) <= _VALUES_YAML_API_MAX:
return raw
return raw[: _VALUES_YAML_API_MAX - 120] + "\n\n# … YAML обрезан (лимит ответа API).\n"
def helm_addon_snapshot(cluster_name: str) -> dict[str, Any]:
"""
Флаги установки, версии чартов из ``helm list`` и признак полного mesh Istio+Kiali.
"""
lst = helm_list_all(cluster_name)
def pair(rel: str, ns: str) -> tuple[bool, str | None]:
e = helm_release_entry(lst, rel, ns)
if not e:
return False, None
ch = str(e.get("chart") or "")
ver = _chart_version_from_list_field(ch, rel)
return True, ver
ing_on, ing_v = pair("ingress-nginx", "ingress-nginx")
prom_on, prom_v = pair("kube-prometheus-stack", "monitoring")
ms_on, ms_v = pair("metrics-server", "kube-system")
base_on, base_v = pair("istio-base", "istio-system")
istiod_on, istiod_v = pair("istiod", "istio-system")
kiali_on, kiali_v = pair("kiali-server", "istio-system")
mesh = bool(base_on and istiod_on and kiali_on)
return {
"ingress_nginx": ing_on,
"ingress_nginx_chart_version": ing_v if ing_on else None,
"kube_prometheus_stack": prom_on,
"kube_prometheus_stack_chart_version": prom_v if prom_on else None,
"metrics_server": ms_on,
"metrics_server_chart_version": ms_v if ms_on else None,
"istio_base": base_on,
"istiod": istiod_on,
"kiali_server": kiali_on,
"istio_base_chart_version": base_v if base_on else None,
"istiod_chart_version": istiod_v if istiod_on else None,
"kiali_server_chart_version": kiali_v if kiali_on else None,
"istio_mesh_ready": mesh,
}
def addon_status(cluster_name: str) -> dict[str, bool]:
"""Какие аддоны установлены (по имени релиза и namespace). Совместимость со старым кодом."""
s = helm_addon_snapshot(cluster_name)
return {
"ingress_nginx": bool(s["ingress_nginx"]),
"kube_prometheus_stack": bool(s["kube_prometheus_stack"]),
"metrics_server": bool(s["metrics_server"]),
"istio_base": bool(s["istio_base"]),
"istiod": bool(s["istiod"]),
"kiali": bool(s["kiali_server"]),
}
def collect_installed_addons_values(cluster_name: str) -> dict[str, Any]:
"""
Для установленных релизов — версия чарта (как в snapshot) и YAML ``helm get values --all``.
Ключи совпадают с логическими аддонами страницы UI.
"""
s = helm_addon_snapshot(cluster_name)
out: dict[str, Any] = {}
if s["ingress_nginx"]:
try:
y = trim_values_yaml_for_api(
helm_get_values_all_yaml(cluster_name, "ingress-nginx", "ingress-nginx"),
)
except HelmAddonError as e:
y = f"# Не удалось прочитать values: {e}\n"
out["ingress_nginx"] = {"chart_version": s["ingress_nginx_chart_version"], "values_yaml": y}
if s["kube_prometheus_stack"]:
try:
y = trim_values_yaml_for_api(
helm_get_values_all_yaml(cluster_name, "kube-prometheus-stack", "monitoring"),
)
except HelmAddonError as e:
y = f"# Не удалось прочитать values: {e}\n"
out["kube_prometheus_stack"] = {"chart_version": s["kube_prometheus_stack_chart_version"], "values_yaml": y}
if s["metrics_server"]:
try:
y = trim_values_yaml_for_api(
helm_get_values_all_yaml(cluster_name, "metrics-server", "kube-system"),
)
except HelmAddonError as e:
y = f"# Не удалось прочитать values: {e}\n"
out["metrics_server"] = {"chart_version": s["metrics_server_chart_version"], "values_yaml": y}
if s["istio_mesh_ready"]:
try:
kb = trim_values_yaml_for_api(
helm_get_values_all_yaml(cluster_name, "istio-base", "istio-system"),
)
ki = trim_values_yaml_for_api(helm_get_values_all_yaml(cluster_name, "istiod", "istio-system"))
kk = trim_values_yaml_for_api(
helm_get_values_all_yaml(cluster_name, "kiali-server", "istio-system"),
)
except HelmAddonError as e:
kb = ki = kk = f"# Не удалось прочитать values: {e}\n"
out["istio_kiali"] = {
"istio_chart_version": s["istiod_chart_version"] or s["istio_base_chart_version"],
"kiali_chart_version": s["kiali_server_chart_version"],
"istio_base_values_yaml": kb,
"istiod_values_yaml": ki,
"kiali_values_yaml": kk,
}
return out
def _kubectl_apply_manifest(cluster_name: str, manifest: dict[str, Any]) -> tuple[int, str]:
+4 -3
View File
@@ -21,9 +21,9 @@
|---------|----------|
| `GET /` | HTML **Панель**: CTA создания кластера, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. |
| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, отдельная карточка под панелью действий — **мини-карточки установленных Helm-аддонов** (**`GET /api/v1/clusters/{name}/addons/status`**, ссылки на **`/cluster-addons`**), **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). |
| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. В шапке активна **Кластеры**. |
| `GET /cluster-addons` | HTML **Аддоны**: **`GET /helm/chart-versions`**, **`POST /helm/addons/compose-values`** (подстановка **реальных** `helm show values` + логины в редактор), три YAML для Istio, установка/удаление релизов. |
| `GET /cluster-addons` | HTML **Аддоны**: **`GET /helm/chart-versions`** + **`GET …/addons/status`**; для **установленных** релизов — **`GET …/addons/installed-values`**: в селекте версия с пометкой «(текущая установленная версия)», в форме — values из кластера. По кнопке **«Загрузить values»** — шаблон из **`POST /helm/addons/compose-values`**. Установка/удаление релизов. |
| `GET /journal` | HTML **Журналы**: переключатель (как «Простой/Расширенный» в редактировании кластера) — **по кластеру** (`journal/recent?cluster=`), **развёртывание** (`/journal/provision`), **Helm-аддоны** (`/journal/helm-addons`); пагинация по **30** записей. |
| `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path`**`GET /api/v1/docs/readme`**, с `?path=app/docs/…`**`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2****одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. |
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
@@ -57,7 +57,8 @@
| GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) |
| POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта |
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| GET | `/api/v1/clusters/{name}/addons/status` | Статус Helm-релизов (ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali); нужен **helm** в образе |
| GET | `/api/v1/clusters/{name}/addons/status` | Статус Helm-релизов + **версии чартов** из `helm list` (`ingress_nginx_chart_version`, `kube_prometheus_stack_chart_version`, `metrics_server_chart_version`, `istiod_chart_version`, `istio_base_chart_version`, `kiali_server_chart_version`), если релиз установлен; нужен **helm** |
| GET | `/api/v1/clusters/{name}/addons/installed-values` | Для **установленных** аддонов: эффективный YAML (`helm get values --all -o yaml`) и версии; поля `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio_kiali` (или `null`); лимит размера YAML в ответе |
| GET | `/api/v1/helm/chart-versions` | Версии чартов для UI (после `helm repo update`); кэш **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`**; поля: `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio`, `kiali_server` |
| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx**; тело: опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта, см. **compose-values** и раздел ниже) |
| DELETE | `/api/v1/clusters/{name}/addons/ingress-nginx` | Удалить ingress-nginx |
+42
View File
@@ -306,6 +306,48 @@ class ClusterAddonsStatusResponse(BaseModel):
istio_mesh_ready: bool = Field(
description="Установлены istio-base, istiod и kiali-server в istio-system",
)
ingress_nginx_chart_version: str | None = Field(
default=None,
description="Версия чарта из helm list (если релиз установлен)",
)
kube_prometheus_stack_chart_version: str | None = Field(default=None)
metrics_server_chart_version: str | None = Field(default=None)
istiod_chart_version: str | None = Field(
default=None,
description="Версия чарта istiod в кластере (для селекта Istio на UI)",
)
istio_base_chart_version: str | None = Field(default=None)
kiali_server_chart_version: str | None = Field(default=None)
class InstalledAddonValuesBlob(BaseModel):
"""Снимок values одного релиза (helm get values --all)."""
chart_version: str | None = Field(default=None, description="Версия чарта из helm list")
values_yaml: str = Field(default="", description="YAML эффективных values")
class IstioKialiInstalledValuesBlob(BaseModel):
"""Три релиза mesh + версии для селектов UI."""
istio_chart_version: str | None = Field(
default=None,
description="Версия istiod (или base), для селекта «Версия чартов Istio»",
)
kiali_chart_version: str | None = None
istio_base_values_yaml: str = ""
istiod_values_yaml: str = ""
kiali_values_yaml: str = ""
class ClusterAddonsInstalledValuesResponse(BaseModel):
"""Values из кластера только для установленных аддонов (для редактирования перед upgrade)."""
cluster_name: str
ingress_nginx: InstalledAddonValuesBlob | None = None
kube_prometheus_stack: InstalledAddonValuesBlob | None = None
metrics_server: InstalledAddonValuesBlob | None = None
istio_kiali: IstioKialiInstalledValuesBlob | None = None
class HelmAddonApplyResponse(BaseModel):
+416 -62
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() {
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 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,
};
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-batch", {
var r = await api("/helm/addons/compose-values", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(batchBody),
body: JSON.stringify(body),
});
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");
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 (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 (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);
setBootstrapMsg("Не удалось загрузить values (helm show values): " + e.message);
} finally {
if (triggerBtn) {
triggerBtn.disabled = false;
triggerBtn.textContent = prevText || "Загрузить values";
}
}
function scheduleComposeValuesRefresh() {
if (composeDebounceTimer) clearTimeout(composeDebounceTimer);
composeDebounceTimer = setTimeout(function () {
composeDebounceTimer = null;
refreshAllComposeValuesFromServer();
}, COMPOSE_DEBOUNCE_MS);
}
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);
}
+68 -20
View File
@@ -18,7 +18,7 @@
{% endblock %}
{% block content %}
{# Полноэкранный спиннер до загрузки списка кластеров, статуса и версий чартов; скрывается в cluster_addons.js #}
{# Полноэкранный спиннер до списка кластеров и статуса; версии чартов подгружаются после (helm repo update может быть долгим). #}
<div
id="addons-page-loading-overlay"
class="page-loading-overlay"
@@ -30,7 +30,7 @@
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span class="page-loading-label">Загрузка списка кластеров и версий чартов</span>
<span class="page-loading-label">Загрузка списка кластеров…</span>
</div>
</div>
@@ -68,15 +68,19 @@
</header>
<p class="muted cluster-addon-card__desc">Контроллер Ingress.</p>
<div class="create-form-field">
<label for="addons-ing-version-select">Версия чарта</label>
<span class="cluster-addon-card__field-label" id="addons-ing-version-label">Версия чарта</span>
<div class="cluster-addon-version-row" role="group" aria-labelledby="addons-ing-version-label">
<select id="addons-ing-version-select" class="cluster-addons-version-select" aria-label="Версия чарта ingress-nginx">
<option value="">Последняя (из репозитория)</option>
</select>
<button type="button" class="btn-secondary btn-addon-load-values" data-addon="ingress-nginx">Загрузить values</button>
</div>
<details class="cluster-addon-card__values-details">
</div>
<div id="addons-values-panel-ingress-nginx" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open>
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
Содержимое из <code>helm show values</code> плюс сервис для kind; подставляется при смене версии чарта. После <code>-f</code> Helm применяет <code>--set</code> для NodePort.
Содержимое из <code>helm show values</code> плюс сервис для kind. После <code>-f</code> Helm применяет <code>--set</code> для NodePort. Нажмите «Загрузить values» для выбранной версии.
</p>
<label for="addons-values-yaml-ingress-nginx" class="sr-only">Полные YAML values для ingress-nginx</label>
<textarea
@@ -87,7 +91,8 @@
aria-label="Полные YAML values чарта ingress-nginx"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
</div>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--inline">
<button type="button" class="btn-addon-install" data-endpoint="ingress-nginx">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="ingress-nginx" hidden>Удалить</button>
</div>
@@ -108,15 +113,23 @@
<input id="addons-grafana-pass" type="password" minlength="8" maxlength="256" autocomplete="new-password" placeholder="не менее 8 символов" />
</div>
<div class="create-form-field">
<label for="addons-prom-version-select">Версия чарта</label>
<select id="addons-prom-version-select" class="cluster-addons-version-select">
<span class="cluster-addon-card__field-label" id="addons-prom-version-label">Версия чарта</span>
<div class="cluster-addon-version-row" role="group" aria-labelledby="addons-prom-version-label">
<select
id="addons-prom-version-select"
class="cluster-addons-version-select"
aria-labelledby="addons-prom-version-label"
>
<option value="">Последняя (из репозитория)</option>
</select>
<button type="button" class="btn-secondary btn-addon-load-values" data-addon="kube-prometheus-stack">Загрузить values</button>
</div>
<details class="cluster-addon-card__values-details">
</div>
<div id="addons-values-panel-kube-prometheus-stack" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open>
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
Вывод <code>helm show values</code> с подставленными логином и паролем Grafana из полей выше; обновляется при смене версии или учётных данных. Пустое поле при установке — сервер соберёт values сам.
Вывод <code>helm show values</code> с подстановкой логина и пароля Grafana из полей выше на момент нажатия «Загрузить values». Пустой textarea при установке — сервер соберёт values сам.
</p>
<label for="addons-values-yaml-kube-prometheus-stack" class="sr-only">Полные YAML values для kube-prometheus-stack</label>
<textarea
@@ -127,7 +140,8 @@
aria-label="Полные YAML values чарта kube-prometheus-stack"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
</div>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--inline">
<button type="button" class="btn-addon-install" data-endpoint="kube-prometheus-stack">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="kube-prometheus-stack" hidden>Удалить</button>
</div>
@@ -140,15 +154,23 @@
</header>
<p class="muted cluster-addon-card__desc">Метрики узлов и подов для <code>kubectl top</code>; для kind включён <code>--kubelet-insecure-tls</code>.</p>
<div class="create-form-field">
<label for="addons-ms-version-select">Версия чарта</label>
<select id="addons-ms-version-select" class="cluster-addons-version-select">
<span class="cluster-addon-card__field-label" id="addons-ms-version-label">Версия чарта</span>
<div class="cluster-addon-version-row" role="group" aria-labelledby="addons-ms-version-label">
<select
id="addons-ms-version-select"
class="cluster-addons-version-select"
aria-labelledby="addons-ms-version-label"
>
<option value="">Последняя (из репозитория)</option>
</select>
<button type="button" class="btn-secondary btn-addon-load-values" data-addon="metrics-server">Загрузить values</button>
</div>
<details class="cluster-addon-card__values-details">
</div>
<div id="addons-values-panel-metrics-server" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open>
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
<code>helm show values</code> плюс аргументы для kind (<code>--kubelet-insecure-tls</code> и т.д.). Пустое поле — автосборка на сервере.
<code>helm show values</code> плюс аргументы для kind (<code>--kubelet-insecure-tls</code> и т.д.). Пустое поле при установке — автосборка на сервере.
</p>
<label for="addons-values-yaml-metrics-server" class="sr-only">Полные YAML values для metrics-server</label>
<textarea
@@ -159,7 +181,8 @@
aria-label="Полные YAML values чарта metrics-server"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
</div>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--inline">
<button type="button" class="btn-addon-install" data-endpoint="metrics-server">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="metrics-server" hidden>Удалить</button>
</div>
@@ -195,11 +218,18 @@
<option value="">Последняя (из репозитория)</option>
</select>
</div>
<div class="create-form-field cluster-addon-istio-load-field">
<span class="cluster-addon-card__field-label" id="addons-istio-values-label">Предпросмотр YAML</span>
<div class="cluster-addon-version-row" role="group" aria-labelledby="addons-istio-values-label">
<button type="button" class="btn-secondary btn-addon-load-values" data-addon="istio-kiali">Загрузить values</button>
</div>
<details class="cluster-addon-card__values-details">
</div>
</div>
<div id="addons-values-panel-istio-kiali" class="cluster-addon-card__values-panel hidden" hidden aria-hidden="true">
<details class="cluster-addon-card__values-details" open>
<summary class="cluster-addon-card__values-summary">Values чартов Istio и Kiali (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
Три блока из <code>helm show values</code> для выбранных версий. Логин и пароль Kiali вносятся в секрет Kubernetes отдельно; для kiali-server дополнительно задаётся <code>auth.strategy=login</code> через <code>--set</code>.
Три блока из <code>helm show values</code> для выбранных версий Istio и Kiali. Логин и пароль Kiali задаются в секрете при установке; здесь — только шаблон values чартов.
</p>
<p class="cluster-addon-card__values-subhead">kiali-server</p>
<label for="addons-values-yaml-istio-kiali" class="sr-only">YAML values kiali-server</label>
@@ -229,7 +259,8 @@
aria-label="Полные YAML values чарта istio/istiod"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
</div>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--inline">
<button type="button" class="btn-addon-install" data-endpoint="istio-kiali">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="istio-kiali" hidden>Удалить</button>
</div>
@@ -237,7 +268,8 @@
</div>
</div>
{# Журнал операции Helm: те же блоки, что на странице создания кластера (прогресс + job-log-panel). Без полноэкранного спиннера. #}
{# Ход Helm: секция в dock; при установке/удалении переносится внутрь карточки аддона (cluster_addons.js). #}
<div id="addons-helm-progress-dock" class="cluster-addons-helm-dock" aria-hidden="true">
<section
id="addons-helm-progress-wrap"
class="card create-progress-wrap cluster-addons-helm-progress hidden"
@@ -271,6 +303,22 @@
></pre>
</div>
</section>
</div>
{# Если внешний cluster_addons.js не выполнился (404, ошибка синтаксиса), оверлей снимается через 50 с. #}
<script>
(function () {
var ms = 50000;
setTimeout(function () {
var el = document.getElementById("addons-page-loading-overlay");
if (!el || el.classList.contains("hidden")) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("cluster-addons-page-loading");
}, ms);
})();
</script>
{% include "partials/dashboard_modals.html" %}
{% endblock %}
+25
View File
@@ -65,6 +65,31 @@
<div id="cluster-page-aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
</section>
{# Карточка «Установленные аддоны» — после сводки по узлам (показ/содержимое — dashboard.js, GET …/addons/status). #}
<section
id="cluster-detail-addons-section"
class="card cluster-detail-addons-card hidden"
hidden
aria-hidden="true"
aria-labelledby="cluster-detail-addons-heading"
>
<header class="cluster-detail-addons-card__header">
<div class="cluster-detail-addons-card__head-row">
<span class="cluster-detail-addons-card__helm-badge" aria-hidden="true">Helm</span>
<h2 id="cluster-detail-addons-heading" class="cluster-detail-section-title cluster-detail-addons-card__title">
Установленные аддоны
</h2>
</div>
<p class="cluster-detail-addons-card__lead muted">
Релизы в кластере по данным <code>helm list</code>. Нажмите на карточку, чтобы перейти к установке, обновлению и values.
</p>
</header>
<div id="cluster-detail-addons-strip" class="cluster-detail-addons-grid" role="list"></div>
<footer class="cluster-detail-addons-card__footer">
<a href="/cluster-addons" class="cluster-detail-addons-card__cta">Все аддоны и настройки →</a>
</footer>
</section>
<section class="card cluster-detail-nodes-card" aria-labelledby="cluster-nodes-heading">
<h2 id="cluster-nodes-heading" class="cluster-detail-section-title">Ресурсы узлов</h2>
<div