Панель: редактирование конфига кластера, reapply при старте, UI и таблица

- PUT/GET конфигурации кластера, страница редактирования и модалка после сохранения
- После смены kind-config: флаг в meta и start_cluster_reapply (kind delete + create)
- Старт/стоп: полноэкранный спиннер до завершения job; модалки и документация API
- Таблица кластеров: колонка Имя 40% при table-layout fixed; чекбоксы без width 100%
- Карточки ресурсов узлов на странице кластера: до 3 в ряд; прочие правки стилей и dashboard.js
This commit is contained in:
Sergey Antropoff
2026-04-04 10:49:40 +03:00
parent af0d1705cc
commit 52538d9816
14 changed files with 2571 additions and 137 deletions
+247 -14
View File
@@ -21,12 +21,20 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from starlette.background import BackgroundTask 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 ( from core.cluster_lifecycle import (
KindClusterError, KindClusterError,
cluster_summary_for_api, cluster_summary_for_api,
configured_container_cli, configured_container_cli,
create_cluster_non_interactive, create_cluster_non_interactive,
delete_kind_cluster_and_data, delete_kind_cluster_and_data,
delete_kind_cluster_keep_data,
kubectl_delete_pod, kubectl_delete_pod,
kubectl_get_json, kubectl_get_json,
kubectl_get_all_namespaces_wide, 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 kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host
from models.schemas import ( from models.schemas import (
AggregateResourcesSummary, AggregateResourcesSummary,
ClusterConfigGetResponse,
ClusterConfigUpdateRequest,
ClusterConfigUpdateResponse,
ClusterCreateAccepted, ClusterCreateAccepted,
ClusterCreateRequest, ClusterCreateRequest,
ClusterOverviewResponse, ClusterOverviewResponse,
@@ -74,6 +85,11 @@ from models.schemas import (
logger = logging.getLogger("kind_k8s.api.clusters") logger = logging.getLogger("kind_k8s.api.clusters")
# Сообщение UI: узлы kind остановлены — kubectl до API кластера не достучится (DNS control-plane и т.д.).
_K8S_CLUSTER_STOPPED_MSG = (
"Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели)."
)
# Безопасные имена namespace/pod для kubectl (без shell-метасимволов). # Безопасные имена 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]$") _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 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( @router.get(
"/clusters/{name}/kubeconfig", "/clusters/{name}/kubeconfig",
summary="Скачать kubeconfig", summary="Скачать kubeconfig",
@@ -329,22 +439,28 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
"""``kubectl get nodes`` и ``kubectl get pods -A`` по сохранённому kubeconfig.""" """``kubectl get nodes`` и ``kubectl get pods -A`` по сохранённому kubeconfig."""
if not validate_cluster_name(name): if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
kc = clusters_dir() / name / "kubeconfig" n = name.strip()
kc = clusters_dir() / n / "kubeconfig"
if not kc.is_file(): 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( nodes_rc, nodes_out = await asyncio.to_thread(
kubectl_nodes_wide, kubectl_nodes_wide,
cluster_name=name, cluster_name=n,
kubeconfig=kc, kubeconfig=kc,
) )
pods_rc, pods_out = await asyncio.to_thread( pods_rc, pods_out = await asyncio.to_thread(
kubectl_pods_all_namespaces, kubectl_pods_all_namespaces,
cluster_name=name, cluster_name=n,
kubeconfig=kc, kubeconfig=kc,
) )
return ClusterWorkloadsResponse( return ClusterWorkloadsResponse(
cluster_name=name, cluster_name=n,
nodes_rc=nodes_rc, nodes_rc=nodes_rc,
nodes_output=nodes_out, nodes_output=nodes_out,
pods_rc=pods_rc, pods_rc=pods_rc,
@@ -387,6 +503,8 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse:
k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None) k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_pvcs = 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(): if not kc.is_file():
kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/" kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/"
_kube_missing = K8sListJsonBlock(ok=False, items=[], message=kube_err) _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_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _kube_missing
k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing
k8s_jobs = k8s_cronjobs = k8s_pvcs = _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: else:
( (
n_res, n_res,
@@ -443,7 +570,7 @@ async def cluster_overview(name: str) -> ClusterOverviewResponse:
meta = summary.get("meta") meta = summary.get("meta")
return ClusterOverviewResponse( return ClusterOverviewResponse(
cluster_name=n, cluster_name=n,
registered_in_kind=bool(summary["registered_in_kind"]), registered_in_kind=reg_in_kind,
kind_nodes_running=kind_running, kind_nodes_running=kind_running,
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]), has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
has_provision_log=bool(summary.get("has_provision_log")), 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) 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: async def _run_stop_cluster_job(job_id: str, name: str) -> None:
"""Фоновая остановка узлов с журналом в GET /jobs/{id}.""" """Фоновая остановка узлов с журналом в GET /jobs/{id}."""
n = name.strip() n = name.strip()
@@ -811,10 +1021,41 @@ async def start_cluster_nodes(
n = name.strip() 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: async with kind_cluster_lock:
in_kind = n in await asyncio.to_thread(list_registered_kind_clusters) in_kind = n in await asyncio.to_thread(list_registered_kind_clusters)
if in_kind: 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) rec = await job_store.create_job("start_containers", cluster_name=n)
background_tasks.add_task(_run_start_containers_job, rec.job_id, n) background_tasks.add_task(_run_start_containers_job, rec.job_id, n)
logger.info("Запуск контейнеров кластера %s в фоне, job_id=%s", n, rec.job_id) 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 — создайте кластер или восстановите конфиг.", 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) 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) 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) logger.info("Фоновый старт кластера %s по конфигу, job_id=%s", n, rec.job_id)
+215
View File
@@ -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(),
}
+54
View File
@@ -630,6 +630,11 @@ def create_cluster_non_interactive(
} }
if patched: if patched:
meta["kubeconfig_host_path"] = str(host_kube_path.relative_to(root)) 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") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
_progress("Завершение", 95) _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) 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]: def _sort_kind_node_containers(names: list[str]) -> list[str]:
"""Сначала control-plane, затем остальные — удобнее для ``docker start``.""" """Сначала control-plane, затем остальные — удобнее для ``docker start``."""
+128 -12
View File
@@ -20,12 +20,13 @@
| Маршрут | Описание | | Маршрут | Описание |
|---------|----------| |---------|----------|
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | | `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 /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 /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/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**. **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/stats` | Сводка для дашборда |
| GET | `/api/v1/clusters` | Список кластеров (поле `has_provision_log` — есть ли `clusters/<имя>/provision_log.json`) | | GET | `/api/v1/clusters` | Список кластеров (поле `has_provision_log` — есть ли `clusters/<имя>/provision_log.json`) |
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) | | 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}` | | 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}` | Детали + `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}/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}/workloads` | Узлы и поды (`kubectl`) |
| GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) | | GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) |
| POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта | | 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). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В ответе задания поля **`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**). - В **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-коды. - Для **`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`); дочерний процесс текущей команды получает принудительное завершение. - Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
--- ---
@@ -311,7 +314,7 @@ Accept: text/markdown
## POST /api/v1/jobs/{job_id}/cancel ## 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:** **Пример ответа 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 ## GET /api/v1/clusters/{name}/kubeconfig
Скачать kubeconfig для **kubectl на машине пользователя** (ответ — тело файла, `Content-Disposition`: `kubeconfig-{name}.yaml`). Скачать kubeconfig для **kubectl на машине пользователя** (ответ — тело файла, `Content-Disposition`: `kubeconfig-{name}.yaml`).
@@ -342,7 +441,7 @@ Accept: text/markdown
## GET /api/v1/clusters/{name}/provision-log ## 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:** файла нет (кластер ещё не создавали/не стартовали с сохранением журнала, или каталог без записи). **Ошибка 404:** файла нет (кластер ещё не создавали/не стартовали с сохранением журнала, или каталог без записи).
@@ -392,6 +491,8 @@ Accept: text/markdown
Если kubeconfig нет: `error` — строка вида **`Нет сохранённого kubeconfig в clusters/<имя>/`**, поля вывода kubectl могут быть `null`. Если kubeconfig нет: `error` — строка вида **`Нет сохранённого kubeconfig в clusters/<имя>/`**, поля вывода kubectl могут быть `null`.
Если кластер **есть в kind**, но **узлы остановлены** (`docker stop` / «Стоп» в UI): **`error`** — краткое сообщение без вызова kubectl (вместо сырого stderr про DNS `*-control-plane`).
**Ошибка 400:** некорректное имя кластера. **Ошибка 400:** некорректное имя кластера.
--- ---
@@ -401,6 +502,7 @@ Accept: text/markdown
Единый ответ для **страницы кластера** (`GET /cluster/{name}`): флаги и `meta` как в списке кластеров, **метрики узлов** (docker/podman), **`aggregate_resources`** (как в `GET /stats` для донатов), данные Kubernetes в виде **`kubectl get … -o json`** (разбор `items` на фронтенде в таблицы). Единый ответ для **страницы кластера** (`GET /cluster/{name}`): флаги и `meta` как в списке кластеров, **метрики узлов** (docker/podman), **`aggregate_resources`** (как в `GET /stats` для донатов), данные Kubernetes в виде **`kubectl get … -o json`** (разбор `items` на фронтенде в таблицы).
- При отсутствии kubeconfig: **`kubeconfig_error`** — строка-пояснение; блоки **`k8s_*`** недоступны (см. ниже). - При отсутствии 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-блоки). - Поля **`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`** (все пространства имён). - Блоки **`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`; - **`ok`**: `true` при успешном `kubectl`;
@@ -563,10 +665,13 @@ Accept: text/markdown
## POST /api/v1/clusters/{name}/start ## 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`**). 1. Кластер **зарегистрирован** в kind и в **`meta.json`** **нет** флага **`apply_kind_config_on_next_start`** — задание **`start_containers`** (поочерёдный `docker start` узлов, `mode`: **`containers`**).
2. В kind кластера **нет**, но есть сохранённый **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**), логика как при создании (в т.ч. скачивание образа при необходимости). 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 (запуск узлов):** **Пример ответа 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`.
--- ---
+22
View File
@@ -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="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
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="Создание кластера и задания") @app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания")
async def cluster_create_page(request: Request) -> HTMLResponse: async def cluster_create_page(request: Request) -> HTMLResponse:
"""Форма создания кластера, прогресс и список последних заданий.""" """Форма создания кластера, прогресс и список последних заданий."""
+72 -13
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class ClusterCreateRequest(BaseModel): class ClusterCreateRequest(BaseModel):
@@ -143,12 +143,12 @@ class ClusterWorkloadsResponse(BaseModel):
class K8sListJsonBlock(BaseModel): class K8sListJsonBlock(BaseModel):
"""Фрагмент ответа ``kubectl get … -o json`` (items) для таблицы в UI.""" """Фрагмент списка ресурсов Kubernetes (поле ``items`` из JSON List) для таблицы в UI."""
ok: bool = True 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) 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): class PodRestartRequest(BaseModel):
@@ -169,7 +169,7 @@ class PodRestartResponse(BaseModel):
class ClusterOverviewResponse(BaseModel): class ClusterOverviewResponse(BaseModel):
"""Сводка для страницы кластера: мета, ресурсы узлов, kubectl (поды, workloads).""" """Сводка для страницы кластера: мета, ресурсы узлов, таблицы ресурсов Kubernetes."""
cluster_name: str cluster_name: str
registered_in_kind: bool = False registered_in_kind: bool = False
@@ -182,7 +182,7 @@ class ClusterOverviewResponse(BaseModel):
default_factory=lambda: KindClusterResources(cluster_name="", nodes=[], note=None), default_factory=lambda: KindClusterResources(cluster_name="", nodes=[], note=None),
) )
aggregate_resources: AggregateResourcesSummary = Field(default_factory=AggregateResourcesSummary) 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_rc: int | None = None
nodes_output: str | None = None nodes_output: str | None = None
pods_rc: int | None = None pods_rc: int | None = None
@@ -197,8 +197,8 @@ class ClusterOverviewResponse(BaseModel):
services_output: str | None = None services_output: str | None = None
ingresses_rc: int | None = None ingresses_rc: int | None = None
ingresses_output: str | None = None ingresses_output: str | None = None
k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get nodes -o json") k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Узлы кластера")
k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get pods -A -o json") k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="Поды, все namespace")
k8s_deployments: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_deployments: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock)
k8s_statefulsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) k8s_statefulsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock)
k8s_daemonsets: 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_ingresses: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock)
k8s_namespaces: K8sListJsonBlock = Field( k8s_namespaces: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock, default_factory=K8sListJsonBlock,
description="kubectl get namespaces -o json", description="Namespaces",
) )
k8s_replicasets: K8sListJsonBlock = Field( k8s_replicasets: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock, default_factory=K8sListJsonBlock,
description="kubectl get replicasets -A -o json", description="ReplicaSets, все namespace",
) )
k8s_jobs: K8sListJsonBlock = Field( k8s_jobs: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock, default_factory=K8sListJsonBlock,
description="kubectl get jobs -A -o json", description="Jobs, все namespace",
) )
k8s_cronjobs: K8sListJsonBlock = Field( k8s_cronjobs: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock, default_factory=K8sListJsonBlock,
description="kubectl get cronjobs -A -o json", description="CronJobs, все namespace",
) )
k8s_pvcs: K8sListJsonBlock = Field( k8s_pvcs: K8sListJsonBlock = Field(
default_factory=K8sListJsonBlock, 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="Актуальная сводка для строки таблицы / панели")
+811
View File
@@ -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 = "<option value=\"\">— загрузка —</option>";
});
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 = "<option value=\"\">(список пуст — введите вручную)</option>";
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 = "<option value=\"\">(ошибка загрузки тегов)</option>";
});
}
}
/**
* Разбор секции networking: (документация kind — cluster-wide networking).
* @param {string} text
* @returns {Pick<AdvancedFormState, 'ipFamily'|'apiServerAddress'|'apiServerPort'|'podSubnet'|'serviceSubnet'|'kubeProxyMode'|'disableDefaultCNI'>}
*/
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<string, unknown>} */
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);
})();
+93 -28
View File
@@ -26,8 +26,8 @@
var pollLockCreateForm = false; var pollLockCreateForm = false;
/** @type {string | null} */ /** @type {string | null} */
var currentPollJobId = null; var currentPollJobId = null;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */ /** Полноэкранный спиннер старт/стоп (не используется при создании кластера из формы). */
var currentModalClusterName = null; var clusterLifecycleJobOverlayActive = false;
/** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */ /** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */
var cachedClusterResourcesByName = {}; var cachedClusterResourcesByName = {};
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */ /** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
@@ -84,6 +84,29 @@
document.body.classList.remove("dashboard-home-loading"); 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) { function formatApiError(data, fallback) {
if (!data) return fallback; if (!data) return fallback;
if (typeof data.detail === "string") return data.detail; if (typeof data.detail === "string") return data.detail;
@@ -213,6 +236,8 @@
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
restart: restart:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
edit:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>',
}; };
/** @type {ReturnType<typeof setTimeout> | null} */ /** @type {ReturnType<typeof setTimeout> | null} */
@@ -1249,7 +1274,7 @@
host.appendChild( host.appendChild(
iconActionButton( iconActionButton(
ICONS.state, ICONS.state,
"Состояние: узлы и поды (kubectl get nodes / pods)", "Состояние: узлы и поды",
"", "",
function () { function () {
openWorkloadsModal(name); openWorkloadsModal(name);
@@ -1257,6 +1282,17 @@
false, 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) { if (c.registered_in_kind && c.kind_nodes_running) {
host.appendChild( host.appendChild(
iconActionButton( iconActionButton(
@@ -1355,15 +1391,26 @@
); );
appendClusterNodeCardsRow(document.getElementById("cluster-page-node-cards"), d.cluster_resources); appendClusterNodeCardsRow(document.getElementById("cluster-page-node-cards"), d.cluster_resources);
const kubeErr = d.kubeconfig_error || null; const kubeErr = d.kubeconfig_error || null;
const clusterStoppedInKind = !!d.registered_in_kind && !d.kind_nodes_running;
if (hint) { if (hint) {
if (kubeErr) { if (kubeErr) {
hint.textContent = kubeErr; hint.textContent = kubeErr;
hint.classList.remove("hidden"); hint.classList.remove("hidden");
if (clusterStoppedInKind) {
hint.classList.add("cluster-kube-hint--info");
} else {
hint.classList.remove("cluster-kube-hint--info");
}
} else { } else {
hint.textContent = ""; hint.textContent = "";
hint.classList.add("hidden"); hint.classList.add("hidden");
hint.classList.remove("cluster-kube-hint--info");
} }
} }
if (msg && clusterStoppedInKind) {
msg.textContent =
"Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели).";
}
fillClusterK8sTablesFromOverview(d); fillClusterK8sTablesFromOverview(d);
} catch (e) { } catch (e) {
const errText = e && e.message ? e.message : String(e); const errText = e && e.message ? e.message : String(e);
@@ -1635,24 +1682,24 @@
"<span class=\"muted\" title=\"Нет в kind — только локальный каталог или кластер удалён из kind\">—</span>"; "<span class=\"muted\" title=\"Нет в kind — только локальный каталог или кластер удалён из kind\">—</span>";
} }
tr.innerHTML = tr.innerHTML =
"<td><a class=\"cluster-name-link\" href=\"/cluster/" + "<td class=\"col-cluster-name\"><a class=\"cluster-name-link\" href=\"/cluster/" +
encodeURIComponent(c.name) + encodeURIComponent(c.name) +
"\" title=\"Страница кластера: ресурсы и kubectl\"><code class=\"cluster-name\">" + "\" title=\"Страница кластера: ресурсы и kubectl\"><code class=\"cluster-name\">" +
nameEsc + nameEsc +
"</code></a></td>" + "</code></a></td>" +
"<td>" + "<td class=\"col-kind\">" +
(c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") + (c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" + "</td>" +
"<td>" + "<td class=\"col-kubeconfig\">" +
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") + (c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" + "</td>" +
"<td>" + "<td class=\"col-version\">" +
escapeHtml(String(ver)) + escapeHtml(String(ver)) +
"</td>" + "</td>" +
"<td>" + "<td class=\"col-workers\">" +
escapeHtml(String(wn)) + escapeHtml(String(wn)) +
"</td>" + "</td>" +
"<td>" + "<td class=\"col-state\">" +
stateCell + stateCell +
"</td>" + "</td>" +
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>"; "<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
@@ -1725,6 +1772,7 @@
const st = escapeHtml(j.status || ""); const st = escapeHtml(j.status || "");
var kindTag = ""; var kindTag = "";
if (j.kind === "start_cluster") 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 === "stop_containers") kindTag = "[стоп] ";
else if (j.kind === "start_containers") kindTag = "[запуск узлов] "; else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
var cellMsg = kindTag + (j.message || "").slice(0, 140); var cellMsg = kindTag + (j.message || "").slice(0, 140);
@@ -1774,15 +1822,12 @@
const nodes = document.getElementById("modal-nodes"); const nodes = document.getElementById("modal-nodes");
const pods = document.getElementById("modal-pods"); const pods = document.getElementById("modal-pods");
const spin = document.getElementById("modal-spinner"); const spin = document.getElementById("modal-spinner");
const modalDlWrap = document.getElementById("modal-dl-wrap");
if (!overlay) return; if (!overlay) return;
hideActionTooltip(); hideActionTooltip();
currentModalClusterName = name;
document.getElementById("modal-title").textContent = "Кластер «" + name + "»"; document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
sub.textContent = ""; sub.textContent = "";
nodes.textContent = ""; nodes.textContent = "";
pods.textContent = ""; pods.textContent = "";
if (modalDlWrap) modalDlWrap.classList.add("hidden");
if (spin) spin.classList.remove("hidden"); if (spin) spin.classList.remove("hidden");
overlay.classList.remove("hidden"); overlay.classList.remove("hidden");
document.body.classList.add("modal-open"); document.body.classList.add("modal-open");
@@ -1792,10 +1837,9 @@
sub.textContent = w.error; sub.textContent = w.error;
return; 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 || "(пусто)"; nodes.textContent = w.nodes_output || "(пусто)";
pods.textContent = w.pods_output || "(пусто)"; pods.textContent = w.pods_output || "(пусто)";
if (modalDlWrap) modalDlWrap.classList.remove("hidden");
} catch (e) { } catch (e) {
sub.textContent = "Ошибка: " + e.message; sub.textContent = "Ошибка: " + e.message;
} finally { } finally {
@@ -1878,9 +1922,6 @@
function closeModal() { function closeModal() {
hideActionTooltip(); hideActionTooltip();
const overlay = document.getElementById("modal-overlay"); 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"); if (overlay) overlay.classList.add("hidden");
document.body.classList.remove("modal-open"); document.body.classList.remove("modal-open");
} }
@@ -1908,7 +1949,10 @@
} }
if (r.status === 202 && data.job_id) { if (r.status === 202 && data.job_id) {
setProgressHint(name, "stop"); setProgressHint(name, "stop");
pollJob(data.job_id, { lockCreateForm: false }); pollJob(data.job_id, {
lockCreateForm: false,
lifecycleOverlayLabel: "Останавливаем узлы кластера «" + name + "»…",
});
return; return;
} }
if (!r.ok) { if (!r.ok) {
@@ -1941,8 +1985,15 @@
if (r.status === 202 && data.job_id) { if (r.status === 202 && data.job_id) {
var sm = data.mode || "kind_config"; var sm = data.mode || "kind_config";
if (sm === "containers") setProgressHint(name, "start_containers"); if (sm === "containers") setProgressHint(name, "start_containers");
else if (sm === "kind_config_reapply") setProgressHint(name, "start_reapply");
else setProgressHint(name, "start_config"); 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; return;
} }
if (!r.ok) { if (!r.ok) {
@@ -2015,7 +2066,7 @@
/** /**
* Подсказка над прогресс-баром (без технических имён команд). * Подсказка над прогресс-баром (без технических имён команд).
* @param {string | null} clusterName * @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) { function setProgressHint(clusterName, mode) {
const hint = document.getElementById("create-progress-hint"); const hint = document.getElementById("create-progress-hint");
@@ -2026,6 +2077,11 @@
hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже."; hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже.";
} else if (mode === "start_containers") { } else if (mode === "start_containers") {
hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже."; hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже.";
} else if (mode === "start_reapply") {
hint.textContent =
"Пересоздаём кластер " +
named +
" в kind по обновлённому kind-config.yaml (сначала удаление записи, затем создание). Журнал ниже.";
} else if (mode === "start_config") { } else if (mode === "start_config") {
hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже."; hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже.";
} else { } else {
@@ -2052,6 +2108,7 @@
} }
function stopPollJob() { function stopPollJob() {
hideClusterLifecycleJobOverlay();
if (pollTimer) { if (pollTimer) {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
@@ -2077,7 +2134,8 @@
/** /**
* @param {string} jobId * @param {string} jobId
* @param {{ lockCreateForm?: boolean }} [opts] * @param {{ lockCreateForm?: boolean, lifecycleOverlayLabel?: string }} [opts]
* ``lifecycleOverlayLabel`` — полноэкранный спиннер (старт/стоп с главной или страницы кластера).
*/ */
function pollJob(jobId, opts) { function pollJob(jobId, opts) {
opts = opts || {}; opts = opts || {};
@@ -2096,6 +2154,9 @@
if (pollLockCreateForm) { if (pollLockCreateForm) {
setCreateFormDisabled(true); setCreateFormDisabled(true);
} }
if (opts.lifecycleOverlayLabel) {
showClusterLifecycleJobOverlay(opts.lifecycleOverlayLabel);
}
showCreateProgress(true); showCreateProgress(true);
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" }); updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
@@ -2105,6 +2166,14 @@
const preEl = document.getElementById("job-json"); const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2); if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
updateCreateProgressFromJob(j); 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 (logEl) {
if (j.progress_log && j.progress_log.length) { if (j.progress_log && j.progress_log.length) {
logEl.removeAttribute("data-placeholder"); logEl.removeAttribute("data-placeholder");
@@ -2123,6 +2192,8 @@
if (msg) { if (msg) {
if (j.status === "success") { if (j.status === "success") {
if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу."; 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 === "stop_containers") msg.textContent = "Узлы кластера остановлены.";
else if (j.kind === "start_containers") msg.textContent = "Узлы кластера запущены."; else if (j.kind === "start_containers") msg.textContent = "Узлы кластера запущены.";
else msg.textContent = "Кластер создан."; else msg.textContent = "Кластер создан.";
@@ -2131,6 +2202,7 @@
} }
if (j.status === "success") { if (j.status === "success") {
if (j.kind === "start_cluster") showToast("Кластер запущен", false); 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 === "stop_containers") showToast("Узлы остановлены", false);
else if (j.kind === "start_containers") showToast("Узлы запущены", false); else if (j.kind === "start_containers") showToast("Узлы запущены", false);
else showToast("Кластер создан", false); else showToast("Кластер создан", false);
@@ -2255,13 +2327,6 @@
const mClose = document.getElementById("modal-close"); const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal); 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"); const overlay = document.getElementById("modal-overlay");
if (overlay) { if (overlay) {
overlay.addEventListener("click", function (ev) { overlay.addEventListener("click", function (ev) {
+517 -31
View File
@@ -805,7 +805,7 @@ html[data-theme="light"] .cluster-node-res-card__bar-track {
font-weight: 600; 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 { .create-form-grid .create-form-field select {
width: 100%; width: 100%;
max-width: none; max-width: none;
@@ -1102,19 +1102,6 @@ html[data-theme="light"] .job-details {
justify-content: center; justify-content: center;
gap: 0.65rem 1rem; 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 { .modal-title-text {
margin: 0; margin: 0;
font-size: 1.05rem; font-size: 1.05rem;
@@ -1211,7 +1198,7 @@ button {
font: inherit; font: inherit;
} }
input, input:not([type="checkbox"]),
select { select {
width: 100%; width: 100%;
max-width: 28rem; max-width: 28rem;
@@ -1222,6 +1209,12 @@ select {
color: inherit; color: inherit;
} }
/* Чекбоксы: без растягивания на ширину полей text/select */
input[type="checkbox"] {
width: auto;
max-width: none;
}
button { button {
margin-top: 0.75rem; margin-top: 0.75rem;
padding: 0.45rem 0.85rem; padding: 0.45rem 0.85rem;
@@ -1359,10 +1352,56 @@ button.btn-small {
font-size: 0.8rem; 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 th.col-actions,
#tbl-clusters td.actions { #tbl-clusters td.actions {
width: 1%;
padding: 0.3rem 0.25rem; padding: 0.3rem 0.25rem;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
@@ -1485,9 +1524,53 @@ html[data-theme="light"] .icon-btn--danger {
.confirm-modal-overlay { .confirm-modal-overlay {
z-index: 150; 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 { .provision-log-modal-overlay {
z-index: 120; z-index: 500;
} }
.modal-box--provision-log .provision-log-body { .modal-box--provision-log .provision-log-body {
max-height: min(70vh, 32rem); max-height: min(70vh, 32rem);
@@ -2041,33 +2124,44 @@ html[data-theme="light"] .cluster-detail-actions-card {
border: 1px solid var(--border); border: 1px solid var(--border);
background: rgba(234, 179, 8, 0.12); 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) */ /* Вводная карточка kubectl + отдельная карточка на каждый ресурс (как в шаблоне cluster_detail) */
.cluster-k8s-intro-card .cluster-k8s-scope-hint { .cluster-k8s-intro-card .cluster-k8s-scope-hint {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.cluster-k8s-resource-card .cluster-detail-section-title { .cluster-k8s-resource-card .cluster-detail-section-title {
margin: 0 0 0.2rem; margin: 0 0 0.55rem;
font-size: 1.02rem; 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 { .cluster-page-node-cards--wide.cluster-nodes-resources__cards-row {
display: grid; 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; flex-wrap: wrap;
overflow-x: visible; overflow-x: visible;
width: 100%; 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 { .cluster-page-node-cards--wide .cluster-node-res-card {
width: auto; width: auto;
min-width: 0; min-width: 0;
@@ -2190,6 +2284,13 @@ body.cluster-page-loading {
display: none; display: none;
pointer-events: none; pointer-events: none;
} }
/* Старт/стоп кластера: тот же полноэкранный паттерн, z-index ниже полноэкранных ошибок при необходимости */
.cluster-lifecycle-job-overlay {
z-index: 9996;
}
body.cluster-lifecycle-job-busy {
overflow: hidden;
}
.page-loading-backdrop { .page-loading-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -2228,3 +2329,388 @@ html[data-theme="light"] .page-loading-center {
max-width: 18rem; max-width: 18rem;
line-height: 1.4; 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;
}
+2 -15
View File
@@ -77,82 +77,69 @@
<section class="card cluster-k8s-intro-card" aria-labelledby="cluster-k8s-heading"> <section class="card cluster-k8s-intro-card" aria-labelledby="cluster-k8s-heading">
<h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2> <h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2>
<p class="muted cluster-k8s-scope-hint"> <p class="muted cluster-k8s-scope-hint">
Данные по <strong>всему кластеру</strong>: для ресурсов в namespace используется Данные по <strong>всему кластеру</strong>: ресурсы в namespace — со всех пространств имён; узлы и список
<code>kubectl get … -A</code> (все namespace); узлы и список namespace — без фильтра по ns. namespace — без ограничения по отдельному ns.
Ниже каждый тип ресурса — отдельная карточка с тем же запросом, что выполняет API.
</p> </p>
<p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p> <p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-nodes-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-nodes-title">
<h2 class="cluster-detail-section-title" id="k8s-nodes-title">Узлы</h2> <h2 class="cluster-detail-section-title" id="k8s-nodes-title">Узлы</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get nodes -o json</code></p>
<div id="cluster-k8s-nodes-root" class="cluster-k8s-root" role="region" aria-label="Узлы кластера"></div> <div id="cluster-k8s-nodes-root" class="cluster-k8s-root" role="region" aria-label="Узлы кластера"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ns-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ns-title">
<h2 class="cluster-detail-section-title" id="k8s-ns-title">Namespaces</h2> <h2 class="cluster-detail-section-title" id="k8s-ns-title">Namespaces</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get namespaces -o json</code></p>
<div id="cluster-k8s-namespaces-root" class="cluster-k8s-root" role="region" aria-label="Namespaces"></div> <div id="cluster-k8s-namespaces-root" class="cluster-k8s-root" role="region" aria-label="Namespaces"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title">
<h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2> <h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get pods -A -o json</code></p>
<div id="cluster-k8s-pods-root" class="cluster-k8s-root" role="region" aria-label="Поды"></div> <div id="cluster-k8s-pods-root" class="cluster-k8s-root" role="region" aria-label="Поды"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title">
<h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2> <h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get deployments -A -o json</code></p>
<div id="cluster-k8s-deployments-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-deployments-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title">
<h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2> <h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get statefulsets -A -o json</code></p>
<div id="cluster-k8s-statefulsets-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-statefulsets-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title">
<h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2> <h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get daemonsets -A -o json</code></p>
<div id="cluster-k8s-daemonsets-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-daemonsets-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title">
<h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2> <h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get replicasets -A -o json</code></p>
<div id="cluster-k8s-replicasets-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-replicasets-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title">
<h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2> <h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get jobs -A -o json</code></p>
<div id="cluster-k8s-jobs-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-jobs-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title">
<h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2> <h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get cronjobs -A -o json</code></p>
<div id="cluster-k8s-cronjobs-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-cronjobs-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title">
<h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2> <h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get services -A -o json</code></p>
<div id="cluster-k8s-services-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-services-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title">
<h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2> <h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get ingresses.networking.k8s.io -A -o json</code></p>
<div id="cluster-k8s-ingresses-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-ingresses-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title">
<h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2> <h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2>
<p class="muted cluster-k8s-cmd"><code>kubectl get pvc -A -o json</code></p>
<div id="cluster-k8s-pvcs-root" class="cluster-k8s-root" role="region" aria-label="PersistentVolumeClaim"></div> <div id="cluster-k8s-pvcs-root" class="cluster-k8s-root" role="region" aria-label="PersistentVolumeClaim"></div>
</section> </section>
+384
View File
@@ -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 %}
<div class="footer-inner">
<p class="footer-copyright">
© {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %}
{% block content %}
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
<a href="/">Панель</a>
<span aria-hidden="true"> / </span>
<a href="/cluster/{{ cluster_name }}">Кластер <code>{{ cluster_name | e }}</code></a>
<span aria-hidden="true"> / </span>
<span>Редактирование</span>
</p>
<section class="card hero-panel cluster-create-hero" aria-labelledby="edit-hero-title">
<h1 class="page-title" id="edit-hero-title">Редактирование: <code>{{ cluster_name | e }}</code></h1>
<p class="muted page-lead" id="edit-kind-note">
Загрузка подсказки…
</p>
</section>
<section class="card create-cluster-card">
<h2 class="create-cluster-card__title">Конфигурация на диске</h2>
<p class="muted cluster-edit-hint">
Изменения пишутся в <code>clusters/{{ cluster_name | e }}/kind-config.yaml</code> и <code>meta.json</code>.
После сохранения конфигурации YAML показывается окно с тем, как изменения попадут в kind; кратко: при «Старт» зарегистрированный кластер пересоздаётся по новому файлу (данные в <code>clusters/{{ cluster_name | e }}/</code> сохраняются).
</p>
<form id="form-cluster-edit" novalidate>
<fieldset class="cluster-edit-mode-fieldset">
<legend class="cluster-edit-mode-legend">Режим настройки</legend>
<div
class="cluster-edit-segmented"
role="radiogroup"
aria-label="Режим настройки кластера kind"
>
<div class="cluster-edit-segment-item">
<input
type="radio"
name="save_mode"
id="seg-mode-simple"
class="cluster-edit-segment-native"
value="simple"
checked
/>
<label for="seg-mode-simple" class="cluster-edit-segment-label">
<span class="cluster-edit-segment-title">Простой</span>
<span class="cluster-edit-segment-sub-wrap">
<span class="cluster-edit-segment-sub">Тег образа kindest/node</span>
<span class="cluster-edit-segment-sub">и число worker-нод</span>
</span>
</label>
</div>
<div class="cluster-edit-segment-item">
<input
type="radio"
name="save_mode"
id="seg-mode-advanced"
class="cluster-edit-segment-native"
value="yaml"
/>
<label for="seg-mode-advanced" class="cluster-edit-segment-label">
<span class="cluster-edit-segment-title">Расширенный</span>
<span class="cluster-edit-segment-sub-wrap">
<span class="cluster-edit-segment-sub">Сеть, ноды, порты</span>
<span class="cluster-edit-segment-sub">и патчи kubeadm</span>
</span>
</label>
</div>
</div>
<p class="muted cluster-edit-mode-hint" id="cluster-edit-mode-hint"></p>
</fieldset>
<div class="create-form-grid" id="edit-simple-block">
<div class="create-form-field">
<label for="version-select-edit">
<a
href="https://hub.docker.com/r/kindest/node/tags"
target="_blank"
rel="noopener noreferrer"
class="create-form-doc-link"
title="Теги kindest/node на Docker Hub"
>Тег образа</a><span class="create-form-doc-link__ext" aria-hidden="true"></span>
</label>
<select id="version-select-edit" name="kubernetes_version_preset">
<option value="">— загрузить список —</option>
</select>
</div>
<div class="create-form-field">
<label for="fld-workers-edit">Worker-ноды (020)</label>
<input id="fld-workers-edit" name="workers" type="number" min="0" max="20" step="1" value="0" />
</div>
<div class="create-form-field create-form-span">
<label for="kubernetes_version_edit">Тег kindest/node (вручную)</label>
<input
id="kubernetes_version_edit"
name="kubernetes_version"
type="text"
placeholder="1.29.4 или v1.29.4"
autocomplete="off"
/>
</div>
</div>
<div class="cluster-edit-advanced-wrap hidden" id="edit-advanced-block" aria-hidden="true">
<div class="cluster-edit-advanced-intro">
<p class="cluster-edit-advanced-intro-text">
Поля соответствуют манифесту <code>kind: Cluster</code> (<code>apiVersion: kind.x-k8s.io/v1alpha4</code>).
Подробнее — в
<a href="https://kind.sigs.k8s.io/docs/user/configuration/" target="_blank" rel="noopener noreferrer">документации kind</a>.
</p>
</div>
<div class="cluster-edit-panels">
<!-- Панель: API и сеть кластера -->
<section class="cluster-edit-panel" aria-labelledby="adv-panel-net-heading">
<div class="cluster-edit-panel-head">
<h3 class="cluster-edit-panel-title" id="adv-panel-net-heading">Сеть кластера</h3>
<p class="cluster-edit-panel-desc muted">
Секция <code>networking:</code> — семейство IP, подсети, API server, CNI и режим kube-proxy.
</p>
</div>
<div class="cluster-edit-panel-body cluster-edit-panel-grid">
<div class="create-form-field">
<label for="fld-adv-api-version">apiVersion конфига</label>
<select id="fld-adv-api-version" name="adv_api_version">
<option value="kind.x-k8s.io/v1alpha4" selected>kind.x-k8s.io/v1alpha4</option>
</select>
</div>
<div class="create-form-field">
<label for="fld-adv-ip-family">ipFamily</label>
<select id="fld-adv-ip-family" name="adv_ip_family">
<option value="">по умолчанию (ipv4)</option>
<option value="ipv4">ipv4</option>
<option value="ipv6">ipv6</option>
<option value="dual">dual (dual-stack)</option>
</select>
</div>
<div class="create-form-field">
<label for="fld-adv-api-addr">apiServerAddress</label>
<input
id="fld-adv-api-addr"
type="text"
placeholder="127.0.0.1 — не задавать, если не нужно"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="create-form-field">
<label for="fld-adv-api-port">apiServerPort</label>
<input
id="fld-adv-api-port"
type="number"
min="1"
max="65535"
placeholder="пусто — случайный порт kind"
/>
</div>
<div class="create-form-field">
<label for="fld-adv-pod-subnet">podSubnet</label>
<input
id="fld-adv-pod-subnet"
type="text"
placeholder="напр. 10.244.0.0/16"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="create-form-field">
<label for="fld-adv-svc-subnet">serviceSubnet</label>
<input
id="fld-adv-svc-subnet"
type="text"
placeholder="напр. 10.96.0.0/12"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="create-form-field">
<label for="fld-adv-kube-proxy">kubeProxyMode</label>
<select id="fld-adv-kube-proxy" name="adv_kube_proxy">
<option value="">по умолчанию (iptables)</option>
<option value="iptables">iptables</option>
<option value="nftables">nftables (K8s 1.31+)</option>
<option value="ipvs">ipvs</option>
<option value="none">none (отключить kube-proxy)</option>
</select>
</div>
<div class="create-form-field cluster-edit-panel-span">
<label class="cluster-edit-check-label cluster-edit-check-label--card">
<input type="checkbox" id="fld-adv-disable-cni" />
<span class="cluster-edit-check-inline">
<span class="cluster-edit-check-title">Отключить встроенный CNI (kindnetd)</span>
<span class="cluster-edit-check-sep muted" aria-hidden="true"></span>
<span class="cluster-edit-check-hint muted">networking.disableDefaultCNI; свой CNI (Calico и т.д.)</span>
</span>
</label>
</div>
</div>
</section>
<!-- Панель: ноды -->
<section class="cluster-edit-panel" aria-labelledby="adv-panel-nodes-heading">
<div class="cluster-edit-panel-head">
<h3 class="cluster-edit-panel-title" id="adv-panel-nodes-heading">Ноды и образ</h3>
<p class="cluster-edit-panel-desc muted">
Список <code>nodes:</code> — роли control-plane / worker и образ <code>kindest/node</code> (версия Kubernetes).
</p>
</div>
<div class="cluster-edit-panel-body cluster-edit-panel-grid">
<div class="create-form-field">
<label for="version-select-adv">
<a
href="https://hub.docker.com/r/kindest/node/tags"
target="_blank"
rel="noopener noreferrer"
class="create-form-doc-link"
>Каталог тегов</a><span class="create-form-doc-link__ext" aria-hidden="true"></span>
</label>
<select id="version-select-adv" name="adv_kubernetes_version_preset">
<option value="">— загрузить список —</option>
</select>
</div>
<div class="create-form-field cluster-edit-panel-span">
<label for="fld-adv-image">Образ узла (<code>image:</code>)</label>
<input
id="fld-adv-image"
type="text"
placeholder="kindest/node:v1.29.4"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="create-form-field">
<label for="fld-adv-cp">Control-plane нод</label>
<input id="fld-adv-cp" type="number" min="1" max="5" step="1" value="1" />
<span class="field-hint muted">Несколько CP — сценарий HA; обычно 1 или 3</span>
</div>
<div class="create-form-field">
<label for="fld-adv-workers">Worker-нод</label>
<input id="fld-adv-workers" type="number" min="0" max="20" step="1" value="0" />
</div>
</div>
</section>
<!-- Панель: проброс портов -->
<section class="cluster-edit-panel" aria-labelledby="adv-panel-ports-heading">
<div class="cluster-edit-panel-head">
<h3 class="cluster-edit-panel-title" id="adv-panel-ports-heading">Проброс портов</h3>
<p class="cluster-edit-panel-desc muted">
<code>extraPortMappings</code> на <strong>первой</strong> control-plane (удобно для Docker Desktop / Ingress).
</p>
</div>
<div class="cluster-edit-panel-body">
<div class="create-form-field cluster-edit-panel-span">
<label class="cluster-edit-check-label cluster-edit-check-label--card">
<input type="checkbox" id="fld-adv-port-enable" />
<span class="cluster-edit-check-inline">
<span class="cluster-edit-check-title">Включить проброс порта</span>
<span class="cluster-edit-check-sep muted" aria-hidden="true"></span>
<span class="cluster-edit-check-hint muted">
См.
<a
href="https://kind.sigs.k8s.io/docs/user/configuration/#extra-port-mappings"
target="_blank"
rel="noopener noreferrer"
>документацию kind</a>
</span>
</span>
</label>
</div>
<div class="cluster-edit-port-card hidden" id="adv-port-fields-row">
<div class="cluster-edit-panel-grid cluster-edit-panel-grid--tight">
<div class="create-form-field">
<label for="fld-adv-host-port">hostPort</label>
<input id="fld-adv-host-port" type="number" min="1" max="65535" placeholder="8080" />
</div>
<div class="create-form-field">
<label for="fld-adv-container-port">containerPort</label>
<input id="fld-adv-container-port" type="number" min="1" max="65535" value="80" />
</div>
<div class="create-form-field">
<label for="fld-adv-listen-addr">listenAddress</label>
<input
id="fld-adv-listen-addr"
type="text"
placeholder="127.0.0.1 или 0.0.0.0"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="create-form-field">
<label for="fld-adv-port-proto">protocol</label>
<select id="fld-adv-port-proto">
<option value="TCP" selected>TCP</option>
<option value="UDP">UDP</option>
<option value="SCTP">SCTP</option>
</select>
</div>
</div>
</div>
</div>
</section>
<!-- Панель: kubeadm -->
<section class="cluster-edit-panel" aria-labelledby="adv-panel-kubeadm-heading">
<div class="cluster-edit-panel-head">
<h3 class="cluster-edit-panel-title" id="adv-panel-kubeadm-heading">Патчи kubeadm</h3>
<p class="cluster-edit-panel-desc muted">
<code>kubeadmConfigPatches</code> для <strong>первой</strong> control-plane (YAML после <code>|</code>).
</p>
</div>
<div class="cluster-edit-panel-body">
<div class="create-form-field">
<label for="fld-adv-kubeadm">Фрагмент YAML</label>
<textarea
id="fld-adv-kubeadm"
class="cluster-edit-kubeadm-textarea mono"
rows="8"
spellcheck="false"
placeholder="kind: InitConfiguration&#10;nodeRegistration:&#10; kubeletExtraArgs:&#10; system-reserved: memory=500Mi"
></textarea>
</div>
</div>
</section>
</div>
<div class="cluster-edit-yaml-preview-card">
<div class="cluster-edit-yaml-preview-head">
<span class="cluster-edit-yaml-preview-title">Предпросмотр kind-config.yaml</span>
<span class="muted cluster-edit-yaml-preview-note">итог сохранения</span>
</div>
<pre id="adv-yaml-preview" class="mono cluster-edit-yaml-preview-pre" aria-live="polite"></pre>
</div>
</div>
<div class="create-form-field">
<label for="fld-description">Описание (meta.json, необязательно)</label>
<input id="fld-description" name="description" type="text" maxlength="2000" autocomplete="off" placeholder="Заметка для панели" />
</div>
<div class="row create-actions cluster-edit-actions">
<button type="submit">Сохранить</button>
<a class="btn-secondary cluster-edit-cancel-link" href="/cluster/{{ cluster_name }}">Отмена</a>
</div>
<p id="edit-msg" class="msg" role="status" aria-live="polite"></p>
</form>
</section>
{# После успешного PUT /config — как применяются изменения (пересоздание при «Старт» и т.д.). #}
<div
id="cluster-edit-save-modal-overlay"
class="modal-overlay cluster-edit-save-modal-overlay hidden"
role="dialog"
aria-modal="true"
aria-labelledby="cluster-edit-save-modal-title"
aria-hidden="true"
>
<div class="modal-box modal-box--cluster-edit-save" role="document">
<h3 id="cluster-edit-save-modal-title" class="cluster-edit-save-modal-title">Сохранено</h3>
<div id="cluster-edit-save-modal-body" class="cluster-edit-save-modal-body muted"></div>
<div class="confirm-modal-actions">
<button type="button" id="cluster-edit-save-modal-ok">Понятно</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/cluster_edit.js" defer></script>
{% endblock %}
+6 -6
View File
@@ -64,12 +64,12 @@
<table id="tbl-clusters" aria-label="Список кластеров"> <table id="tbl-clusters" aria-label="Список кластеров">
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th class="col-cluster-name">Имя</th>
<th>kind</th> <th class="col-kind">kind</th>
<th>kubeconfig</th> <th class="col-kubeconfig">kubeconfig</th>
<th>Версия</th> <th class="col-version">Версия</th>
<th>Workers</th> <th class="col-workers">Workers</th>
<th>Состояние</th> <th class="col-state">Состояние</th>
<th class="col-actions">Действия</th> <th class="col-actions">Действия</th>
</tr> </tr>
</thead> </thead>
+19 -18
View File
@@ -11,22 +11,6 @@
<div class="modal-head modal-head--cluster"> <div class="modal-head modal-head--cluster">
<h3 id="modal-title" class="modal-title-text modal-title-text--center">Состояние кластера</h3> <h3 id="modal-title" class="modal-title-text modal-title-text--center">Состояние кластера</h3>
<div class="modal-toolbar" role="toolbar" aria-label="Действия в окне"> <div class="modal-toolbar" role="toolbar" aria-label="Действия в окне">
<div id="modal-dl-wrap" class="modal-dl-wrap hidden">
<span
class="icon-tooltip-host"
data-tooltip="Скачать kubeconfig для использования с kubectl на хосте"
>
<button
type="button"
class="icon-btn"
id="modal-btn-kubeconfig"
aria-label="Скачать kubeconfig для kubectl на хосте"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</span>
<span class="muted modal-dl-hint">kubeconfig</span>
</div>
<button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button> <button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button>
</div> </div>
</div> </div>
@@ -36,9 +20,9 @@
</div> </div>
<p id="modal-sub" class="muted modal-sub"></p> <p id="modal-sub" class="muted modal-sub"></p>
<h4 class="modal-section-title muted">Узлы</h4> <h4 class="modal-section-title muted">Узлы</h4>
<pre id="modal-nodes" class="mono" role="region" aria-label="Вывод kubectl get nodes"></pre> <pre id="modal-nodes" class="mono" role="region" aria-label="Список узлов"></pre>
<h4 class="modal-section-title muted">Поды (все namespace)</h4> <h4 class="modal-section-title muted">Поды (все namespace)</h4>
<pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre> <pre id="modal-pods" class="mono" role="region" aria-label="Список подов"></pre>
</div> </div>
</div> </div>
@@ -87,3 +71,20 @@
</div> </div>
</div> </div>
</div> </div>
{# Полноэкранный спиннер на время фонового старта/стопа узлов (главная и страница кластера). #}
<div
id="cluster-lifecycle-job-overlay"
class="page-loading-overlay cluster-lifecycle-job-overlay hidden"
role="status"
aria-live="polite"
aria-busy="false"
aria-hidden="true"
aria-label="Операция с кластером"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span id="cluster-lifecycle-job-label" class="page-loading-label"></span>
</div>
</div>
+1
View File
@@ -7,3 +7,4 @@ fastapi>=0.115.0
uvicorn[standard]>=0.32.0 uvicorn[standard]>=0.32.0
jinja2>=3.1.4 jinja2>=3.1.4
pydantic-settings>=2.6.0 pydantic-settings>=2.6.0
pyyaml>=6.0.1