diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 890d5e2..c05f613 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -21,12 +21,20 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi.responses import FileResponse, JSONResponse from starlette.background import BackgroundTask +from core.cluster_config_edit import ( + ClusterConfigEditError, + NOTE_NOT_IN_KIND, + NOTE_REGISTERED_IN_KIND, + apply_cluster_config_update, + read_cluster_config_bundle, +) from core.cluster_lifecycle import ( KindClusterError, cluster_summary_for_api, configured_container_cli, create_cluster_non_interactive, delete_kind_cluster_and_data, + delete_kind_cluster_keep_data, kubectl_delete_pod, kubectl_get_json, kubectl_get_all_namespaces_wide, @@ -60,6 +68,9 @@ from kind_k8s_paths import clusters_dir from kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host from models.schemas import ( AggregateResourcesSummary, + ClusterConfigGetResponse, + ClusterConfigUpdateRequest, + ClusterConfigUpdateResponse, ClusterCreateAccepted, ClusterCreateRequest, ClusterOverviewResponse, @@ -74,6 +85,11 @@ from models.schemas import ( logger = logging.getLogger("kind_k8s.api.clusters") +# Сообщение UI: узлы kind остановлены — kubectl до API кластера не достучится (DNS control-plane и т.д.). +_K8S_CLUSTER_STOPPED_MSG = ( + "Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели)." +) + # Безопасные имена namespace/pod для kubectl (без shell-метасимволов). _K8S_NAME_SAFE = re.compile(r"^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$") @@ -239,6 +255,100 @@ async def list_clusters() -> list[ClusterSummary]: return out +def _cluster_summary_model(name: str) -> ClusterSummary: + """Сводка кластера для ответов API (как в GET /clusters).""" + summary = cluster_summary_for_api(name) + running_map = running_kind_clusters_mask([name]) + return ClusterSummary( + name=str(summary["name"]), + registered_in_kind=bool(summary["registered_in_kind"]), + kind_nodes_running=bool(running_map.get(name, False)), + has_local_kubeconfig=bool(summary["has_local_kubeconfig"]), + has_provision_log=bool(summary.get("has_provision_log")), + meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {}, + ) + + +@router.get( + "/clusters/{name}/config", + response_model=ClusterConfigGetResponse, + summary="Конфигурация кластера (meta + kind-config.yaml)", + responses={404: {"description": "Каталог кластера не найден"}}, +) +async def get_cluster_config(name: str) -> ClusterConfigGetResponse: + """ + Текущие ``meta.json`` и текст ``kind-config.yaml`` из ``clusters/<имя>/``. + Поле ``kind_note`` напоминает: у уже созданного в kind кластера смена YAML на диске не меняет узлы до пересоздания. + """ + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + try: + bundle = await asyncio.to_thread(read_cluster_config_bundle, n) + except ClusterConfigEditError as e: + if e.not_found: + raise HTTPException(status_code=404, detail=str(e)) from e + raise HTTPException(status_code=400, detail=str(e)) from e + summary = await asyncio.to_thread(cluster_summary_for_api, n) + reg = bool(summary["registered_in_kind"]) + note = NOTE_REGISTERED_IN_KIND if reg else NOTE_NOT_IN_KIND + return ClusterConfigGetResponse( + cluster_name=str(bundle["cluster_name"]), + meta=dict(bundle["meta"]) if isinstance(bundle.get("meta"), dict) else {}, + kind_config_yaml=bundle.get("kind_config_yaml"), + has_kind_config=bool(bundle.get("has_kind_config")), + registered_in_kind=reg, + kind_note=note, + ) + + +@router.put( + "/clusters/{name}/config", + response_model=ClusterConfigUpdateResponse, + summary="Обновить конфигурацию кластера на диске", + responses={404: {"description": "Каталог кластера не найден"}}, +) +async def put_cluster_config(name: str, body: ClusterConfigUpdateRequest) -> ClusterConfigUpdateResponse: + """ + Сохраняет изменения в ``kind-config.yaml`` и/или ``meta.json``. + + Режимы: (1) непустой ``kind_config_yaml`` — полная замена файла с проверкой структуры kind Cluster; + (2) ``kubernetes_version`` и/или ``workers`` — пересборка простого конфига (как при создании); + (3) ``description`` — заметка в meta (пустая строка сбрасывает поле). + + Kind не применяет новый YAML к уже работающим нодам — см. ``kind_note`` в ответе. + """ + n = name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + try: + updated_yaml, msg = await asyncio.to_thread( + apply_cluster_config_update, + n, + kubernetes_version=body.kubernetes_version, + workers=body.workers, + kind_config_yaml=body.kind_config_yaml, + description=body.description, + ) + except ClusterConfigEditError as e: + if e.not_found: + raise HTTPException(status_code=404, detail=str(e)) from e + raise HTTPException(status_code=400, detail=str(e)) from e + summary_api = await asyncio.to_thread(cluster_summary_for_api, n) + reg = bool(summary_api["registered_in_kind"]) + note = NOTE_REGISTERED_IN_KIND if reg else NOTE_NOT_IN_KIND + summ = await asyncio.to_thread(_cluster_summary_model, n) + logger.info("Обновлена конфигурация кластера «%s», yaml=%s", n, updated_yaml) + return ClusterConfigUpdateResponse( + cluster_name=n, + updated_kind_config_yaml=updated_yaml, + message=msg, + registered_in_kind=reg, + kind_note=note, + summary=summ, + ) + + @router.get( "/clusters/{name}/kubeconfig", summary="Скачать kubeconfig", @@ -329,22 +439,28 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse: """``kubectl get nodes`` и ``kubectl get pods -A`` по сохранённому kubeconfig.""" if not validate_cluster_name(name): raise HTTPException(status_code=400, detail="Некорректное имя кластера") - kc = clusters_dir() / name / "kubeconfig" + n = name.strip() + kc = clusters_dir() / n / "kubeconfig" if not kc.is_file(): - return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/") + return ClusterWorkloadsResponse(cluster_name=n, error="Нет сохранённого kubeconfig в clusters/<имя>/") + + summary = cluster_summary_for_api(n) + run_mask = await asyncio.to_thread(running_kind_clusters_mask, [n]) + if bool(summary["registered_in_kind"]) and not bool(run_mask.get(n, False)): + return ClusterWorkloadsResponse(cluster_name=n, error=_K8S_CLUSTER_STOPPED_MSG) nodes_rc, nodes_out = await asyncio.to_thread( kubectl_nodes_wide, - cluster_name=name, + cluster_name=n, kubeconfig=kc, ) pods_rc, pods_out = await asyncio.to_thread( kubectl_pods_all_namespaces, - cluster_name=name, + cluster_name=n, kubeconfig=kc, ) return ClusterWorkloadsResponse( - cluster_name=name, + cluster_name=n, nodes_rc=nodes_rc, nodes_output=nodes_out, pods_rc=pods_rc, @@ -387,6 +503,8 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse: k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None) k8s_pvcs = K8sListJsonBlock(ok=False, items=[], message=None) + reg_in_kind = bool(summary["registered_in_kind"]) + if not kc.is_file(): kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/" _kube_missing = K8sListJsonBlock(ok=False, items=[], message=kube_err) @@ -394,6 +512,15 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse: k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _kube_missing k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing k8s_jobs = k8s_cronjobs = k8s_pvcs = _kube_missing + elif reg_in_kind and not kind_running: + # Не вызываем kubectl: контейнеры узлов остановлены — в логах был бы шум про dev-control-plane / DNS. + kube_err = _K8S_CLUSTER_STOPPED_MSG + _stopped = K8sListJsonBlock(ok=False, items=[], message=_K8S_CLUSTER_STOPPED_MSG) + k8s_nodes = _stopped + k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _stopped + k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _stopped + k8s_jobs = k8s_cronjobs = k8s_pvcs = _stopped + logger.debug("overview %s: kubectl пропущен — кластер в kind, узлы не запущены", n) else: ( n_res, @@ -443,7 +570,7 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse: meta = summary.get("meta") return ClusterOverviewResponse( cluster_name=n, - registered_in_kind=bool(summary["registered_in_kind"]), + registered_in_kind=reg_in_kind, kind_nodes_running=kind_running, has_local_kubeconfig=bool(summary["has_local_kubeconfig"]), has_provision_log=bool(summary.get("has_provision_log")), @@ -653,6 +780,89 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: end_job_tracking(job_id) +async def _run_reapply_kind_config_start_job( + job_id: str, name: str, kubernetes_version_tag: str, workers: int +) -> None: + """ + После правки kind-config.yaml: удалить кластер из kind без удаления каталога данных, + затем ``kind create`` по сохранённому файлу (новый ``meta.json`` без флага reapply). + """ + register_uncapped_job_log(job_id) + n = name.strip() + try: + async with kind_cluster_lock: + await job_store.set_running(job_id) + try: + await asyncio.to_thread(delete_kind_cluster_keep_data, name=n, job_id=job_id) + except KindClusterError as e: + msg = str(e) + if "отмен" in msg.lower(): + await job_store.set_cancelled(job_id, msg) + else: + await job_store.set_failed(job_id, msg) + logger.warning("reapply start job %s: delete_kind: %s", job_id, e) + return + except Exception as e: + await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") + logger.exception("reapply start job %s: ошибка delete", job_id) + return + + try: + result = await asyncio.to_thread( + create_cluster_non_interactive, + name=n, + kubernetes_version_tag=kubernetes_version_tag.strip(), + workers=workers, + job_id=job_id, + use_existing_config=True, + ) + except KindClusterError as e: + msg = str(e) + if "отмен" in msg.lower(): + await job_store.set_cancelled(job_id, msg) + else: + await job_store.set_failed(job_id, msg) + logger.warning("reapply start job %s: create: %s", job_id, e) + return + except Exception as e: + await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") + logger.exception("reapply start job %s: непредвиденная ошибка create", job_id) + return + + payload: dict[str, Any] = { + "cluster_name": result.cluster_name, + "kubernetes_version_tag": result.ver_tag, + "node_image": result.node_image, + "workers": result.workers, + "kubeconfig_path": str(result.kubeconfig_path), + "kubeconfig_patched_for_host": result.kubeconfig_patched_for_host, + "nodes_ready": result.nodes_ready, + "nodes_ready_message": result.nodes_ready_message, + } + await job_store.set_success( + job_id, + result=payload, + message="Кластер пересоздан в kind по обновлённому kind-config.yaml", + ) + logger.info("reapply start job %s: успех, кластер %s", job_id, result.cluster_name) + finally: + lines = take_uncapped_log_finalize(job_id) + rec = await job_store.get(job_id) + if rec is not None: + await asyncio.to_thread( + lambda: write_cluster_provision_log( + cluster_name=n, + job_id=job_id, + job_kind="start_cluster_reapply", + status=rec.status, + message=rec.message, + lines=lines, + result=rec.result, + ), + ) + end_job_tracking(job_id) + + async def _run_stop_cluster_job(job_id: str, name: str) -> None: """Фоновая остановка узлов с журналом в GET /jobs/{id}.""" n = name.strip() @@ -811,10 +1021,41 @@ async def start_cluster_nodes( n = name.strip() + meta = read_meta_json(n) or {} + ver_raw = str(meta.get("kubernetes_version_tag") or "v1.29.4").strip() or "v1.29.4" + w_raw = meta.get("worker_nodes") + try: + w = int(w_raw) if w_raw is not None else 0 + except (TypeError, ValueError): + w = 0 + async with kind_cluster_lock: in_kind = n in await asyncio.to_thread(list_registered_kind_clusters) if in_kind: + if bool(meta.get("apply_kind_config_on_next_start")): + cfg = clusters_dir() / n / "kind-config.yaml" + if not cfg.is_file(): + raise HTTPException( + status_code=400, + detail="Нет файла clusters/<имя>/kind-config.yaml — восстановите конфиг или снимите флаг пересоздания в meta.json.", + ) + rec = await job_store.create_job("start_cluster_reapply", cluster_name=n) + background_tasks.add_task(_run_reapply_kind_config_start_job, rec.job_id, n, ver_raw, w) + logger.info( + "Пересоздание кластера %s в kind по новому конфигу (фон), job_id=%s", + n, + rec.job_id, + ) + return JSONResponse( + status_code=202, + content={ + "job_id": rec.job_id, + "status": "queued", + "mode": "kind_config_reapply", + "message": "Удаление записи в kind и создание кластера по обновлённому kind-config.yaml; GET /api/v1/jobs/{job_id}", + }, + ) rec = await job_store.create_job("start_containers", cluster_name=n) background_tasks.add_task(_run_start_containers_job, rec.job_id, n) logger.info("Запуск контейнеров кластера %s в фоне, job_id=%s", n, rec.job_id) @@ -835,14 +1076,6 @@ async def start_cluster_nodes( detail="Кластер не в kind и нет файла clusters/<имя>/kind-config.yaml — создайте кластер или восстановите конфиг.", ) - meta = read_meta_json(n) or {} - ver_raw = str(meta.get("kubernetes_version_tag") or "v1.29.4").strip() or "v1.29.4" - w_raw = meta.get("worker_nodes") - try: - w = int(w_raw) if w_raw is not None else 0 - except (TypeError, ValueError): - w = 0 - rec = await job_store.create_job("start_cluster", cluster_name=n) background_tasks.add_task(_run_start_cluster_job, rec.job_id, n, ver_raw, w) logger.info("Фоновый старт кластера %s по конфигу, job_id=%s", n, rec.job_id) diff --git a/app/core/cluster_config_edit.py b/app/core/cluster_config_edit.py new file mode 100644 index 0000000..4765981 --- /dev/null +++ b/app/core/cluster_config_edit.py @@ -0,0 +1,215 @@ +"""Редактирование ``kind-config.yaml`` и ``meta.json`` в каталоге кластера. + +Kind **не** меняет число нод и образ узлов у уже созданного кластера: сохранённые файлы +используются при следующем ``kind create`` (например после ``kind delete`` и кнопки «Старт» +или полного пересоздания). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import yaml + +from core.cluster_lifecycle import build_kind_config_yaml, read_meta_json, validate_cluster_name +from kindest_node_tags import normalize_tag_v_prefix +from kind_k8s_paths import clusters_dir, data_root + +logger = logging.getLogger("kind_k8s.cluster_config_edit") + +# Пояснение для UI: до следующего «Старт» kind продолжает использовать старые узлы; после сохранения +# конфига выставляется флаг — при «Старт» выполняется kind delete + create по новому kind-config.yaml. +NOTE_REGISTERED_IN_KIND = ( + "Конфиг сохранён. При следующем «Старт» кластер будет пересоздан в kind по обновлённому " + "kind-config.yaml (запись в kind удаляется и создаётся заново; каталог clusters/<имя>/ сохраняется). " + "Пока вы не нажали «Старт», работают прежние узлы." +) + +NOTE_NOT_IN_KIND = ( + "Конфигурация сохранена. При «Старт» или создании кластера будет использован этот файл." +) + + +def validate_kind_cluster_yaml_root(data: Any) -> str | None: + """ + Проверить распарсенный YAML корня конфига kind. + + Возвращает текст ошибки для пользователя или ``None``, если структура допустима. + """ + if not isinstance(data, dict): + return "Корень YAML должен быть объектом (mapping)." + if data.get("kind") != "Cluster": + return "Ожидается поле kind: Cluster." + api = str(data.get("apiVersion") or "") + if "kind.x-k8s.io" not in api: + return "Ожидается apiVersion с группой kind.x-k8s.io (например kind.x-k8s.io/v1alpha4)." + nodes = data.get("nodes") + if not isinstance(nodes, list) or len(nodes) < 1: + return "Нужен непустой список nodes." + has_cp = False + for i, n in enumerate(nodes): + if not isinstance(n, dict): + return f"Элемент nodes[{i}] должен быть объектом." + role = n.get("role") + if role == "control-plane": + has_cp = True + elif role != "worker": + return f"Узел nodes[{i}]: role должен быть control-plane или worker." + if not has_cp: + return "В nodes должна быть хотя бы одна нода с role: control-plane." + return None + + +def _count_workers(nodes: list[Any]) -> int: + return sum(1 for n in nodes if isinstance(n, dict) and n.get("role") == "worker") + + +def _first_image_from_nodes(nodes: list[Any]) -> str | None: + for n in nodes: + if isinstance(n, dict): + img = n.get("image") + if isinstance(img, str) and img.strip(): + return img.strip() + return None + + +def _tag_from_node_image(image: str) -> str | None: + m = re.search(r":([^:]+)$", image.strip()) + return m.group(1) if m else None + + +class ClusterConfigEditError(Exception): + """Ошибка валидации или отсутствия каталога (сообщение для HTTP 400/404).""" + + def __init__(self, message: str, *, not_found: bool = False) -> None: + super().__init__(message) + self.not_found = not_found + + +def apply_cluster_config_update( + cluster_name: str, + *, + kubernetes_version: str | None, + workers: int | None, + kind_config_yaml: str | None, + description: str | None, +) -> tuple[bool, str]: + """ + Обновить ``kind-config.yaml`` и/или поля ``meta.json``. + + Возвращает (обновлён ли YAML, краткое сообщение для ответа API). + """ + if not validate_cluster_name(cluster_name): + raise ClusterConfigEditError("Некорректное имя кластера") + + out_dir = clusters_dir() / cluster_name + if not out_dir.is_dir(): + raise ClusterConfigEditError("Каталог кластера не найден", not_found=True) + + meta: dict[str, Any] = dict(read_meta_json(cluster_name) or {}) + cfg_path = out_dir / "kind-config.yaml" + root = data_root() + updated_yaml = False + + yaml_raw = (kind_config_yaml or "").strip() + if yaml_raw: + try: + parsed = yaml.safe_load(yaml_raw) + except yaml.YAMLError as e: + raise ClusterConfigEditError(f"Ошибка разбора YAML: {e}") from e + err = validate_kind_cluster_yaml_root(parsed) + if err: + raise ClusterConfigEditError(err) + if not yaml_raw.endswith("\n"): + yaml_raw += "\n" + cfg_path.write_text(yaml_raw, encoding="utf-8") + updated_yaml = True + nodes = parsed.get("nodes") or [] + if isinstance(nodes, list): + meta["worker_nodes"] = _count_workers(nodes) + img = _first_image_from_nodes(nodes) + if img: + meta["node_image"] = img + tag = _tag_from_node_image(img) + if tag: + meta["kubernetes_version_tag"] = tag + logger.info("Обновлён kind-config.yaml (расширенный режим) для кластера «%s»", cluster_name) + elif kubernetes_version is not None or workers is not None: + ver_src = (kubernetes_version or "").strip() or str(meta.get("kubernetes_version_tag") or "v1.29.4") + ver_tag = normalize_tag_v_prefix(ver_src) + w_val = meta.get("worker_nodes") + try: + w_default = int(w_val) if w_val is not None else 0 + except (TypeError, ValueError): + w_default = 0 + w = workers if workers is not None else w_default + if w < 0 or w > 20: + raise ClusterConfigEditError("Число worker-нод должно быть от 0 до 20.") + node_image = f"kindest/node:{ver_tag}" + text = build_kind_config_yaml(node_image=node_image, workers=w) + cfg_path.write_text(text, encoding="utf-8") + updated_yaml = True + meta["kubernetes_version_tag"] = ver_tag + meta["node_image"] = node_image + meta["worker_nodes"] = w + logger.info( + "Пересобран kind-config.yaml для «%s»: workers=%s, образ %s", + cluster_name, + w, + node_image, + ) + elif description is None: + raise ClusterConfigEditError("Укажите версию/workers, полный YAML kind-config или описание.") + + # После смены kind-config.yaml при «Старт» (кластер в kind) — пересоздание, а не docker start. + if updated_yaml: + meta["apply_kind_config_on_next_start"] = True + + if description is not None: + d = description.strip() + if d: + meta["description"] = d[:2000] + else: + meta.pop("description", None) + + meta["cluster_name"] = cluster_name + if cfg_path.is_file(): + try: + meta["kind_config_path"] = str(cfg_path.relative_to(root)) + except ValueError: + meta["kind_config_path"] = str(cfg_path) + + meta_path = out_dir / "meta.json" + meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + logger.info("Обновлён meta.json для кластера «%s»", cluster_name) + + return updated_yaml, "Сохранено." + + +def read_cluster_config_bundle(cluster_name: str) -> dict[str, Any]: + """Собрать данные для GET /clusters/{name}/config (без флагов kind — их добавляет эндпоинт).""" + if not validate_cluster_name(cluster_name): + raise ClusterConfigEditError("Некорректное имя кластера") + + out_dir = clusters_dir() / cluster_name + if not out_dir.is_dir(): + raise ClusterConfigEditError("Каталог кластера не найден", not_found=True) + + cfg_path = out_dir / "kind-config.yaml" + yaml_text: str | None = None + if cfg_path.is_file(): + yaml_text = cfg_path.read_text(encoding="utf-8") + + meta = read_meta_json(cluster_name) + return { + "cluster_name": cluster_name, + "meta": meta if isinstance(meta, dict) else {}, + "kind_config_yaml": yaml_text, + "has_kind_config": cfg_path.is_file(), + } diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index c335b89..e11005b 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -630,6 +630,11 @@ def create_cluster_non_interactive( } if patched: meta["kubeconfig_host_path"] = str(host_kube_path.relative_to(root)) + # Сохраняем описание из прежнего meta.json при подъёме по существующему конфигу (в т.ч. reapply). + if use_existing_config: + desc = prev_meta_for_workers.get("description") + if isinstance(desc, str) and desc.strip(): + meta["description"] = desc.strip()[:2000] meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") _progress("Завершение", 95) @@ -694,6 +699,55 @@ def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> t return kind_ok, "; ".join(parts) +def delete_kind_cluster_keep_data(*, name: str, job_id: str | None = None) -> tuple[bool, str]: + """ + Выполнить ``kind delete cluster`` без удаления каталога ``clusters/<имя>/``. + + Используется после изменения ``kind-config.yaml``: затем ``kind create`` поднимает узлы + по новому файлу. Если запись в kind уже отсутствует — считаем шаг успешным и идём к create. + + При ``job_id`` вывод и отмена — как у других длительных операций (см. ``_run_checked_stream``). + + Автор: Сергей Антропов + Сайт: https://devops.org.ru + """ + from core import job_store as _js + + def _log(line: str) -> None: + if job_id: + _js.append_log_sync(job_id, line) + + if not shutil.which("kind"): + raise KindClusterError("Не найден kind в PATH.", exit_code=127) + + if job_id and _js.is_cancelled_sync(job_id): + raise KindClusterError("Операция отменена", exit_code=130) + + _log("Удаление записи кластера в kind (каталог clusters/<имя>/ на диске не удаляется).") + logger.info("kind delete с сохранением каталога данных для «%s»", name) + + try: + _run_checked_stream( + ["kind", "delete", "cluster", "--name", name], + on_line=_log if job_id else None, + job_id=job_id, + ) + return True, "kind delete: OK" + except KindClusterError as e: + if job_id and _js.is_cancelled_sync(job_id): + raise KindClusterError("Операция отменена", exit_code=130) from e + # kind иногда возвращает ошибку, если кластер уже снят с учёта — проверяем фактическое состояние + remaining = list_registered_kind_clusters() + if name not in remaining: + _log("Запись кластера в kind отсутствует — далее создание по сохранённому kind-config.yaml.") + logger.info( + "После сбоя kind delete кластер «%s» не в списке зарегистрированных — продолжаем", + name, + ) + return True, "Запись в kind уже отсутствовала" + raise + + def _sort_kind_node_containers(names: list[str]) -> list[str]: """Сначала control-plane, затем остальные — удобнее для ``docker start``.""" diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 5e4be17..b675211 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -20,12 +20,13 @@ | Маршрут | Описание | |---------|----------| | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | -| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** из JSON (`kubectl get … -o json`), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | +| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | +| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. | | `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** на `/` (удобный ярлык). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | -Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/documentation.html` (README). +Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/documentation.html` (README). **kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** — **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**. @@ -42,11 +43,13 @@ | GET | `/api/v1/stats` | Сводка для дашборда | | GET | `/api/v1/clusters` | Список кластеров (поле `has_provision_log` — есть ли `clusters/<имя>/provision_log.json`) | | POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) | -| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers` или `kind_config`); журнал — `GET /jobs/{job_id}` | +| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers`, `kind_config` или `kind_config_reapply`); журнал — `GET /jobs/{job_id}` | | POST | `/api/v1/clusters/{name}/stop` | Остановка узлов в фоне (**202** + `job_id`, `mode`: `stop`); журнал — `GET /jobs/{job_id}` | | GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig | +| GET | `/api/v1/clusters/{name}/config` | Текущие `meta.json` и текст `kind-config.yaml`; подсказка `kind_note` про пересоздание | +| PUT | `/api/v1/clusters/{name}/config` | Сохранить YAML и/или meta (простой режим или полный YAML); ответ включает обновлённый `summary` | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | -| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) | +| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** / **start_cluster_reapply** (JSON с диска) | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | | GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) | | POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта | @@ -64,9 +67,9 @@ - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**). -- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются. Для **create_cluster** и **start_cluster** полный журнал без обрезки дополнительно сохраняется в **`clusters/<имя>/provision_log.json`** (перезапись при каждом завершении такого задания); чтение — **GET /api/v1/clusters/{name}/provision-log**. +- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются. Для **create_cluster**, **start_cluster** и **start_cluster_reapply** полный журнал без обрезки дополнительно сохраняется в **`clusters/<имя>/provision_log.json`** (перезапись при каждом завершении такого задания); чтение — **GET /api/v1/clusters/{name}/provision-log**. - Для **`docker pull`**: если в справке **`docker pull --help`** объявлен флаг **`--progress`**, при **`KIND_K8S_DOCKER_PULL_PLAIN=1`** вызывается **`--progress=plain`** без PTY; на старых CLI флаг не передаётся (нет строки «unknown flag» в журнале). Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды. -- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов). +- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_cluster_reapply` (после правки `kind-config.yaml`: `kind delete` без удаления каталога + `kind create`), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов). - Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение. --- @@ -311,7 +314,7 @@ Accept: text/markdown ## POST /api/v1/jobs/{job_id}/cancel -Прервать фоновое задание (`create_cluster`, `start_cluster`, `start_containers`, `stop_containers`). Для длительной команды завершается связанный дочерний процесс; между шагами запуска/остановки отдельных узлов также проверяется флаг отмены. +Прервать фоновое задание (`create_cluster`, `start_cluster`, `start_cluster_reapply`, `start_containers`, `stop_containers`). Для длительной команды завершается связанный дочерний процесс; между шагами запуска/остановки отдельных узлов также проверяется флаг отмены. **Пример ответа 200:** @@ -328,6 +331,102 @@ Accept: text/markdown --- +## GET /api/v1/clusters/{name}/config + +Чтение **`clusters/<имя>/meta.json`** и текста **`kind-config.yaml`**. Поле **`kind_note`** напоминает: kind **не** меняет топологию и образ уже созданного кластера — новый YAML на диске применится при следующем **`kind create`** (после `kind delete` и «Старт» / пересоздания). + +**Ошибка 404:** каталога кластера нет. +**Ошибка 400:** некорректное имя. + +**Пример ответа 200 (JSON):** + +```json +{ + "cluster_name": "dev", + "meta": { + "cluster_name": "dev", + "kubernetes_version_tag": "v1.29.4", + "worker_nodes": 2, + "node_image": "kindest/node:v1.29.4", + "description": "Тестовый стенд" + }, + "kind_config_yaml": "kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n- role: control-plane\n image: kindest/node:v1.29.4\n- role: worker\n image: kindest/node:v1.29.4\n", + "has_kind_config": true, + "registered_in_kind": true, + "kind_note": "Файлы сохранены на диске. Кластер уже зарегистрирован в kind: действующие узлы и образ не меняются до удаления кластера из kind и повторного создания по обновлённому kind-config.yaml (или «Старт», если запись в kind удалена, а каталог данных остался)." +} +``` + +--- + +## PUT /api/v1/clusters/{name}/config + +Сохранение на диск. Нужно **хотя бы одно** поле тела: + +| Поле | Назначение | +|------|------------| +| `kubernetes_version` | Тег `kindest/node` для **пересборки** простого YAML (control-plane + N worker) | +| `workers` | Число worker-нод 0–20 (вместе с версией из запроса или из meta) | +| `kind_config_yaml` | **Полная замена** `kind-config.yaml` (проверка: `kind: Cluster`, `apiVersion` с `kind.x-k8s.io`, `nodes`, есть `control-plane`) | +| `description` | Строка в meta (пустая строка **сбрасывает** поле) | + +Если передан непустой **`kind_config_yaml`**, поля **`kubernetes_version`** и **`workers`** для генерации YAML **не используются** (можно не отправлять). + +**Пример тела (только описание):** + +```json +{ + "description": "Обновлённая заметка" +} +``` + +**Пример тела (простой режим):** + +```json +{ + "kubernetes_version": "1.30.0", + "workers": 3, + "description": "Три воркера" +} +``` + +**Пример тела (расширенный YAML):** + +```json +{ + "kind_config_yaml": "kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n- role: control-plane\n image: kindest/node:v1.29.4\n", + "description": "" +} +``` + +**Пример ответа 200 (JSON):** + +```json +{ + "cluster_name": "dev", + "updated_kind_config_yaml": true, + "message": "Сохранено.", + "registered_in_kind": true, + "kind_note": "Файлы сохранены на диске. Кластер уже зарегистрирован в kind: действующие узлы и образ не меняются до удаления кластера из kind и повторного создания по обновлённому kind-config.yaml (или «Старт», если запись в kind удалена, а каталог данных остался).", + "summary": { + "name": "dev", + "registered_in_kind": true, + "kind_nodes_running": true, + "has_local_kubeconfig": true, + "has_provision_log": true, + "meta": { + "kubernetes_version_tag": "v1.30.0", + "worker_nodes": 3 + } + } +} +``` + +**Ошибка 404:** каталога кластера нет. +**Ошибка 400:** некорректное имя, пустое тело, ошибка разбора или структуры YAML. + +--- + ## GET /api/v1/clusters/{name}/kubeconfig Скачать kubeconfig для **kubectl на машине пользователя** (ответ — тело файла, `Content-Disposition`: `kubeconfig-{name}.yaml`). @@ -342,7 +441,7 @@ Accept: text/markdown ## GET /api/v1/clusters/{name}/provision-log -Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster** или **start_cluster** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`). +Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster**, **start_cluster** или **start_cluster_reapply** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`). **Ошибка 404:** файла нет (кластер ещё не создавали/не стартовали с сохранением журнала, или каталог без записи). @@ -392,6 +491,8 @@ Accept: text/markdown Если kubeconfig нет: `error` — строка вида **`Нет сохранённого kubeconfig в clusters/<имя>/`**, поля вывода kubectl могут быть `null`. +Если кластер **есть в kind**, но **узлы остановлены** (`docker stop` / «Стоп» в UI): **`error`** — краткое сообщение без вызова kubectl (вместо сырого stderr про DNS `*-control-plane`). + **Ошибка 400:** некорректное имя кластера. --- @@ -401,6 +502,7 @@ Accept: text/markdown Единый ответ для **страницы кластера** (`GET /cluster/{name}`): флаги и `meta` как в списке кластеров, **метрики узлов** (docker/podman), **`aggregate_resources`** (как в `GET /stats` для донатов), данные Kubernetes в виде **`kubectl get … -o json`** (разбор `items` на фронтенде в таблицы). - При отсутствии kubeconfig: **`kubeconfig_error`** — строка-пояснение; блоки **`k8s_*`** недоступны (см. ниже). +- Если кластер **зарегистрирован в kind**, но **узлы не запущены** (`kind_nodes_running: false`): **`kubectl` не вызывается**; в **`kubeconfig_error`** и в **`message`** каждого блока **`k8s_*`** — одно и то же дружелюбное пояснение (без stderr про недоступный API). - Поля **`nodes_rc` / `nodes_output`**, **`pods_rc` / `pods_output`**, … — устаревший текстовый вывод; в текущей версии API могут быть **`null`** (UI использует только JSON-блоки). - Блоки **`k8s_nodes`**, **`k8s_namespaces`**, **`k8s_pods`**, **`k8s_deployments`**, **`k8s_statefulsets`**, **`k8s_daemonsets`**, **`k8s_replicasets`**, **`k8s_jobs`**, **`k8s_cronjobs`**, **`k8s_services`**, **`k8s_ingresses`** (ресурс **`ingresses.networking.k8s.io -A`**), **`k8s_pvcs`** — объекты вида **`K8sListJsonBlock`**; для ресурсов в namespace везде **`kubectl get … -A`** (все пространства имён). - **`ok`**: `true` при успешном `kubectl`; @@ -563,10 +665,13 @@ Accept: text/markdown ## POST /api/v1/clusters/{name}/start -Запуск кластера двумя сценариями (оба с **202** и `job_id`, журнал в `GET /jobs/{job_id}`): +Запуск кластера (все варианты — **202** и `job_id`, журнал в `GET /jobs/{job_id}`): -1. Кластер **зарегистрирован** в kind — задание **`start_containers`** (поочерёдный запуск узлов, `mode`: **`containers`**). -2. В kind кластера **нет**, но есть сохранённый **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**), логика как при создании (в т.ч. скачивание образа при необходимости). +1. Кластер **зарегистрирован** в kind и в **`meta.json`** **нет** флага **`apply_kind_config_on_next_start`** — задание **`start_containers`** (поочерёдный `docker start` узлов, `mode`: **`containers`**). +2. Кластер **зарегистрирован** в kind и после **PUT …/config** с изменением **`kind-config.yaml`** в **`meta.json`** выставлен **`apply_kind_config_on_next_start`: true** — задание **`start_cluster_reapply`** (`mode`: **`kind_config_reapply`**): `kind delete cluster` **без** удаления `clusters/<имя>/`, затем **`kind create`** по сохранённому YAML (как при создании; флаг в новом `meta.json` не сохраняется). +3. В kind кластера **нет**, но есть **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**). + +Остановка (**POST …/stop**) флаг пересоздания **не сбрасывает**; достаточно после правки конфига нажать **«Старт»** (после стопа или сразу — выполнится сценарий 2, если кластер всё ещё в kind). **Пример ответа 202 (запуск узлов):** @@ -590,7 +695,18 @@ Accept: text/markdown } ``` -**Ошибка 400:** некорректное имя или нет ни кластера в kind, ни `kind-config.yaml` в `clusters/<имя>/`. +**Пример ответа 202 (пересоздание после правки YAML):** + +```json +{ + "job_id": "c0ffee...", + "status": "queued", + "mode": "kind_config_reapply", + "message": "Удаление записи в kind и создание кластера по обновлённому kind-config.yaml; GET /api/v1/jobs/{job_id}" +} +``` + +**Ошибка 400:** некорректное имя; или нет ни кластера в kind, ни `kind-config.yaml`; или для **`kind_config_reapply`** отсутствует `kind-config.yaml`. --- diff --git a/app/main.py b/app/main.py index ce6fe17..2d5edea 100644 --- a/app/main.py +++ b/app/main.py @@ -98,6 +98,28 @@ async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLRespon ) +@app.get("/cluster/{cluster_name}/edit", response_class=HTMLResponse, summary="Редактирование конфигурации кластера") +async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse: + """Форма: версия/workers, полный kind-config.yaml, описание в meta; сохранение через PUT /api/v1/clusters/{name}/config.""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="

Шаблоны не найдены. Ожидается каталог app/templates/

", + status_code=500, + ) + n = cluster_name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=404, detail="Некорректное имя кластера") + return templates.TemplateResponse( + request, + "cluster_edit.html", + { + "app_title": settings.app_title, + "nav_active": "panel", + "cluster_name": n, + }, + ) + + @app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания") async def cluster_create_page(request: Request) -> HTMLResponse: """Форма создания кластера, прогресс и список последних заданий.""" diff --git a/app/models/schemas.py b/app/models/schemas.py index cf45978..c0078af 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class ClusterCreateRequest(BaseModel): @@ -143,12 +143,12 @@ class ClusterWorkloadsResponse(BaseModel): class K8sListJsonBlock(BaseModel): - """Фрагмент ответа ``kubectl get … -o json`` (items) для таблицы в UI.""" + """Фрагмент списка ресурсов Kubernetes (поле ``items`` из JSON List) для таблицы в UI.""" ok: bool = True - rc: int | None = Field(default=None, description="Код выхода kubectl") + rc: int | None = Field(default=None, description="Код возврата при обращении к API кластера") items: list[dict[str, Any]] = Field(default_factory=list) - message: str | None = Field(default=None, description="Ошибка kubectl или разбора JSON") + message: str | None = Field(default=None, description="Текст ошибки или разбора JSON") class PodRestartRequest(BaseModel): @@ -169,7 +169,7 @@ class PodRestartResponse(BaseModel): class ClusterOverviewResponse(BaseModel): - """Сводка для страницы кластера: мета, ресурсы узлов, kubectl (поды, workloads).""" + """Сводка для страницы кластера: мета, ресурсы узлов, таблицы ресурсов Kubernetes.""" cluster_name: str registered_in_kind: bool = False @@ -182,7 +182,7 @@ class ClusterOverviewResponse(BaseModel): default_factory=lambda: KindClusterResources(cluster_name="", nodes=[], note=None), ) aggregate_resources: AggregateResourcesSummary = Field(default_factory=AggregateResourcesSummary) - kubeconfig_error: str | None = Field(default=None, description="Нет kubeconfig или ошибка kubectl") + kubeconfig_error: str | None = Field(default=None, description="Нет kubeconfig или недоступен API кластера") nodes_rc: int | None = None nodes_output: str | None = None pods_rc: int | None = None @@ -197,8 +197,8 @@ class ClusterOverviewResponse(BaseModel): services_output: str | None = None ingresses_rc: int | None = None ingresses_output: str | None = None - k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get nodes -o json") - k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get pods -A -o json") + k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Узлы кластера") + k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Поды, все namespace") k8s_deployments: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_statefulsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_daemonsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) @@ -206,21 +206,80 @@ class ClusterOverviewResponse(BaseModel): k8s_ingresses: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_namespaces: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get namespaces -o json", + description="Namespaces", ) k8s_replicasets: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get replicasets -A -o json", + description="ReplicaSets, все namespace", ) k8s_jobs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get jobs -A -o json", + description="Jobs, все namespace", ) k8s_cronjobs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get cronjobs -A -o json", + description="CronJobs, все namespace", ) k8s_pvcs: K8sListJsonBlock = Field( default_factory=K8sListJsonBlock, - description="kubectl get pvc -A -o json", + description="PVC, все namespace", ) + + +class ClusterConfigGetResponse(BaseModel): + """Ответ GET /clusters/{name}/config — текущие meta и kind-config.yaml.""" + + cluster_name: str + meta: dict[str, Any] = Field(default_factory=dict) + kind_config_yaml: str | None = Field(default=None, description="Содержимое kind-config.yaml или null, если файла нет") + has_kind_config: bool = False + registered_in_kind: bool = Field( + default=False, + description="Есть ли кластер в списке kind (важно для подсказки про применение конфига)", + ) + kind_note: str = Field( + default="", + description="Пояснение: kind не меняет топологию уже созданного кластера без пересоздания", + ) + + +class ClusterConfigUpdateRequest(BaseModel): + """Тело PUT /clusters/{name}/config. + + Если передан непустой ``kind_config_yaml``, он перезаписывает файл целиком; поля ``kubernetes_version`` + и ``workers`` в этом запросе для генерации YAML не используются (можно не отправлять). + """ + + kubernetes_version: str | None = Field( + default=None, + description="Тег kindest/node для простого режима (пересборка YAML: control-plane + N worker)", + ) + workers: int | None = Field(default=None, ge=0, le=20) + kind_config_yaml: str | None = Field(default=None, description="Полный YAML манифеста kind Cluster") + description: str | None = Field( + default=None, + max_length=2000, + description="Текст в meta.json (пустая строка сбрасывает поле)", + ) + + @model_validator(mode="after") + def at_least_one_field(self) -> ClusterConfigUpdateRequest: + has_ver = self.kubernetes_version is not None + has_w = self.workers is not None + yaml_s = (self.kind_config_yaml or "").strip() + has_yaml = self.kind_config_yaml is not None and yaml_s != "" + has_desc = self.description is not None + if not (has_ver or has_w or has_yaml or has_desc): + raise ValueError("Укажите хотя бы одно поле для обновления.") + return self + + +class ClusterConfigUpdateResponse(BaseModel): + """Результат сохранения конфигурации кластера на диск.""" + + cluster_name: str + updated_kind_config_yaml: bool = Field(description="Был ли перезаписан kind-config.yaml") + message: str + registered_in_kind: bool + kind_note: str + summary: ClusterSummary = Field(description="Актуальная сводка для строки таблицы / панели") diff --git a/app/static/js/cluster_edit.js b/app/static/js/cluster_edit.js new file mode 100644 index 0000000..aa1a90b --- /dev/null +++ b/app/static/js/cluster_edit.js @@ -0,0 +1,811 @@ +/** + * Страница редактирования кластера: GET/PUT /api/v1/clusters/{name}/config. + * Расширенный режим — форма по документации kind (networking, nodes, extraPortMappings, kubeadmConfigPatches). + * + * Автор: Сергей Антропов + * Сайт: https://devops.org.ru + */ +(function () { + "use strict"; + + const body = document.body; + const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + const clusterName = (body.getAttribute("data-cluster-name") || "").trim(); + if (!clusterName) return; + + /** @type {string} */ + let initialTag = ""; + /** @type {number | null} */ + let initialWorkers = null; + /** @type {string} */ + let initialDescription = ""; + /** @type {string | null} */ + let loadedYamlSnapshot = null; + /** @type {string} */ + let initialAdvancedSnapshot = ""; + + /** + * @typedef {object} AdvancedFormState + * @property {string} apiVersion + * @property {string} image + * @property {number} controlPlanes + * @property {number} workers + * @property {string} ipFamily + * @property {string} apiServerAddress + * @property {string} apiServerPort + * @property {string} podSubnet + * @property {string} serviceSubnet + * @property {string} kubeProxyMode + * @property {boolean} disableDefaultCNI + * @property {boolean} portEnabled + * @property {string} hostPort + * @property {number} containerPort + * @property {string} portListenAddress + * @property {string} portProtocol + * @property {string} kubeadmPatch + */ + + function formatApiError(data, fallback) { + if (!data) return fallback; + if (typeof data.detail === "string") return data.detail; + if (Array.isArray(data.detail)) { + return data.detail + .map(function (x) { + return (x.msg || x) + (x.loc ? " (" + x.loc.join(".") + ")" : ""); + }) + .join("; "); + } + return fallback; + } + + /** + * @param {string} path + * @param {RequestInit} [opts] + */ + async function api(path, opts) { + const url = path.startsWith("http") ? path : API + path; + const r = await fetch(url, opts); + const text = await r.text(); + var data; + try { + data = text ? JSON.parse(text) : null; + } catch (e) { + data = { raw: text }; + } + if (!r.ok) { + const msg = formatApiError(data, text || r.statusText); + const err = new Error(msg); + err.status = r.status; + throw err; + } + return data; + } + + /** + * Собрать содержимое модалки: как на диске сохранилось и как это доходит до kind. + * @param {boolean} updatedYaml + * @param {boolean} registeredInKind + * @returns {DocumentFragment} + */ + function buildSaveInfoModalContent(updatedYaml, registeredInKind) { + const frag = document.createDocumentFragment(); + if (!updatedYaml) { + const p = document.createElement("p"); + p.textContent = + "Сохранены поля без изменения kind-config.yaml (например, только описание в meta.json). " + + "Топология кластера в kind и уже запущенные узлы от этого не меняются."; + frag.appendChild(p); + return frag; + } + const lead = document.createElement("p"); + lead.className = "cluster-edit-save-modal-lead"; + lead.textContent = "Файл kind-config.yaml и связанные поля meta.json записаны на диск."; + frag.appendChild(lead); + + if (registeredInKind) { + const pIntro = document.createElement("p"); + pIntro.textContent = "Кластер уже зарегистрирован в kind. Новый конфиг применяется так:"; + frag.appendChild(pIntro); + const ul = document.createElement("ul"); + const lines = [ + "До нажатия «Старт» продолжают работать текущие узлы (старая топология и образы).", + "При «Старт» на панели или на странице кластера выполняется пересоздание: удаление записи в kind без удаления каталога clusters/" + + clusterName + + "/, затем создание кластера по обновлённому YAML.", + "«Стоп» перед «Старт» не обязателен — можно нажать «Старт» сразу после сохранения.", + ]; + lines.forEach(function (line) { + const li = document.createElement("li"); + li.textContent = line; + ul.appendChild(li); + }); + frag.appendChild(ul); + const note = document.createElement("p"); + note.style.marginTop = "0.65rem"; + note.style.fontSize = "0.88rem"; + note.textContent = + "Состояние Kubernetes внутри кластера при пересоздании начинается заново; kubeconfig в каталоге кластера обновится после успешного «Старт»."; + frag.appendChild(note); + } else { + const p2 = document.createElement("p"); + p2.textContent = + "Кластер сейчас не в списке kind. При «Старт» он будет поднят по сохранённому kind-config.yaml (полный цикл создания, как при восстановлении из файла)."; + frag.appendChild(p2); + } + return frag; + } + + function openClusterEditSaveModal(updatedYaml, registeredInKind) { + const overlay = document.getElementById("cluster-edit-save-modal-overlay"); + const titleEl = document.getElementById("cluster-edit-save-modal-title"); + const bodyEl = document.getElementById("cluster-edit-save-modal-body"); + if (!overlay || !titleEl || !bodyEl) return; + + titleEl.textContent = updatedYaml ? "Конфигурация сохранена" : "Сохранено"; + bodyEl.replaceChildren(); + bodyEl.appendChild(buildSaveInfoModalContent(updatedYaml, registeredInKind)); + + overlay.classList.remove("hidden"); + overlay.setAttribute("aria-hidden", "false"); + document.body.classList.add("modal-open"); + + const okBtn = document.getElementById("cluster-edit-save-modal-ok"); + if (okBtn) okBtn.focus(); + } + + function closeClusterEditSaveModal() { + const overlay = document.getElementById("cluster-edit-save-modal-overlay"); + if (!overlay) return; + overlay.classList.add("hidden"); + overlay.setAttribute("aria-hidden", "true"); + document.body.classList.remove("modal-open"); + } + + /** Закрытие модалки после сохранения и переход на страницу кластера (только кнопка «Понятно»). */ + function onSaveInfoModalOk() { + closeClusterEditSaveModal(); + window.location.href = "/cluster/" + encodeURIComponent(clusterName); + } + + function wireSaveInfoModal() { + const overlay = document.getElementById("cluster-edit-save-modal-overlay"); + const ok = document.getElementById("cluster-edit-save-modal-ok"); + if (!overlay || !ok) return; + ok.addEventListener("click", onSaveInfoModalOk); + overlay.addEventListener("click", function (ev) { + if (ev.target === overlay) closeClusterEditSaveModal(); + }); + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape" && !overlay.classList.contains("hidden")) { + closeClusterEditSaveModal(); + } + }); + } + + function wireVersionSelect(selId, inputId) { + const sel = document.getElementById(selId); + const verInput = document.getElementById(inputId); + if (!sel || !verInput) return; + sel.onchange = function () { + if (!sel.value) return; + if (inputId === "fld-adv-image") { + verInput.value = + sel.value === "latest" ? "kindest/node:latest" : "kindest/node:" + sel.value.replace(/^v/, ""); + } else { + verInput.value = sel.value === "latest" ? "latest" : sel.value.replace(/^v/, ""); + } + }; + } + + async function loadVersions() { + const ids = [ + ["version-select-edit", "kubernetes_version_edit"], + ["version-select-adv", "fld-adv-image"], + ]; + ids.forEach(function (pair) { + const sel = document.getElementById(pair[0]); + if (sel) sel.innerHTML = ""; + }); + try { + const data = await api("/versions"); + ids.forEach(function (pair) { + const sel = document.getElementById(pair[0]); + if (!sel) return; + sel.innerHTML = ""; + if (!data.tags || !data.tags.length) { + sel.innerHTML = ""; + return; + } + const opt0 = document.createElement("option"); + opt0.value = ""; + opt0.textContent = "— выберите тег —"; + sel.appendChild(opt0); + data.tags.forEach(function (t) { + const o = document.createElement("option"); + o.value = t; + o.textContent = t === "latest" ? "latest (kindest/node)" : t; + sel.appendChild(o); + }); + }); + wireVersionSelect("version-select-edit", "kubernetes_version_edit"); + wireVersionSelect("version-select-adv", "fld-adv-image"); + } catch (e) { + ids.forEach(function (pair) { + const sel = document.getElementById(pair[0]); + if (sel) sel.innerHTML = ""; + }); + } + } + + /** + * Разбор секции networking: (документация kind — cluster-wide networking). + * @param {string} text + * @returns {Pick} + */ + function parseNetworkingSection(text) { + const out = { + ipFamily: "", + apiServerAddress: "", + apiServerPort: "", + podSubnet: "", + serviceSubnet: "", + kubeProxyMode: "", + disableDefaultCNI: false, + }; + const lines = text.split("\n"); + let i = 0; + while (i < lines.length) { + if (/^\s*networking:\s*$/.test(lines[i])) { + i++; + while (i < lines.length) { + const L = lines[i]; + const t = L.trim(); + if (!t) { + i++; + continue; + } + if (/^[a-zA-Z]/.test(L) && !/^\s/.test(L)) break; + const kv = L.match(/^\s{2}([a-zA-Z0-9]+):\s*(.*)$/); + if (kv) { + const k = kv[1]; + let v = kv[2].trim(); + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + v = v.slice(1, -1); + } + if (k === "ipFamily") out.ipFamily = v; + else if (k === "apiServerAddress") out.apiServerAddress = v; + else if (k === "apiServerPort") out.apiServerPort = v; + else if (k === "podSubnet") out.podSubnet = v; + else if (k === "serviceSubnet") out.serviceSubnet = v; + else if (k === "kubeProxyMode") out.kubeProxyMode = v.replace(/^["']|["']$/g, ""); + else if (k === "disableDefaultCNI") out.disableDefaultCNI = v === "true"; + } + i++; + } + break; + } + i++; + } + return out; + } + + /** + * @param {AdvancedFormState} f + * @returns {string} + */ + function buildNetworkingYaml(f) { + const rows = []; + if (f.ipFamily && f.ipFamily !== "ipv4") rows.push(" ipFamily: " + f.ipFamily); + const addr = (f.apiServerAddress || "").trim(); + if (addr) rows.push(' apiServerAddress: "' + addr.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'); + const ap = String(f.apiServerPort || "").trim(); + if (ap) { + const pn = parseInt(ap, 10); + if (pn >= 1 && pn <= 65535) rows.push(" apiServerPort: " + pn); + } + const ps = (f.podSubnet || "").trim(); + if (ps) rows.push(' podSubnet: "' + ps.replace(/"/g, '\\"') + '"'); + const ss = (f.serviceSubnet || "").trim(); + if (ss) rows.push(' serviceSubnet: "' + ss.replace(/"/g, '\\"') + '"'); + const kpm = (f.kubeProxyMode || "").trim(); + if (kpm) rows.push(' kubeProxyMode: "' + kpm.replace(/"/g, '\\"') + '"'); + if (f.disableDefaultCNI) rows.push(" disableDefaultCNI: true"); + if (!rows.length) return ""; + return "networking:\n" + rows.join("\n") + "\n"; + } + + /** + * @param {string} text + * @returns {AdvancedFormState} + */ + function parseKindYamlToAdvancedForm(text) { + /** @type {AdvancedFormState} */ + const out = { + apiVersion: "kind.x-k8s.io/v1alpha4", + image: "", + controlPlanes: 1, + workers: 0, + ipFamily: "", + apiServerAddress: "", + apiServerPort: "", + podSubnet: "", + serviceSubnet: "", + kubeProxyMode: "", + disableDefaultCNI: false, + portEnabled: false, + hostPort: "", + containerPort: 80, + portListenAddress: "", + portProtocol: "TCP", + kubeadmPatch: "", + }; + if (!text || !text.trim()) return out; + const av = text.match(/^\s*apiVersion:\s*(\S+)/m); + if (av) out.apiVersion = av[1].trim().replace(/['"]/g, ""); + const net = parseNetworkingSection(text); + Object.assign(out, net); + + const lines = text.split("\n"); + let inNodes = false; + let cp = 0; + let wk = 0; + /** @type {string | null} */ + let pendingRole = null; + const images = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const t = line.trim(); + if (t === "nodes:" || t.startsWith("nodes:")) { + inNodes = true; + pendingRole = null; + continue; + } + if (!inNodes) continue; + if (t && !line.startsWith(" ") && !line.startsWith("\t")) break; + const roleM = line.match(/^\s*-\s*role:\s*(\S+)/); + if (roleM) { + pendingRole = roleM[1]; + if (pendingRole === "control-plane") cp++; + else if (pendingRole === "worker") wk++; + continue; + } + const imgM = line.match(/^\s*image:\s*(.+)$/); + if (imgM && pendingRole) { + images.push(imgM[1].trim().replace(/['"]/g, "")); + } + } + if (cp > 0) out.controlPlanes = cp; + out.workers = wk; + const uniq = []; + images.forEach(function (im) { + if (uniq.indexOf(im) < 0) uniq.push(im); + }); + out.image = uniq[0] || ""; + + const portSplit = text.split("extraPortMappings:"); + if (portSplit.length > 1) { + const portBlock = portSplit[1]; + const hh = portBlock.match(/hostPort:\s*(\d+)/); + const cc = portBlock.match(/containerPort:\s*(\d+)/); + const la = + portBlock.match(/listenAddress:\s*"([^"]+)"/) || + portBlock.match(/listenAddress:\s*'([^']+)'/); + const pr = portBlock.match(/protocol:\s*(\w+)/); + if (hh) { + out.portEnabled = true; + out.hostPort = hh[1]; + if (cc) out.containerPort = parseInt(cc[1], 10) || 80; + if (la) out.portListenAddress = la[1]; + if (pr && /UDP|SCTP|TCP/i.test(pr[1])) out.portProtocol = pr[1].toUpperCase(); + } + } + + const kubStart = text.indexOf("kubeadmConfigPatches:"); + if (kubStart >= 0) { + const sub = text.slice(kubStart); + const pipeM = sub.match(/kubeadmConfigPatches:\s*\n\s*-\s*\|\s*\n([\s\S]+)$/); + if (pipeM) { + const raw = pipeM[1]; + const linesPatch = raw.split("\n"); + const outLines = []; + for (let j = 0; j < linesPatch.length; j++) { + const ln = linesPatch[j]; + if (/^\s*-\s*role:\s*/.test(ln)) break; + outLines.push(ln.replace(/^ /, "")); + } + out.kubeadmPatch = outLines.join("\n").replace(/\n+$/, ""); + } + } + return out; + } + + function ensureApiVersionOption(apiVersion) { + const sel = document.getElementById("fld-adv-api-version"); + if (!sel || !apiVersion) return; + let found = false; + sel.querySelectorAll("option").forEach(function (o) { + if (o.value === apiVersion) found = true; + }); + if (!found) { + const o = document.createElement("option"); + o.value = apiVersion; + o.textContent = apiVersion; + sel.appendChild(o); + } + } + + /** + * @param {AdvancedFormState} f + * @returns {string} + */ + function serializeAdvancedKindCluster(f) { + const api = f.apiVersion || "kind.x-k8s.io/v1alpha4"; + const img = (f.image || "").trim(); + if (!img) throw new Error("Укажите образ узла (kindest/node:…)."); + const cp = Math.min(5, Math.max(1, parseInt(String(f.controlPlanes), 10) || 1)); + const wk = Math.min(20, Math.max(0, parseInt(String(f.workers), 10) || 0)); + + const lines = ["kind: Cluster", "apiVersion: " + api]; + const netBlock = buildNetworkingYaml(f); + if (netBlock) lines.push(netBlock.trimEnd()); + + lines.push("nodes:"); + const firstExtras = []; + if (f.portEnabled && f.hostPort) { + const hp = parseInt(String(f.hostPort), 10); + const ctp = parseInt(String(f.containerPort), 10) || 80; + if (hp >= 1 && hp <= 65535) { + firstExtras.push(" extraPortMappings:"); + firstExtras.push(" - containerPort: " + ctp); + firstExtras.push(" hostPort: " + hp); + const listen = (f.portListenAddress || "").trim(); + if (listen) { + firstExtras.push(' listenAddress: "' + listen.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'); + } + const proto = (f.portProtocol || "TCP").toUpperCase(); + if (proto === "TCP" || proto === "UDP" || proto === "SCTP") { + firstExtras.push(" protocol: " + proto); + } + } + } + const patch = (f.kubeadmPatch || "").trim(); + if (patch) { + firstExtras.push(" kubeadmConfigPatches:"); + firstExtras.push(" - |"); + patch.split("\n").forEach(function (ln) { + firstExtras.push(" " + ln); + }); + } + let i; + for (i = 0; i < cp; i++) { + lines.push(" - role: control-plane"); + lines.push(" image: " + img); + if (i === 0 && firstExtras.length) { + firstExtras.forEach(function (x) { + lines.push(x); + }); + } + } + for (i = 0; i < wk; i++) { + lines.push(" - role: worker"); + lines.push(" image: " + img); + } + return lines.join("\n") + "\n"; + } + + /** + * @returns {AdvancedFormState} + */ + function readAdvancedFormFromDom() { + const pick = function (id) { + const el = document.getElementById(id); + return el ? el.value : ""; + }; + const pickNum = function (id, def) { + const el = document.getElementById(id); + if (!el || !el.value.trim()) return def; + const n = parseInt(el.value, 10); + return Number.isNaN(n) ? def : n; + }; + const pickChk = function (id) { + const el = document.getElementById(id); + return !!(el && el.checked); + }; + return { + apiVersion: pick("fld-adv-api-version") || "kind.x-k8s.io/v1alpha4", + image: pick("fld-adv-image").trim(), + controlPlanes: pickNum("fld-adv-cp", 1), + workers: pickNum("fld-adv-workers", 0), + ipFamily: pick("fld-adv-ip-family"), + apiServerAddress: pick("fld-adv-api-addr"), + apiServerPort: pick("fld-adv-api-port"), + podSubnet: pick("fld-adv-pod-subnet"), + serviceSubnet: pick("fld-adv-svc-subnet"), + kubeProxyMode: pick("fld-adv-kube-proxy"), + disableDefaultCNI: pickChk("fld-adv-disable-cni"), + portEnabled: pickChk("fld-adv-port-enable"), + hostPort: pick("fld-adv-host-port"), + containerPort: pickNum("fld-adv-container-port", 80), + portListenAddress: pick("fld-adv-listen-addr"), + portProtocol: pick("fld-adv-port-proto") || "TCP", + kubeadmPatch: (function () { + const el = document.getElementById("fld-adv-kubeadm"); + return el ? el.value : ""; + })(), + }; + } + + function advancedFormSnapshotString() { + try { + return JSON.stringify(readAdvancedFormFromDom()); + } catch (e) { + return ""; + } + } + + /** + * @param {AdvancedFormState} f + */ + function applyAdvancedFormToDom(f) { + const set = function (id, v) { + const el = document.getElementById(id); + if (el) el.value = v != null ? String(v) : ""; + }; + ensureApiVersionOption(f.apiVersion || "kind.x-k8s.io/v1alpha4"); + set("fld-adv-api-version", f.apiVersion || "kind.x-k8s.io/v1alpha4"); + set("fld-adv-ip-family", f.ipFamily || ""); + set("fld-adv-api-addr", f.apiServerAddress || ""); + set("fld-adv-api-port", f.apiServerPort || ""); + set("fld-adv-pod-subnet", f.podSubnet || ""); + set("fld-adv-svc-subnet", f.serviceSubnet || ""); + set("fld-adv-kube-proxy", f.kubeProxyMode || ""); + const cni = document.getElementById("fld-adv-disable-cni"); + if (cni) cni.checked = !!f.disableDefaultCNI; + set("fld-adv-image", f.image || ""); + set("fld-adv-cp", String(Math.min(5, Math.max(1, f.controlPlanes || 1)))); + set("fld-adv-workers", String(Math.min(20, Math.max(0, f.workers || 0)))); + const pe = document.getElementById("fld-adv-port-enable"); + if (pe) pe.checked = !!f.portEnabled; + set("fld-adv-host-port", f.hostPort || ""); + set("fld-adv-container-port", String(f.containerPort || 80)); + set("fld-adv-listen-addr", f.portListenAddress || ""); + set("fld-adv-port-proto", f.portProtocol || "TCP"); + const kub = document.getElementById("fld-adv-kubeadm"); + if (kub) kub.value = f.kubeadmPatch || ""; + togglePortRow(); + } + + function togglePortRow() { + const pe = document.getElementById("fld-adv-port-enable"); + const row = document.getElementById("adv-port-fields-row"); + const on = !!(pe && pe.checked); + if (row) { + row.classList.toggle("hidden", !on); + row.setAttribute("aria-hidden", on ? "false" : "true"); + } + } + + function updateAdvancedPreview() { + const pre = document.getElementById("adv-yaml-preview"); + if (!pre) return; + try { + pre.textContent = serializeAdvancedKindCluster(readAdvancedFormFromDom()); + } catch (e) { + pre.textContent = "# " + (e.message || "Заполните образ узла"); + } + } + + function setModeUi(isAdvanced) { + const simple = document.getElementById("edit-simple-block"); + const adv = document.getElementById("edit-advanced-block"); + const hint = document.getElementById("cluster-edit-mode-hint"); + const radios = document.querySelectorAll('input[name="save_mode"]'); + radios.forEach(function (r) { + r.checked = (isAdvanced && r.value === "yaml") || (!isAdvanced && r.value === "simple"); + }); + if (simple) { + simple.classList.toggle("hidden", isAdvanced); + simple.setAttribute("aria-hidden", isAdvanced ? "true" : "false"); + } + if (adv) { + adv.classList.toggle("hidden", !isAdvanced); + adv.setAttribute("aria-hidden", !isAdvanced ? "true" : "false"); + } + if (hint) { + hint.textContent = isAdvanced + ? "Секции соответствуют kind Cluster: networking, nodes, extraPortMappings и kubeadmConfigPatches (к первой control-plane)." + : "Только тег kindest/node и число worker-нод; конфиг пересобирается как при создании кластера."; + } + if (isAdvanced) updateAdvancedPreview(); + } + + function getSaveMode() { + const c = document.querySelector('input[name="save_mode"]:checked'); + return c && c.value === "yaml" ? "yaml" : "simple"; + } + + function normTag(s) { + return String(s || "") + .trim() + .toLowerCase() + .replace(/^v/, ""); + } + + async function loadConfig() { + const noteEl = document.getElementById("edit-kind-note"); + const msg = document.getElementById("edit-msg"); + const verInput = document.getElementById("kubernetes_version_edit"); + const workersEl = document.getElementById("fld-workers-edit"); + const descEl = document.getElementById("fld-description"); + try { + const d = await api("/clusters/" + encodeURIComponent(clusterName) + "/config"); + if (noteEl) { + noteEl.textContent = d.kind_note || ""; + } + const meta = d.meta || {}; + initialTag = meta.kubernetes_version_tag != null ? String(meta.kubernetes_version_tag) : ""; + if (verInput) { + const tag = initialTag; + verInput.value = tag ? tag.replace(/^v/i, "") : ""; + } + if (meta.worker_nodes != null) { + const wn = Number(meta.worker_nodes); + initialWorkers = Number.isNaN(wn) ? 0 : wn; + } else { + initialWorkers = null; + } + if (workersEl) { + workersEl.value = String(initialWorkers !== null ? initialWorkers : 0); + } + initialDescription = meta.description != null ? String(meta.description) : ""; + if (descEl) { + descEl.value = initialDescription; + } + const yamlText = d.kind_config_yaml != null ? String(d.kind_config_yaml) : ""; + loadedYamlSnapshot = yamlText; + const advParsed = parseKindYamlToAdvancedForm(yamlText); + if (!advParsed.image && meta.node_image) { + advParsed.image = String(meta.node_image); + } + if (advParsed.controlPlanes < 1) advParsed.controlPlanes = 1; + applyAdvancedFormToDom(advParsed); + initialAdvancedSnapshot = advancedFormSnapshotString(); + updateAdvancedPreview(); + if (msg) msg.textContent = ""; + } catch (e) { + if (noteEl) noteEl.textContent = ""; + if (msg) { + msg.textContent = e.status === 404 ? "Каталог кластера не найден." : "Ошибка загрузки: " + e.message; + } + } + } + + function bindAdvancedInputs() { + const ids = [ + "fld-adv-api-version", + "fld-adv-ip-family", + "fld-adv-api-addr", + "fld-adv-api-port", + "fld-adv-pod-subnet", + "fld-adv-svc-subnet", + "fld-adv-kube-proxy", + "fld-adv-disable-cni", + "fld-adv-image", + "fld-adv-cp", + "fld-adv-workers", + "fld-adv-port-enable", + "fld-adv-host-port", + "fld-adv-container-port", + "fld-adv-listen-addr", + "fld-adv-port-proto", + "fld-adv-kubeadm", + ]; + ids.forEach(function (id) { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener("input", updateAdvancedPreview); + el.addEventListener("change", updateAdvancedPreview); + }); + const pe = document.getElementById("fld-adv-port-enable"); + if (pe) { + pe.addEventListener("change", function () { + togglePortRow(); + updateAdvancedPreview(); + }); + } + } + + function bindForm() { + const form = document.getElementById("form-cluster-edit"); + if (!form) return; + document.querySelectorAll('input[name="save_mode"]').forEach(function (r) { + r.addEventListener("change", function () { + setModeUi(getSaveMode() === "yaml"); + }); + }); + + form.addEventListener("submit", async function (ev) { + ev.preventDefault(); + const msg = document.getElementById("edit-msg"); + const mode = getSaveMode(); + const descEl = document.getElementById("fld-description"); + const descVal = descEl ? descEl.value : ""; + /** @type {Record} */ + const payload = { description: descVal }; + + if (mode === "yaml") { + let yamlText; + try { + yamlText = serializeAdvancedKindCluster(readAdvancedFormFromDom()); + } catch (e) { + if (msg) msg.textContent = e.message || "Проверьте поля расширенного режима."; + return; + } + const yamlChanged = yamlText !== (loadedYamlSnapshot || ""); + const advChanged = advancedFormSnapshotString() !== initialAdvancedSnapshot; + const descChanged = descVal !== initialDescription; + if (!yamlChanged && !advChanged && !descChanged) { + if (msg) msg.textContent = "Изменений нет."; + return; + } + if (yamlChanged || advChanged) { + payload.kind_config_yaml = yamlText; + } + } else { + const verInput = document.getElementById("kubernetes_version_edit"); + const workersEl = document.getElementById("fld-workers-edit"); + const ver = verInput ? verInput.value.trim() : ""; + const wParsed = workersEl ? parseInt(workersEl.value, 10) : NaN; + const workersOk = !Number.isNaN(wParsed); + const effInitW = initialWorkers !== null && initialWorkers !== undefined ? initialWorkers : 0; + const workersChanged = workersOk && wParsed !== effInitW; + const verChanged = normTag(ver) !== normTag(initialTag); + const descChanged = descVal !== initialDescription; + if (!verChanged && !workersChanged && !descChanged) { + if (msg) msg.textContent = "Изменений нет."; + return; + } + if (verChanged) { + if (!ver) { + if (msg) msg.textContent = "Укажите тег образа или откатите поле к прежнему значению."; + return; + } + payload.kubernetes_version = ver; + } + if (workersChanged) { + if (!workersOk || wParsed < 0 || wParsed > 20) { + if (msg) msg.textContent = "Число worker-нод должно быть от 0 до 20."; + return; + } + payload.workers = wParsed; + } + } + + if (msg) msg.textContent = "Сохранение…"; + try { + const r = await api("/clusters/" + encodeURIComponent(clusterName) + "/config", { + method: "PUT", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload), + }); + if (msg) { + msg.textContent = r.message || "Сохранено."; + } + openClusterEditSaveModal(!!r.updated_kind_config_yaml, !!r.registered_in_kind); + await loadConfig(); + } catch (e) { + if (msg) msg.textContent = "Ошибка: " + e.message; + } + }); + } + + wireSaveInfoModal(); + + loadVersions(); + loadConfig().then(function () { + bindAdvancedInputs(); + togglePortRow(); + }); + bindForm(); + setModeUi(false); +})(); diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 76c7928..d134540 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -26,8 +26,8 @@ var pollLockCreateForm = false; /** @type {string | null} */ var currentPollJobId = null; - /** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */ - var currentModalClusterName = null; + /** Полноэкранный спиннер старт/стоп (не используется при создании кластера из формы). */ + var clusterLifecycleJobOverlayActive = false; /** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */ var cachedClusterResourcesByName = {}; /** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */ @@ -84,6 +84,29 @@ document.body.classList.remove("dashboard-home-loading"); } + /** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */ + function showClusterLifecycleJobOverlay(labelText) { + const el = document.getElementById("cluster-lifecycle-job-overlay"); + const lab = document.getElementById("cluster-lifecycle-job-label"); + if (!el) return; + clusterLifecycleJobOverlayActive = true; + if (lab) lab.textContent = labelText || "Выполняется операция…"; + el.classList.remove("hidden"); + el.setAttribute("aria-busy", "true"); + el.setAttribute("aria-hidden", "false"); + document.body.classList.add("cluster-lifecycle-job-busy"); + } + + function hideClusterLifecycleJobOverlay() { + const el = document.getElementById("cluster-lifecycle-job-overlay"); + if (!el) return; + clusterLifecycleJobOverlayActive = false; + el.classList.add("hidden"); + el.setAttribute("aria-busy", "false"); + el.setAttribute("aria-hidden", "true"); + document.body.classList.remove("cluster-lifecycle-job-busy"); + } + function formatApiError(data, fallback) { if (!data) return fallback; if (typeof data.detail === "string") return data.detail; @@ -213,6 +236,8 @@ '', restart: '', + edit: + '', }; /** @type {ReturnType | null} */ @@ -1249,7 +1274,7 @@ host.appendChild( iconActionButton( ICONS.state, - "Состояние: узлы и поды (kubectl get nodes / pods)", + "Состояние: узлы и поды", "", function () { openWorkloadsModal(name); @@ -1257,6 +1282,17 @@ false, ), ); + host.appendChild( + iconActionButton( + ICONS.edit, + "Редактировать kind-config.yaml и meta.json", + "icon-btn--secondary", + function () { + window.location.href = "/cluster/" + encodeURIComponent(name) + "/edit"; + }, + false, + ), + ); if (c.registered_in_kind && c.kind_nodes_running) { host.appendChild( iconActionButton( @@ -1355,15 +1391,26 @@ ); appendClusterNodeCardsRow(document.getElementById("cluster-page-node-cards"), d.cluster_resources); const kubeErr = d.kubeconfig_error || null; + const clusterStoppedInKind = !!d.registered_in_kind && !d.kind_nodes_running; if (hint) { if (kubeErr) { hint.textContent = kubeErr; hint.classList.remove("hidden"); + if (clusterStoppedInKind) { + hint.classList.add("cluster-kube-hint--info"); + } else { + hint.classList.remove("cluster-kube-hint--info"); + } } else { hint.textContent = ""; hint.classList.add("hidden"); + hint.classList.remove("cluster-kube-hint--info"); } } + if (msg && clusterStoppedInKind) { + msg.textContent = + "Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели)."; + } fillClusterK8sTablesFromOverview(d); } catch (e) { const errText = e && e.message ? e.message : String(e); @@ -1635,24 +1682,24 @@ ""; } tr.innerHTML = - "" + nameEsc + "" + - "" + + "" + (c.registered_in_kind ? "да" : "нет") + "" + - "" + + "" + (c.has_local_kubeconfig ? "да" : "нет") + "" + - "" + + "" + escapeHtml(String(ver)) + "" + - "" + + "" + escapeHtml(String(wn)) + "" + - "" + + "" + stateCell + "" + ""; @@ -1725,6 +1772,7 @@ const st = escapeHtml(j.status || ""); var kindTag = ""; if (j.kind === "start_cluster") kindTag = "[старт] "; + else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] "; else if (j.kind === "stop_containers") kindTag = "[стоп] "; else if (j.kind === "start_containers") kindTag = "[запуск узлов] "; var cellMsg = kindTag + (j.message || "").slice(0, 140); @@ -1774,15 +1822,12 @@ const nodes = document.getElementById("modal-nodes"); const pods = document.getElementById("modal-pods"); const spin = document.getElementById("modal-spinner"); - const modalDlWrap = document.getElementById("modal-dl-wrap"); if (!overlay) return; hideActionTooltip(); - currentModalClusterName = name; document.getElementById("modal-title").textContent = "Кластер «" + name + "»"; sub.textContent = ""; nodes.textContent = ""; pods.textContent = ""; - if (modalDlWrap) modalDlWrap.classList.add("hidden"); if (spin) spin.classList.remove("hidden"); overlay.classList.remove("hidden"); document.body.classList.add("modal-open"); @@ -1792,10 +1837,9 @@ sub.textContent = w.error; return; } - sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc; + sub.textContent = "Узлы: код " + w.nodes_rc + " · Поды: код " + w.pods_rc; nodes.textContent = w.nodes_output || "(пусто)"; pods.textContent = w.pods_output || "(пусто)"; - if (modalDlWrap) modalDlWrap.classList.remove("hidden"); } catch (e) { sub.textContent = "Ошибка: " + e.message; } finally { @@ -1878,9 +1922,6 @@ function closeModal() { hideActionTooltip(); const overlay = document.getElementById("modal-overlay"); - const modalDlWrap = document.getElementById("modal-dl-wrap"); - if (modalDlWrap) modalDlWrap.classList.add("hidden"); - currentModalClusterName = null; if (overlay) overlay.classList.add("hidden"); document.body.classList.remove("modal-open"); } @@ -1908,7 +1949,10 @@ } if (r.status === 202 && data.job_id) { setProgressHint(name, "stop"); - pollJob(data.job_id, { lockCreateForm: false }); + pollJob(data.job_id, { + lockCreateForm: false, + lifecycleOverlayLabel: "Останавливаем узлы кластера «" + name + "»…", + }); return; } if (!r.ok) { @@ -1941,8 +1985,15 @@ if (r.status === 202 && data.job_id) { var sm = data.mode || "kind_config"; if (sm === "containers") setProgressHint(name, "start_containers"); + else if (sm === "kind_config_reapply") setProgressHint(name, "start_reapply"); else setProgressHint(name, "start_config"); - pollJob(data.job_id, { lockCreateForm: false }); + var startLab = + sm === "containers" + ? "Запуск узлов кластера «" + name + "»…" + : sm === "kind_config_reapply" + ? "Пересоздание кластера «" + name + "» в kind…" + : "Подъём кластера «" + name + "» по конфигурации…"; + pollJob(data.job_id, { lockCreateForm: false, lifecycleOverlayLabel: startLab }); return; } if (!r.ok) { @@ -2015,7 +2066,7 @@ /** * Подсказка над прогресс-баром (без технических имён команд). * @param {string | null} clusterName - * @param {"create"|"start_config"|"start_containers"|"stop"} [mode] + * @param {"create"|"start_config"|"start_reapply"|"start_containers"|"stop"} [mode] */ function setProgressHint(clusterName, mode) { const hint = document.getElementById("create-progress-hint"); @@ -2026,6 +2077,11 @@ hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже."; } else if (mode === "start_containers") { hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже."; + } else if (mode === "start_reapply") { + hint.textContent = + "Пересоздаём кластер " + + named + + " в kind по обновлённому kind-config.yaml (сначала удаление записи, затем создание). Журнал ниже."; } else if (mode === "start_config") { hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже."; } else { @@ -2052,6 +2108,7 @@ } function stopPollJob() { + hideClusterLifecycleJobOverlay(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; @@ -2077,7 +2134,8 @@ /** * @param {string} jobId - * @param {{ lockCreateForm?: boolean }} [opts] + * @param {{ lockCreateForm?: boolean, lifecycleOverlayLabel?: string }} [opts] + * ``lifecycleOverlayLabel`` — полноэкранный спиннер (старт/стоп с главной или страницы кластера). */ function pollJob(jobId, opts) { opts = opts || {}; @@ -2096,6 +2154,9 @@ if (pollLockCreateForm) { setCreateFormDisabled(true); } + if (opts.lifecycleOverlayLabel) { + showClusterLifecycleJobOverlay(opts.lifecycleOverlayLabel); + } showCreateProgress(true); updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" }); @@ -2105,6 +2166,14 @@ const preEl = document.getElementById("job-json"); if (preEl) preEl.textContent = JSON.stringify(j, null, 2); updateCreateProgressFromJob(j); + if (clusterLifecycleJobOverlayActive) { + const lab = document.getElementById("cluster-lifecycle-job-label"); + if (lab) { + var st = j.progress_stage; + if (!st && (j.status === "queued" || j.status === "running")) st = "В очереди…"; + if (st) lab.textContent = st; + } + } if (logEl) { if (j.progress_log && j.progress_log.length) { logEl.removeAttribute("data-placeholder"); @@ -2123,6 +2192,8 @@ if (msg) { if (j.status === "success") { if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу."; + else if (j.kind === "start_cluster_reapply") + msg.textContent = "Кластер пересоздан в kind по обновлённому конфигу."; else if (j.kind === "stop_containers") msg.textContent = "Узлы кластера остановлены."; else if (j.kind === "start_containers") msg.textContent = "Узлы кластера запущены."; else msg.textContent = "Кластер создан."; @@ -2131,6 +2202,7 @@ } if (j.status === "success") { if (j.kind === "start_cluster") showToast("Кластер запущен", false); + else if (j.kind === "start_cluster_reapply") showToast("Кластер пересоздан по новому конфигу", false); else if (j.kind === "stop_containers") showToast("Узлы остановлены", false); else if (j.kind === "start_containers") showToast("Узлы запущены", false); else showToast("Кластер создан", false); @@ -2255,13 +2327,6 @@ const mClose = document.getElementById("modal-close"); if (mClose) mClose.addEventListener("click", closeModal); - const modalBtnKube = document.getElementById("modal-btn-kubeconfig"); - if (modalBtnKube) { - modalBtnKube.addEventListener("click", function () { - if (currentModalClusterName) downloadKubeconfig(currentModalClusterName); - }); - } - const overlay = document.getElementById("modal-overlay"); if (overlay) { overlay.addEventListener("click", function (ev) { diff --git a/app/static/style.css b/app/static/style.css index 3ee0ec1..7eb26f7 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -805,7 +805,7 @@ html[data-theme="light"] .cluster-node-res-card__bar-track { font-weight: 600; } -.create-form-grid .create-form-field input, +.create-form-grid .create-form-field input:not([type="checkbox"]), .create-form-grid .create-form-field select { width: 100%; max-width: none; @@ -1102,19 +1102,6 @@ html[data-theme="light"] .job-details { justify-content: center; gap: 0.65rem 1rem; } -.modal-dl-wrap { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5rem 0.75rem; - margin: 0 0 0.65rem; -} -.modal-toolbar .modal-dl-wrap { - margin: 0; -} -.modal-dl-hint { - font-size: 0.8rem; -} .modal-title-text { margin: 0; font-size: 1.05rem; @@ -1211,7 +1198,7 @@ button { font: inherit; } -input, +input:not([type="checkbox"]), select { width: 100%; max-width: 28rem; @@ -1222,6 +1209,12 @@ select { color: inherit; } +/* Чекбоксы: без растягивания на ширину полей text/select */ +input[type="checkbox"] { + width: auto; + max-width: none; +} + button { margin-top: 0.75rem; padding: 0.45rem 0.85rem; @@ -1359,10 +1352,56 @@ button.btn-small { font-size: 0.8rem; } -/* Колонка «Действия»: ширина по содержимому (иконки в ряд) */ +/* Таблица кластеров: колонка «Имя» 40 % ширины таблицы (без width:1%, из‑за него в UI казалось «1 %») */ +#tbl-clusters { + table-layout: fixed; + width: 90%; +} + +#tbl-clusters th.col-cluster-name, +#tbl-clusters td.col-cluster-name { + width: 40%; + min-width: 0; + text-align: left; + vertical-align: middle; + overflow-wrap: break-word; + word-break: break-word; +} + +/* Остальные колонки — компактно; содержимое по центру */ +#tbl-clusters th.col-kind, +#tbl-clusters td.col-kind, +#tbl-clusters th.col-kubeconfig, +#tbl-clusters td.col-kubeconfig, +#tbl-clusters th.col-workers, +#tbl-clusters td.col-workers, +#tbl-clusters th.col-state, +#tbl-clusters td.col-state { + padding-left: 0.5rem; + padding-right: 0.5rem; + text-align: center; + vertical-align: middle; + white-space: nowrap; +} + +#tbl-clusters th.col-version, +#tbl-clusters td.col-version { + padding-left: 0.5rem; + padding-right: 0.5rem; + text-align: center; + vertical-align: middle; +} +#tbl-clusters th.col-version { + white-space: nowrap; +} +#tbl-clusters td.col-version { + white-space: normal; + word-break: break-word; +} + +/* Колонка «Действия»: доля от оставшихся 50 %, иконки в ряд */ #tbl-clusters th.col-actions, #tbl-clusters td.actions { - width: 1%; padding: 0.3rem 0.25rem; text-align: center; vertical-align: middle; @@ -1485,9 +1524,53 @@ html[data-theme="light"] .icon-btn--danger { .confirm-modal-overlay { z-index: 150; } -/* Журнал развёртывания: между окном «Состояние» (100) и подтверждением (150) */ +/* Информация после сохранения конфига на странице редактирования кластера */ +.cluster-edit-save-modal-overlay { + z-index: 160; +} +.modal-box--cluster-edit-save { + max-width: min(34rem, 100%); + text-align: left; +} +.cluster-edit-save-modal-title { + margin: 0 0 0.65rem; + font-size: 1.05rem; + text-align: center; +} +.cluster-edit-save-modal-body { + margin: 0; + font-size: 0.92rem; + line-height: 1.55; +} +.cluster-edit-save-modal-body p { + margin: 0 0 0.65rem; +} +.cluster-edit-save-modal-body p:last-child { + margin-bottom: 0; +} +.cluster-edit-save-modal-body ul { + margin: 0.35rem 0 0.85rem 1.15rem; + padding: 0; + list-style: disc; +} +.cluster-edit-save-modal-body li { + margin-bottom: 0.45rem; +} +.cluster-edit-save-modal-body li:last-child { + margin-bottom: 0; +} +.cluster-edit-save-modal-body code { + font-size: 0.86em; + word-break: break-word; +} +.cluster-edit-save-modal-body .cluster-edit-save-modal-lead { + margin-top: 0; + font-weight: 500; + color: var(--text); +} +/* Журнал развёртывания: поверх шапки и выпадающего меню API (до z-index 160); ниже полноэкранного спиннера (9998) */ .provision-log-modal-overlay { - z-index: 120; + z-index: 500; } .modal-box--provision-log .provision-log-body { max-height: min(70vh, 32rem); @@ -2041,33 +2124,44 @@ html[data-theme="light"] .cluster-detail-actions-card { border: 1px solid var(--border); background: rgba(234, 179, 8, 0.12); } +/* Ожидаемое состояние: узлы остановлены — не предупреждение, а подсказка */ +.cluster-kube-hint.cluster-kube-hint--info { + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.35); +} +html[data-theme="light"] .cluster-kube-hint.cluster-kube-hint--info { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(37, 99, 235, 0.35); +} /* Вводная карточка kubectl + отдельная карточка на каждый ресурс (как в шаблоне cluster_detail) */ .cluster-k8s-intro-card .cluster-k8s-scope-hint { margin: 0 0 0.75rem; } .cluster-k8s-resource-card .cluster-detail-section-title { - margin: 0 0 0.2rem; + margin: 0 0 0.55rem; font-size: 1.02rem; } -.cluster-k8s-cmd { - margin: 0 0 0.6rem; - font-size: 0.8rem; - line-height: 1.4; -} -.cluster-k8s-cmd code { - font-size: 0.78em; - word-break: break-all; -} -/* Сетка карточек узлов на странице кластера — на всю ширину секции */ +/* Сетка карточек узлов на странице кластера: не более 3 карточек в ряд */ .cluster-page-node-cards--wide.cluster-nodes-resources__cards-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; flex-wrap: wrap; overflow-x: visible; width: 100%; } +@media (max-width: 52rem) { + .cluster-page-node-cards--wide.cluster-nodes-resources__cards-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 32rem) { + .cluster-page-node-cards--wide.cluster-nodes-resources__cards-row { + grid-template-columns: 1fr; + } +} .cluster-page-node-cards--wide .cluster-node-res-card { width: auto; min-width: 0; @@ -2190,6 +2284,13 @@ body.cluster-page-loading { display: none; pointer-events: none; } +/* Старт/стоп кластера: тот же полноэкранный паттерн, z-index ниже полноэкранных ошибок при необходимости */ +.cluster-lifecycle-job-overlay { + z-index: 9996; +} +body.cluster-lifecycle-job-busy { + overflow: hidden; +} .page-loading-backdrop { position: absolute; inset: 0; @@ -2228,3 +2329,388 @@ html[data-theme="light"] .page-loading-center { max-width: 18rem; line-height: 1.4; } + +/* Редактирование кластера (cluster_edit.html). + Автор: Сергей Антропов — https://devops.org.ru */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.cluster-edit-hint { + margin: 0 0 1rem; + font-size: 0.92rem; + line-height: 1.45; +} + +.cluster-edit-mode-fieldset { + border: none; + margin: 0 0 1.25rem; + padding: 0; +} + +.cluster-edit-mode-legend { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 0 0 0.65rem; + padding: 0; +} + +/* Сегментный переключатель «Простой / Расширенный»: одна строка, равная высота */ +.cluster-edit-segmented { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + width: 100%; + max-width: 42rem; + padding: 4px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.18); + gap: 4px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12); +} +html[data-theme="light"] .cluster-edit-segmented { + background: rgba(15, 23, 42, 0.06); + box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06); +} + +.cluster-edit-segment-item { + position: relative; + display: flex; + flex: 1 1 0; + min-width: 0; + min-height: 5.35rem; +} + +.cluster-edit-segment-native { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; + pointer-events: none; +} + +.cluster-edit-segment-label { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 0; + margin: 0; + padding: 0.75rem 1.05rem; + border-radius: 10px; + cursor: pointer; + border: 1px solid transparent; + box-sizing: border-box; + transition: + background 0.18s ease, + color 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease; + color: var(--muted); + font-weight: 500; + line-height: 1.25; + min-height: 100%; +} + +.cluster-edit-segment-title { + font-size: 0.98rem; + color: inherit; + font-weight: 600; +} + +.cluster-edit-segment-sub-wrap { + display: flex; + flex-direction: column; + gap: 0.12rem; + margin-top: 0.35rem; +} + +.cluster-edit-segment-sub { + font-size: 0.76rem; + font-weight: 400; + opacity: 0.9; + line-height: 1.32; +} + +.cluster-edit-segment-native:focus-visible + .cluster-edit-segment-label { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.cluster-edit-segment-native:checked + .cluster-edit-segment-label { + background: var(--accent); + color: #f8fafc; + border-color: var(--accent); + box-shadow: 0 2px 14px rgba(59, 130, 246, 0.35); +} + +.cluster-edit-segment-native:checked + .cluster-edit-segment-label .cluster-edit-segment-sub { + color: inherit; + opacity: 0.92; +} + +.cluster-edit-mode-hint { + margin: 0.75rem 0 0; + font-size: 0.88rem; + line-height: 1.45; + max-width: 40rem; +} + +.cluster-edit-advanced-wrap { + margin-bottom: 1rem; +} + +.cluster-edit-advanced-intro { + margin: 0 0 1.15rem; + padding: 0.75rem 1rem; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(59, 130, 246, 0.08); +} +html[data-theme="light"] .cluster-edit-advanced-intro { + background: rgba(59, 130, 246, 0.06); +} + +.cluster-edit-advanced-intro-text { + margin: 0; + font-size: 0.88rem; + line-height: 1.5; + color: var(--fg); +} + +.cluster-edit-panels { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.15rem; +} + +.cluster-edit-panel { + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card); + overflow: hidden; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset; +} +html[data-theme="light"] .cluster-edit-panel { + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); +} + +.cluster-edit-panel-head { + padding: 0.85rem 1.1rem 0.75rem; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.14); +} +html[data-theme="light"] .cluster-edit-panel-head { + background: rgba(15, 23, 42, 0.04); +} + +.cluster-edit-panel-title { + margin: 0 0 0.35rem; + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +.cluster-edit-panel-desc { + margin: 0; + font-size: 0.82rem; + line-height: 1.45; +} + +.cluster-edit-panel-body { + padding: 1rem 1.1rem 1.05rem; +} + +.cluster-edit-panel-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.85rem 1.25rem; +} + +@media (min-width: 640px) { + .cluster-edit-panel-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.cluster-edit-panel-grid--tight { + gap: 0.65rem 1rem; +} + +.cluster-edit-panel-span { + grid-column: 1 / -1; +} + +.cluster-edit-check-label { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + line-height: 1.35; + cursor: pointer; + text-align: left; +} + +.cluster-edit-check-label input[type="checkbox"] { + margin: 0; + flex-shrink: 0; +} + +.cluster-edit-check-label--card { + display: inline-flex; + align-items: center; + box-sizing: border-box; + padding: 0.55rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); + max-width: 100%; +} + +/* Один ряд: краткий заголовок — пояснение (сжимается с многоточием при нехватке места) */ +.cluster-edit-check-inline { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 1; + overflow: hidden; +} + +.cluster-edit-check-title { + flex: 0 1 auto; + min-width: 0; + max-width: min(22rem, 58%); + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cluster-edit-check-sep { + flex-shrink: 0; + padding: 0 0.05rem; + user-select: none; +} + +.cluster-edit-check-hint { + flex: 1; + min-width: 0; + margin: 0; + font-size: 0.86em; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cluster-edit-check-hint a { + white-space: nowrap; +} +html[data-theme="light"] .cluster-edit-check-label--card { + background: rgba(15, 23, 42, 0.03); +} + +.cluster-edit-port-card { + margin-top: 0.75rem; + padding: 0.85rem 0.9rem 0.95rem; + border-radius: 10px; + border: 1px dashed var(--border); + background: rgba(0, 0, 0, 0.1); +} +html[data-theme="light"] .cluster-edit-port-card { + background: rgba(15, 23, 42, 0.03); +} + +.create-cluster-card .field-hint { + display: block; + margin-top: 0.3rem; + font-size: 0.76rem; + line-height: 1.35; +} + +.cluster-edit-kubeadm-textarea { + width: 100%; + min-height: 7rem; + resize: vertical; + font-size: 0.82rem; + line-height: 1.35; +} + +.cluster-edit-yaml-preview-card { + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); + overflow: hidden; +} +html[data-theme="light"] .cluster-edit-yaml-preview-card { + background: rgba(15, 23, 42, 0.04); +} + +.cluster-edit-yaml-preview-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.35rem 0.75rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); +} +html[data-theme="light"] .cluster-edit-yaml-preview-head { + background: rgba(15, 23, 42, 0.05); +} + +.cluster-edit-yaml-preview-title { + font-size: 0.82rem; + font-weight: 600; +} + +.cluster-edit-yaml-preview-note { + font-size: 0.75rem; +} + +.cluster-edit-yaml-preview-pre { + margin: 0; + padding: 0.75rem 1rem; + max-height: 22rem; + overflow: auto; + font-size: 0.78rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.cluster-edit-actions { + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} + +.cluster-edit-cancel-link { + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} diff --git a/app/templates/cluster_detail.html b/app/templates/cluster_detail.html index 478bcdf..3b7f32a 100644 --- a/app/templates/cluster_detail.html +++ b/app/templates/cluster_detail.html @@ -77,82 +77,69 @@

Kubernetes (kubectl)

- Данные по всему кластеру: для ресурсов в namespace используется - kubectl get … -A (все namespace); узлы и список namespace — без фильтра по ns. - Ниже каждый тип ресурса — отдельная карточка с тем же запросом, что выполняет API. + Данные по всему кластеру: ресурсы в namespace — со всех пространств имён; узлы и список + namespace — без ограничения по отдельному ns.

Узлы

-

kubectl get nodes -o json

Namespaces

-

kubectl get namespaces -o json

Поды

-

kubectl get pods -A -o json

Deployments

-

kubectl get deployments -A -o json

StatefulSets

-

kubectl get statefulsets -A -o json

DaemonSets

-

kubectl get daemonsets -A -o json

ReplicaSets

-

kubectl get replicasets -A -o json

Jobs

-

kubectl get jobs -A -o json

CronJobs

-

kubectl get cronjobs -A -o json

Сервисы

-

kubectl get services -A -o json

Ingresses

-

kubectl get ingresses.networking.k8s.io -A -o json

PVC

-

kubectl get pvc -A -o json

diff --git a/app/templates/cluster_edit.html b/app/templates/cluster_edit.html new file mode 100644 index 0000000..cf8bed9 --- /dev/null +++ b/app/templates/cluster_edit.html @@ -0,0 +1,384 @@ +{# Редактирование kind-config.yaml и meta.json для существующего каталога кластера. + Автор: Сергей Антропов — https://devops.org.ru #} +{% extends "base.html" %} + +{% block page_title %}Редактирование {{ cluster_name }}{% endblock %} + +{% block body_attrs %}data-cluster-name="{{ cluster_name | e }}"{% endblock %} + +{% block footer %} + +{% endblock %} + +{% block content %} +

+ Панель + + Кластер {{ cluster_name | e }} + + Редактирование +

+ +
+

Редактирование: {{ cluster_name | e }}

+

+ Загрузка подсказки… +

+
+ +
+

Конфигурация на диске

+

+ Изменения пишутся в clusters/{{ cluster_name | e }}/kind-config.yaml и meta.json. + После сохранения конфигурации YAML показывается окно с тем, как изменения попадут в kind; кратко: при «Старт» зарегистрированный кластер пересоздаётся по новому файлу (данные в clusters/{{ cluster_name | e }}/ сохраняются). +

+ +
+
+ Режим настройки +
+
+ + +
+
+ + +
+
+

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ + +
+ +
+ + Отмена +
+ +

+
+
+ +{# После успешного PUT /config — как применяются изменения (пересоздание при «Старт» и т.д.). #} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 03aad05..6bea57e 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -64,12 +64,12 @@ - - - - - - + + + + + + diff --git a/app/templates/partials/dashboard_modals.html b/app/templates/partials/dashboard_modals.html index 3844cf4..726ecad 100644 --- a/app/templates/partials/dashboard_modals.html +++ b/app/templates/partials/dashboard_modals.html @@ -11,22 +11,6 @@ @@ -36,9 +20,9 @@ - + - + @@ -87,3 +71,20 @@ + +{# Полноэкранный спиннер на время фонового старта/стопа узлов (главная и страница кластера). #} + diff --git a/requirements.txt b/requirements.txt index e8522c5..a441a26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ fastapi>=0.115.0 uvicorn[standard]>=0.32.0 jinja2>=3.1.4 pydantic-settings>=2.6.0 +pyyaml>=6.0.1
ИмяkindkubeconfigВерсияWorkersСостояниеИмяkindkubeconfigВерсияWorkersСостояние Действия