From d55e69c75600e7db912198a64b197c03f305a691 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 14:13:01 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B0=D0=B4=D0=B4=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0;=20API=20=D0=B0=D0=B4=D0=B4=D0=BE=D0=BD=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Отдельная section.card после сводки ресурсов узлов, выравнивание бейджа Helm с заголовком - Расширение эндпоинтов аддонов, схемы, helm_addons, cluster_addons.js/html - Обновление api_routes.md --- app/api/v1/endpoints/cluster_addons.py | 69 +++- app/core/helm_addons.py | 192 +++++++++- app/docs/api_routes.md | 7 +- app/models/schemas.py | 42 ++ app/static/js/cluster_addons.js | 506 +++++++++++++++++++++---- app/static/js/dashboard.js | 121 ++++++ app/static/style.css | 205 +++++++++- app/templates/cluster_addons.html | 300 +++++++++------ app/templates/cluster_detail.html | 25 ++ 9 files changed, 1238 insertions(+), 229 deletions(-) diff --git a/app/api/v1/endpoints/cluster_addons.py b/app/api/v1/endpoints/cluster_addons.py index 2a95ef1..ef15eac 100644 --- a/app/api/v1/endpoints/cluster_addons.py +++ b/app/api/v1/endpoints/cluster_addons.py @@ -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, ) diff --git a/app/core/helm_addons.py b/app/core/helm_addons.py index 94d33ee..2e38b11 100644 --- a/app/core/helm_addons.py +++ b/app/core/helm_addons.py @@ -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]: diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 364e429..cc451cc 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -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 | diff --git a/app/models/schemas.py b/app/models/schemas.py index 97b2587..cd76e84 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -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): diff --git a/app/static/js/cluster_addons.js b/app/static/js/cluster_addons.js index 6eddab3..772dd28 100644 --- a/app/static/js/cluster_addons.js +++ b/app/static/js/cluster_addons.js @@ -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} 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 | 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); + } + })(); })(); diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 5f0c891..9f19b95 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -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 недоступны: " + diff --git a/app/static/style.css b/app/static/style.css index ccc0ab2..9833996 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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); } diff --git a/app/templates/cluster_addons.html b/app/templates/cluster_addons.html index a81bd8f..ff34303 100644 --- a/app/templates/cluster_addons.html +++ b/app/templates/cluster_addons.html @@ -18,7 +18,7 @@ {% endblock %} {% block content %} -{# Полноэкранный спиннер до загрузки списка кластеров, статуса и версий чартов; скрывается в cluster_addons.js #} +{# Полноэкранный спиннер до списка кластеров и статуса; версии чартов подгружаются после (helm repo update может быть долгим). #}
- Загрузка списка кластеров и версий чартов… + Загрузка списка кластеров…
@@ -68,26 +68,31 @@

Контроллер Ingress.

- - + Версия чарта +
+ + +
-
- Values чарта (YAML) -

- Содержимое из helm show values плюс сервис для kind; подставляется при смене версии чарта. После -f Helm применяет --set для NodePort. -

- - -
-
+ +
@@ -108,26 +113,35 @@
- - + Версия чарта +
+ + +
-
- Values чарта (YAML) -

- Вывод helm show values с подставленными логином и паролем Grafana из полей выше; обновляется при смене версии или учётных данных. Пустое поле при установке — сервер соберёт values сам. -

- - -
-
+ +
@@ -140,26 +154,35 @@

Метрики узлов и подов для kubectl top; для kind включён --kubelet-insecure-tls.

- - + Версия чарта +
+ + +
-
- Values чарта (YAML) -

- helm show values плюс аргументы для kind (--kubelet-insecure-tls и т.д.). Пустое поле — автосборка на сервере. -

- - -
-
+ +
@@ -195,41 +218,49 @@
+
+ Предпросмотр YAML +
+ +
+
-
- Values чартов Istio и Kiali (YAML) -

- Три блока из helm show values для выбранных версий. Логин и пароль Kiali вносятся в секрет Kubernetes отдельно; для kiali-server дополнительно задаётся auth.strategy=login через --set. -

-

kiali-server

- - -

istio/base

- - -

istio/istiod

- - -
-
+ +
@@ -237,40 +268,57 @@
-{# Журнал операции Helm: те же блоки, что на странице создания кластера (прогресс + job-log-panel). Без полноэкранного спиннера. #} - +

Ход операции Helm

+

+ Выполняется команда в контейнере веб-приложения; полный вывод появится по завершении. +

+
+
+
+

+
+ +

+    
+ + + +{# Если внешний cluster_addons.js не выполнился (404, ошибка синтаксиса), оверлей снимается через 50 с. #} + {% include "partials/dashboard_modals.html" %} {% endblock %} diff --git a/app/templates/cluster_detail.html b/app/templates/cluster_detail.html index 0747149..7866575 100644 --- a/app/templates/cluster_detail.html +++ b/app/templates/cluster_detail.html @@ -65,6 +65,31 @@
+{# Карточка «Установленные аддоны» — после сводки по узлам (показ/содержимое — dashboard.js, GET …/addons/status). #} + +

Ресурсы узлов