Веб-UI кластера: страница деталей, kubectl по карточкам, мета 3 колонки

- Страница /cluster/<имя>: сводка, ресурсы узлов полосами, отдельные карточки на каждый kubectl get … -o json, рестарт пода.
- API: overview с блоками k8s_*, POST pods/restart, расширенный набор ресурсов (-A).
- Панель: спиннер загрузки, правки дашборда и стилей; документация api_routes, compose и прочие сопутствующие изменения.
This commit is contained in:
Sergey Antropoff
2026-04-04 09:13:08 +03:00
parent 4546f50aef
commit af0d1705cc
17 changed files with 2605 additions and 208 deletions
+159 -9
View File
@@ -17,13 +17,19 @@ import re
import shutil
import subprocess
from collections.abc import Callable
from typing import Any
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from kind_k8s_paths import clusters_dir, data_root
from kindest_node_tags import normalize_tag_v_prefix
from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_after_create
from kubeconfig_patch import (
kubeconfig_host_file,
kubeconfig_path_for_container_kubectl,
patch_kubeconfig_server_for_host,
should_patch_after_create,
)
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
@@ -398,19 +404,31 @@ def _wait_nodes_timeout_sec() -> int:
return 300
def wait_nodes_ready(*, kubeconfig_path: Path, timeout_sec: int | None = None) -> tuple[bool, str]:
def wait_nodes_ready(
*,
cluster_name: str,
kubeconfig_path: Path,
timeout_sec: int | None = None,
) -> tuple[bool, str]:
"""
Дождаться condition=Ready для всех нод через kubectl wait.
В контейнере веб-приложения путь к kubeconfig проходит через
``kubeconfig_path_for_container_kubectl`` (шлюз хоста + проброшенный порт или имя узла kind).
Возвращает (успех, сообщение для лога/UI).
"""
if timeout_sec is None:
timeout_sec = _wait_nodes_timeout_sec()
cfg = kubeconfig_path_for_container_kubectl(
cluster_name=cluster_name,
kube_source_path=kubeconfig_path,
)
t = f"{timeout_sec}s"
cmd = [
"kubectl",
"--kubeconfig",
str(kubeconfig_path),
str(cfg),
"wait",
"--for=condition=Ready",
"nodes",
@@ -561,10 +579,14 @@ def create_cluster_non_interactive(
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Операция отменена")
host_kube_path = kubeconfig_host_file(kube_path.parent)
patched = False
if should_patch_after_create():
_progress("Настройка доступа с вашего компьютера", 72)
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
shutil.copyfile(kube_path, host_kube_path, follow_symlinks=True)
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=host_kube_path)
if not patched:
host_kube_path.unlink(missing_ok=True)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
@@ -574,7 +596,7 @@ def create_cluster_non_interactive(
nodes_msg: str | None = None
if _wait_nodes_enabled():
_progress("Ожидание готовности узлов", 82)
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
ok, msg = wait_nodes_ready(cluster_name=name, kubeconfig_path=kube_path)
nodes_ready = ok
nodes_msg = msg
if ok:
@@ -606,6 +628,8 @@ def create_cluster_non_interactive(
"nodes_ready_message": nodes_msg,
"provisioned_from_existing_config": use_existing_config,
}
if patched:
meta["kubeconfig_host_path"] = str(host_kube_path.relative_to(root))
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
_progress("Завершение", 95)
@@ -872,13 +896,15 @@ def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]
return False, err[:800], cli
def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
def kubectl_nodes_wide(*, cluster_name: str, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kubeconfig),
str(cfg),
"get",
"nodes",
"-o",
@@ -894,13 +920,15 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
return p.returncode, msg
def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
def kubectl_pods_all_namespaces(*, cluster_name: str, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get pods -A``; сводка подов по кластеру."""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kubeconfig),
str(cfg),
"get",
"pods",
"-A",
@@ -915,6 +943,128 @@ def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
return p.returncode, msg
def kubectl_get_all_namespaces_wide(
*,
cluster_name: str,
kubeconfig: str | Path,
resource: str,
timeout: str = "30s",
) -> tuple[int, str]:
"""
``kubectl get <resource> -A -o wide`` — Deployments, StatefulSets, DaemonSets, Service, Ingress и т.д.
``resource`` — аргумент kubectl (``deployments``, ``pods``, ``svc`` …).
"""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(cfg),
"get",
resource,
"-A",
"-o",
"wide",
f"--request-timeout={timeout}",
],
capture_output=True,
text=True,
)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
msg = out if out else err
return p.returncode, msg
def _parse_kubectl_list_json(stdout: str) -> tuple[list[dict[str, Any]] | None, str]:
"""Разбор вывода ``kubectl get … -o json`` (Kind List)."""
if not (stdout or "").strip():
return [], ""
try:
data = json.loads(stdout)
except json.JSONDecodeError as e:
return None, f"JSON: {e}"
if not isinstance(data, dict):
return None, "корень ответа не объект"
raw_items = data.get("items")
if raw_items is None:
return [], ""
if not isinstance(raw_items, list):
return None, "поле items не список"
out: list[dict[str, Any]] = [x for x in raw_items if isinstance(x, dict)]
return out, ""
def kubectl_get_json(
*,
cluster_name: str,
kubeconfig: str | Path,
resource_args: list[str],
timeout: str = "30s",
) -> tuple[int, list[dict[str, Any]] | None, str]:
"""
``kubectl get <resource_args…> -o json``.
``resource_args`` — например ``["nodes"]`` или ``["pods", "-A"]``, ``["deployments", "-A"]``.
Возвращает (код, список items или None, сообщение об ошибке).
"""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
cmd = [
"kubectl",
"--kubeconfig",
str(cfg),
"get",
*resource_args,
"-o",
"json",
f"--request-timeout={timeout}",
]
p = subprocess.run(cmd, capture_output=True, text=True)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
if p.returncode != 0:
return p.returncode, None, err or out or f"код {p.returncode}"
items, perr = _parse_kubectl_list_json(out)
if items is None:
return p.returncode, None, perr
return 0, items, ""
def kubectl_delete_pod(
*,
cluster_name: str,
kubeconfig: str | Path,
namespace: str,
pod_name: str,
grace_period: int = 30,
) -> tuple[int, str]:
"""
Удаление пода (контроллер при необходимости создаст новый) — «мягкий рестарт».
``grace_period`` — секунды до SIGKILL (0 — сразу; для системных подов осторожно).
"""
src = Path(kubeconfig)
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
cmd = [
"kubectl",
"--kubeconfig",
str(cfg),
"delete",
"pod",
pod_name,
"-n",
namespace,
f"--grace-period={grace_period}",
"--request-timeout=60s",
]
p = subprocess.run(cmd, capture_output=True, text=True)
msg = ((p.stdout or "").strip() or (p.stderr or "").strip() or f"код {p.returncode}")[:8000]
return p.returncode, msg
def cluster_summary_for_api(name: str) -> dict[str, object]:
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
from core.provision_log import provision_log_file_path
+41 -2
View File
@@ -221,8 +221,8 @@ def aggregate_kind_cluster_resources(blocks: list[KindClusterResources]) -> Aggr
cpu_l = f"ср. {av_cpu:.1f}%" if av_cpu is not None else ""
mem_l = f"ср. {av_mem_pct:.1f}%" if av_mem_pct is not None else ""
ratio_l = f"ср. {av_mem_ratio:.1f}%" if av_mem_ratio is not None else ""
net_l = f"Σ {_fmt_bytes_ru(sum_net)}" if sum_net > 0 else ""
disk_l = f"Σ {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else ""
net_l = f"всего {_fmt_bytes_ru(sum_net)}" if sum_net > 0 else ""
disk_l = f"всего {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else ""
logger.debug(
"aggregate_kind_cluster_resources: узлов=%s cpu_ring=%.1f net_ring=%.1f",
@@ -322,3 +322,42 @@ def collect_kind_clusters_resource_stats() -> tuple[list[KindClusterResources],
blocks.append(KindClusterResources(cluster_name=cluster_name, nodes=stats, note=None))
return blocks, None
def collect_single_cluster_resource_stats(cluster_name: str) -> tuple[KindClusterResources, str | None]:
"""
Метрики узлов **одного** кластера (без полного прохода по всем kind-кластерам).
Если кластера нет в ``kind get clusters`` — пустой список узлов и пояснение в ``note``.
"""
name = cluster_name.strip()
cli = _container_cli_bin()
if not shutil.which(cli):
return KindClusterResources(cluster_name=name, nodes=[], note=None), f"CLI «{cli}» не найден в PATH"
if name not in list_registered_kind_clusters():
return KindClusterResources(
cluster_name=name,
nodes=[],
note="Кластер не в списке kind — метрики контейнеров-узлов недоступны",
), None
all_lines, ps_err = _all_container_names_ps_a(cli)
if ps_err:
return KindClusterResources(cluster_name=name, nodes=[], note=None), ps_err
running = _list_running_container_names(cli)
prefix = f"{name}-"
node_names = _sort_kind_node_containers([n for n in all_lines if n.startswith(prefix)])
active = [n for n in node_names if n in running]
if not active:
return KindClusterResources(
cluster_name=name,
nodes=[],
note="узлы остановлены или контейнеры отсутствуют",
), None
stats = _stats_for_container_names(cli, active)
order = {n: i for i, n in enumerate(node_names)}
stats.sort(key=lambda s: order.get(s.container_name, 99))
return KindClusterResources(cluster_name=name, nodes=stats, note=None), None