Веб-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
+267 -9
View File
@@ -10,10 +10,16 @@ import asyncio
import json
import logging
import os
import re
import shutil
import tempfile
from functools import partial
from pathlib import Path
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import FileResponse, JSONResponse
from starlette.background import BackgroundTask
from core.cluster_lifecycle import (
KindClusterError,
@@ -21,6 +27,9 @@ from core.cluster_lifecycle import (
configured_container_cli,
create_cluster_non_interactive,
delete_kind_cluster_and_data,
kubectl_delete_pod,
kubectl_get_json,
kubectl_get_all_namespaces_wide,
kubectl_nodes_wide,
kubectl_pods_all_namespaces,
list_registered_kind_clusters,
@@ -32,6 +41,7 @@ from core.cluster_lifecycle import (
from core.container_resource_stats import (
aggregate_kind_cluster_resources,
collect_kind_clusters_resource_stats,
collect_single_cluster_resource_stats,
running_kind_clusters_mask,
)
from core.job_store import (
@@ -47,18 +57,51 @@ from core.job_store import (
from core.provision_log import provision_log_file_path, write_cluster_provision_log
from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir
from kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host
from models.schemas import (
AggregateResourcesSummary,
ClusterCreateAccepted,
ClusterCreateRequest,
ClusterOverviewResponse,
ClusterSummary,
ClusterWorkloadsResponse,
JobView,
K8sListJsonBlock,
PodRestartRequest,
PodRestartResponse,
StatsResponse,
)
logger = logging.getLogger("kind_k8s.api.clusters")
# Безопасные имена 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]$")
def _k8s_ns_pod_valid(s: str) -> bool:
if not s or len(s) > 253:
return False
return bool(_K8S_NAME_SAFE.match(s))
def _k8s_list_from_kubectl(
rc: int,
items: list[dict[str, Any]] | None,
err: str,
) -> K8sListJsonBlock:
"""Собрать блок JSON для UI из результата :func:`kubectl_get_json`."""
if items is not None and rc == 0:
return K8sListJsonBlock(ok=True, rc=0, items=items, message=None)
return K8sListJsonBlock(ok=False, rc=rc, items=[], message=err or "ошибка kubectl")
def _unlink_temp_kubeconfig(path: Path) -> None:
"""Удалить временный файл после отдачи ответа (скачивание kubeconfig)."""
try:
path.unlink(missing_ok=True)
except OSError as e:
logger.debug("Не удалось удалить временный kubeconfig %s: %s", path, e)
router = APIRouter(tags=["clusters"])
@@ -202,16 +245,56 @@ async def list_clusters() -> list[ClusterSummary]:
responses={404: {"description": "Файл не найден"}},
)
async def download_kubeconfig(name: str) -> FileResponse:
"""Файл ``clusters/<имя>/kubeconfig`` для использования с хоста (локальная dev-среда)."""
"""
Kubeconfig для **kubectl на машине пользователя**: копия ``clusters/<имя>/kubeconfig``
с ``server=https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`` (порт из ``docker port``).
Патч выполняется при каждом скачивании, чтобы учитывать переменные окружения и актуальный порт.
Если патч невозможен — отдаётся сохранённый ``kubeconfig.host`` или сырой ``kubeconfig``.
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
path = clusters_dir() / name / "kubeconfig"
if not path.is_file():
n = name.strip()
base = clusters_dir() / n
primary = base / "kubeconfig"
if not primary.is_file():
raise HTTPException(status_code=404, detail="kubeconfig не найден")
logger.info("Отдача kubeconfig для кластера %s", name)
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="kind-k8s-kube-")
os.close(fd)
tmp_path = Path(tmp_name)
try:
shutil.copyfile(primary, tmp_path, follow_symlinks=True)
except OSError as e:
tmp_path.unlink(missing_ok=True)
logger.warning("Копирование kubeconfig для скачивания: %s", e)
raise HTTPException(status_code=500, detail="Не удалось подготовить kubeconfig") from e
patched = await asyncio.to_thread(
partial(patch_kubeconfig_server_for_host, cluster_name=n, kube_path=tmp_path),
)
if patched:
logger.info("Отдача kubeconfig для кластера %s (сгенерировано с актуальным server)", n)
return FileResponse(
path=str(tmp_path),
filename=f"kubeconfig-{n}.yaml",
media_type="application/x-yaml",
background=BackgroundTask(_unlink_temp_kubeconfig, tmp_path),
)
tmp_path.unlink(missing_ok=True)
host_path = kubeconfig_host_file(base)
if host_path.is_file():
logger.info("Отдача kubeconfig для кластера %s (файл %s, патч при скачивании не удался)", n, host_path.name)
return FileResponse(
path=host_path,
filename=f"kubeconfig-{n}.yaml",
media_type="application/x-yaml",
)
logger.info("Отдача kubeconfig для кластера %s (сырой kubeconfig)", n)
return FileResponse(
path=path,
filename=f"kubeconfig-{name}.yaml",
path=primary,
filename=f"kubeconfig-{n}.yaml",
media_type="application/x-yaml",
)
@@ -250,8 +333,16 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
if not kc.is_file():
return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/")
nodes_rc, nodes_out = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=kc)
pods_rc, pods_out = await asyncio.to_thread(kubectl_pods_all_namespaces, kubeconfig=kc)
nodes_rc, nodes_out = await asyncio.to_thread(
kubectl_nodes_wide,
cluster_name=name,
kubeconfig=kc,
)
pods_rc, pods_out = await asyncio.to_thread(
kubectl_pods_all_namespaces,
cluster_name=name,
kubeconfig=kc,
)
return ClusterWorkloadsResponse(
cluster_name=name,
nodes_rc=nodes_rc,
@@ -261,6 +352,169 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
)
@router.get(
"/clusters/{name}/overview",
response_model=ClusterOverviewResponse,
summary="Сводка кластера (страница UI)",
)
async def cluster_overview(name: str) -> ClusterOverviewResponse:
"""
Мета, метрики узлов (docker/podman), агрегаты как на главной, вывод kubectl по **всему кластеру**:
узлы; для namespace-ресурсов везде **-A** (все пространства имён); отдельно namespaces, RS, jobs и т.д.
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
n = name.strip()
summary = cluster_summary_for_api(n)
run_mask = await asyncio.to_thread(running_kind_clusters_mask, [n])
kind_running = bool(run_mask.get(n, False))
block, res_err = await asyncio.to_thread(collect_single_cluster_resource_stats, n)
agg = aggregate_kind_cluster_resources([block])
kc = clusters_dir() / n / "kubeconfig"
kube_err: str | None = None
k8s_nodes = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_pods = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_deployments = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_statefulsets = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_daemonsets = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_services = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_ingresses = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_namespaces = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_replicasets = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_jobs = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None)
k8s_pvcs = K8sListJsonBlock(ok=False, items=[], message=None)
if not kc.is_file():
kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/"
_kube_missing = K8sListJsonBlock(ok=False, items=[], message=kube_err)
k8s_nodes = _kube_missing
k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _kube_missing
k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing
k8s_jobs = k8s_cronjobs = k8s_pvcs = _kube_missing
else:
(
n_res,
p_res,
d_res,
st_res,
dm_res,
svc_res,
ing_res,
ns_res,
rs_res,
job_res,
cj_res,
pvc_res,
) = await asyncio.gather(
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["nodes"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pods", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["deployments", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["statefulsets", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["daemonsets", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["services", "-A"]),
asyncio.to_thread(
kubectl_get_json,
cluster_name=n,
kubeconfig=kc,
resource_args=["ingresses.networking.k8s.io", "-A"],
),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["namespaces"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["replicasets", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["jobs", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["cronjobs", "-A"]),
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pvc", "-A"]),
)
k8s_nodes = _k8s_list_from_kubectl(*n_res)
k8s_pods = _k8s_list_from_kubectl(*p_res)
k8s_deployments = _k8s_list_from_kubectl(*d_res)
k8s_statefulsets = _k8s_list_from_kubectl(*st_res)
k8s_daemonsets = _k8s_list_from_kubectl(*dm_res)
k8s_services = _k8s_list_from_kubectl(*svc_res)
k8s_ingresses = _k8s_list_from_kubectl(*ing_res)
k8s_namespaces = _k8s_list_from_kubectl(*ns_res)
k8s_replicasets = _k8s_list_from_kubectl(*rs_res)
k8s_jobs = _k8s_list_from_kubectl(*job_res)
k8s_cronjobs = _k8s_list_from_kubectl(*cj_res)
k8s_pvcs = _k8s_list_from_kubectl(*pvc_res)
meta = summary.get("meta")
return ClusterOverviewResponse(
cluster_name=n,
registered_in_kind=bool(summary["registered_in_kind"]),
kind_nodes_running=kind_running,
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
has_provision_log=bool(summary.get("has_provision_log")),
meta=dict(meta) if isinstance(meta, dict) else {},
resources_error=res_err,
cluster_resources=block,
aggregate_resources=agg,
kubeconfig_error=kube_err,
nodes_rc=None,
nodes_output=None,
pods_rc=None,
pods_output=None,
deployments_rc=None,
deployments_output=None,
statefulsets_rc=None,
statefulsets_output=None,
daemonsets_rc=None,
daemonsets_output=None,
services_rc=None,
services_output=None,
ingresses_rc=None,
ingresses_output=None,
k8s_nodes=k8s_nodes,
k8s_pods=k8s_pods,
k8s_deployments=k8s_deployments,
k8s_statefulsets=k8s_statefulsets,
k8s_daemonsets=k8s_daemonsets,
k8s_services=k8s_services,
k8s_ingresses=k8s_ingresses,
k8s_namespaces=k8s_namespaces,
k8s_replicasets=k8s_replicasets,
k8s_jobs=k8s_jobs,
k8s_cronjobs=k8s_cronjobs,
k8s_pvcs=k8s_pvcs,
)
@router.post(
"/clusters/{name}/pods/restart",
response_model=PodRestartResponse,
summary="Рестарт пода (kubectl delete pod)",
)
async def restart_cluster_pod(name: str, body: PodRestartRequest) -> PodRestartResponse:
"""
Удаление пода: Deployment/RS/Job при необходимости создают новый Pod (типичный «рестарт»).
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
n = name.strip()
ns = body.namespace.strip()
pod = body.pod.strip()
if not _k8s_ns_pod_valid(ns) or not _k8s_ns_pod_valid(pod):
raise HTTPException(status_code=400, detail="Некорректное имя namespace или пода")
kc = clusters_dir() / n / "kubeconfig"
if not kc.is_file():
raise HTTPException(status_code=404, detail="kubeconfig не найден")
rc, msg = await asyncio.to_thread(
kubectl_delete_pod,
cluster_name=n,
kubeconfig=kc,
namespace=ns,
pod_name=pod,
grace_period=30,
)
if rc != 0:
logger.warning("restart pod %s/%s в кластере %s: rc=%s %s", ns, pod, n, rc, msg[:500])
raise HTTPException(status_code=502, detail=msg[:4000] if msg else "kubectl delete pod завершился с ошибкой")
logger.info("Рестарт пода %s/%s в кластере %s", ns, pod, n)
return PodRestartResponse(cluster_name=n, namespace=ns, pod=pod, rc=rc, message=msg or "ok")
@router.get("/clusters/{name}", summary="Детали кластера")
async def get_cluster(name: str) -> dict[str, object]:
"""Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig)."""
@@ -269,7 +523,11 @@ async def get_cluster(name: str) -> dict[str, object]:
kubectl_rc: int | None = None
kubectl_msg: str | None = None
if saved.is_file():
rc, msg = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=saved)
rc, msg = await asyncio.to_thread(
kubectl_nodes_wide,
cluster_name=name.strip(),
kubeconfig=saved,
)
kubectl_rc = rc
kubectl_msg = msg
return {