Панель: редактирование конфига кластера, 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:
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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="Актуальная сводка для строки таблицы / панели")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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-ноды (0–20)</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 nodeRegistration: kubeletExtraArgs: 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user