Веб-UI кластера: страница деталей, kubectl по карточкам, мета 3 колонки
- Страница /cluster/<имя>: сводка, ресурсы узлов полосами, отдельные карточки на каждый kubectl get … -o json, рестарт пода. - API: overview с блоками k8s_*, POST pods/restart, расширенный набор ресурсов (-A). - Панель: спиннер загрузки, правки дашборда и стилей; документация api_routes, compose и прочие сопутствующие изменения.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user