diff --git a/Makefile b/Makefile index d040546..62cb812 100644 --- a/Makefile +++ b/Makefile @@ -86,7 +86,7 @@ check-docker: _require_runtime ## (с docker/podman) Проверить CLI и c @$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; } @echo "$(COMPOSE): OK" -# kubectl и kind в образе; kubeconfig в томе /work/clusters//kubeconfig — kubectl на хосте не нужен. +# kubectl в образе; путь к kubeconfig — как у веб-UI (подстановка server через host.docker.internal, см. kubeconfig_patch.py). kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"] @if [ -z "$(CLUSTER)" ]; then \ echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \ @@ -94,7 +94,8 @@ kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \ exit 1; \ fi - cd "$(KIND_K8S_DIR)" && $(COMPOSE) exec kind-k8s-web kubectl --kubeconfig=/work/clusters/$(CLUSTER)/kubeconfig $(KUBECTL_ARGS) + cd "$(KIND_K8S_DIR)" && KC=$$($(COMPOSE) exec -T kind-k8s-web python3 scripts/effective_kubeconfig_path.py $(CLUSTER) | tr -d '\r') && \ + $(COMPOSE) exec kind-k8s-web kubectl --kubeconfig="$$KC" $(KUBECTL_ARGS) build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) diff --git a/README.md b/README.md index 08b38f9..64f5415 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ **На хост не нужны:** **kind**, **kubectl**, Python приложения — всё это в образе `kind-k8s-tools:local` и выполняется в контейнере **`kind-k8s-web`**. Проверка API и узлов: **веб-интерфейс** (кластер → узлы/поды) или **`make docker kubectl CLUSTER=<имя>`** / **`make podman kubectl …`** (см. ниже) — `kubectl` вызывается через **`docker compose exec`** / **`podman compose exec`** внутри уже запущенного сервиса. -Файл **`clusters/<имя>/kubeconfig`** на хосте можно использовать **опционально**, если у вас локально установлен kubectl (например IDE или отладка) — после патча apiserver обычно указывает на `127.0.0.1:<порт>`. +Файл **`clusters/<имя>/kubeconfig.host`** (или скачивание из веб-UI) для kubectl на хосте: **`https://:<порт>`** — см. `app/kubeconfig_patch.py`. Переменная позволяет задать IP/имя хоста, если `localhost` недоступен с вашей машины. Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. Файл **`./README.md`** монтируется в **`/opt/kind-k8s/README.md`** (страница **«Документация»** и **`GET /api/v1/docs/readme`** без пересборки образа). -После создания кластера kubeconfig по умолчанию **патчится** на `https://127.0.0.1:<порт>` для доступа с хоста (`KIND_K8S_PATCH_KUBECONFIG`, см. `app/kubeconfig_patch.py`). +После создания кластера при **`KIND_K8S_PATCH_KUBECONFIG`** дополнительно пишется **`kubeconfig.host`**; скачивание через API каждый раз пересобирает файл с актуальным портом и хостом из **`KIND_K8S_KUBECONFIG_CLIENT_HOST`**. ## Быстрый старт diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 2501e2d..890d5e2 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -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://:<порт>`` (порт из ``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 { diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index ba74449..c335b89 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -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 -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 -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 diff --git a/app/core/container_resource_stats.py b/app/core/container_resource_stats.py index b3d7b5a..d86a11f 100644 --- a/app/core/container_resource_stats.py +++ b/app/core/container_resource_stats.py @@ -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 diff --git a/app/create_cluster.py b/app/create_cluster.py index 6992ae2..48f118f 100644 --- a/app/create_cluster.py +++ b/app/create_cluster.py @@ -198,16 +198,31 @@ def _run_interactive() -> None: print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}") if _in_container(): print(f" Том на хосте: clusters/{result.cluster_name}/ в корне репозитория (рядом с Makefile)") - print( - f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes", - ) + if result.kubeconfig_patched_for_host: + print( + f" Kubectl **с хоста**: clusters/{result.cluster_name}/kubeconfig.host " + f"(или скачивание из веб-UI) — server=:<порт>.", + ) + print( + " Файл kubeconfig (без .host) — как выдал kind; статистика в UI и kubectl внутри приложения " + "используют apiserver через шлюз хоста (host.docker.internal:<порт> из docker port; см. compose extra_hosts).", + ) + else: + print( + f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes", + ) else: print(" Проверка без kubectl на хосте: веб-интерфейс (кластер → узлы/поды) или из корня репозитория:") print(f" make docker kubectl CLUSTER={result.cluster_name} # или: make podman kubectl …") print(" (сервис kind-k8s-web должен быть запущен: make docker up).") - print(f" Локально при установленном kubectl: kubectl --kubeconfig={result.kubeconfig_path} get nodes") - if result.kubeconfig_patched_for_host: - print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.") + kc_for_shell = ( + result.kubeconfig_path.parent / "kubeconfig.host" + if result.kubeconfig_patched_for_host + else result.kubeconfig_path + ) + print(f" Локально при установленном kubectl: kubectl --kubeconfig={kc_for_shell} get nodes") + if result.kubeconfig_patched_for_host: + print(" (файл kubeconfig.host — apiserver: localhost или KIND_K8S_KUBECONFIG_CLIENT_HOST и порт Docker.)") if result.nodes_ready is False and result.nodes_ready_message: print(f" Предупреждение (ожидание нод): {result.nodes_ready_message}", file=sys.stderr) diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 8ebf58e..5e4be17 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -19,14 +19,15 @@ | Маршрут | Описание | |---------|----------| -| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** в реальном времени, **«Отменить»** прерывает текущую команду), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), таблица **последних заданий** с кнопкой **«Очистить завершённые»** (**DELETE /api/v1/jobs**), модалка узлов/подов; шапка — пилюли, 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 /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 /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | -Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/documentation.html` (README). +Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/documentation.html` (README). -**kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API (**`GET /api/v1/clusters/{name}/workloads`**) и веб-UI. Для интерактивной консоли из корня репозитория при запущенном compose: **`make docker kubectl CLUSTER=<имя>`** (или **`make podman kubectl …`**), внутри контейнера kubeconfig — **`/work/clusters/<имя>/kubeconfig`**. Перезапуск только веб-сервиса без `down`/`up`: **`make docker restart`** / **`make podman 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**. --- @@ -47,6 +48,8 @@ | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | | GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | +| GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) | +| POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта | | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | | GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) | | GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) | @@ -218,9 +221,9 @@ Accept: text/markdown "memory_used_ratio_ring": 5.8, "memory_used_ratio_label": "ср. 5.8%", "network_ring": 12.5, - "network_label": "Σ 1.71 MiB", + "network_label": "всего 1.71 MiB", "disk_ring": 45.0, - "disk_label": "Σ 2.5 GiB" + "disk_label": "всего 2.5 GiB" } } ``` @@ -327,9 +330,11 @@ Accept: text/markdown ## GET /api/v1/clusters/{name}/kubeconfig -Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`). +Скачать kubeconfig для **kubectl на машине пользователя** (ответ — тело файла, `Content-Disposition`: `kubeconfig-{name}.yaml`). -**Ошибка 404:** файла нет в `clusters/{name}/`. +При каждом запросе копируется **`clusters/{name}/kubeconfig`** и патчится `server=https://:<порт>` (порт из `docker port … 6443/tcp`). Если патч не удалился — отдаётся сохранённый **`kubeconfig.host`** или сырой **`kubeconfig`**. + +**Ошибка 404:** нет ни `kubeconfig.host`, ни `kubeconfig` в `clusters/{name}/`. **Ошибка 400:** некорректное имя кластера. @@ -391,6 +396,115 @@ Accept: text/markdown --- +## GET /api/v1/clusters/{name}/overview + +Единый ответ для **страницы кластера** (`GET /cluster/{name}`): флаги и `meta` как в списке кластеров, **метрики узлов** (docker/podman), **`aggregate_resources`** (как в `GET /stats` для донатов), данные Kubernetes в виде **`kubectl get … -o json`** (разбор `items` на фронтенде в таблицы). + +- При отсутствии kubeconfig: **`kubeconfig_error`** — строка-пояснение; блоки **`k8s_*`** недоступны (см. ниже). +- Поля **`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`** (все пространства имён). + - **`ok`**: `true` при успешном `kubectl`; + - **`rc`**: код возврата; + - **`items`**: массив объектов из `kubectl` (как в API Kubernetes); + - **`message`**: текст ошибки при `ok: false`. +- **`resources_error`** — ошибка сбора метрик контейнерного CLI (как `cluster_resources_error` в `/stats`). +- **`cluster_resources`** — один блок `KindClusterResources` (узлы с полями `cpu_percent`, `memory_usage`, …). + +**Пример ответа 200 (фрагмент):** + +```json +{ + "cluster_name": "dev", + "registered_in_kind": true, + "kind_nodes_running": true, + "has_local_kubeconfig": true, + "has_provision_log": true, + "meta": { "worker_nodes": 2, "kubernetes_version_tag": "v1.29.4" }, + "resources_error": null, + "cluster_resources": { + "cluster_name": "dev", + "nodes": [ + { + "container_name": "dev-control-plane", + "cpu_percent": "2.10%", + "memory_usage": "800MiB / 7.7GiB", + "memory_percent": "10.14%", + "net_io": "1.2MB / 800kB", + "block_io": "0B / 0B", + "pids": 120 + } + ], + "note": null + }, + "aggregate_resources": { + "nodes_count": 3, + "cpu_ring": 5.2, + "cpu_label": "5.2%", + "memory_percent_ring": 12.0, + "memory_percent_label": "12%", + "memory_used_ratio_ring": 45.0, + "memory_used_ratio_label": "2.1 / 4.6 GiB", + "network_ring": 8.0, + "network_label": "↓ 10 MB", + "disk_ring": 3.0, + "disk_label": "R/W 2 MB" + }, + "kubeconfig_error": null, + "k8s_nodes": { + "ok": true, + "rc": 0, + "items": [{ "metadata": { "name": "dev-control-plane" }, "status": { "conditions": [] } }], + "message": null + }, + "k8s_pods": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_deployments": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_statefulsets": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_daemonsets": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_services": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_ingresses": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_namespaces": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_replicasets": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_jobs": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_cronjobs": { "ok": true, "rc": 0, "items": [], "message": null }, + "k8s_pvcs": { "ok": true, "rc": 0, "items": [], "message": null } +} +``` + +**Ошибка 400:** некорректное имя кластера. + +--- + +## POST /api/v1/clusters/{name}/pods/restart + +Мягкий **рестарт пода**: выполняется **`kubectl delete pod`** в указанном namespace (под пересоздаётся, если им управляет Deployment/ReplicaSet и т.д.). + +**Тело запроса (JSON):** + +```json +{ + "namespace": "default", + "pod": "my-app-7d4f8b9-xk2cp" +} +``` + +**Пример ответа 200:** + +```json +{ + "ok": true, + "cluster_name": "dev", + "namespace": "default", + "pod": "my-app-7d4f8b9-xk2cp", + "message": null +} +``` + +**Ошибка 400:** неверное имя кластера, namespace или pod (валидация как в Kubernetes). + +**Ошибка 502:** `kubectl delete` завершился с ненулевым кодом (текст stderr в теле ответа от сервера). + +--- + ## GET /api/v1/clusters/{name} Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть). diff --git a/app/kubeconfig_patch.py b/app/kubeconfig_patch.py index 38fdc69..51fd177 100644 --- a/app/kubeconfig_patch.py +++ b/app/kubeconfig_patch.py @@ -1,8 +1,22 @@ """Правка kubeconfig kind для доступа к API с хоста (после create из контейнера). -Kind внутри Docker видит другой адрес apiserver; на хосте нужен 127.0.0.1:<порт> -из проброса `docker port -control-plane 6443/tcp` (Podman — тот же клиент -`docker` к docker-совместимому сокету). +Kind внутри Docker видит другой адрес apiserver; для **kubectl на машине пользователя** +в ``kubeconfig.host`` подставляется ``https://<клиентский_хост>:<порт>`` (порт из +``docker port -control-plane 6443/tcp``). Хост по умолчанию — **localhost** +(часто надёжнее ``127.0.0.1`` с TLS); переопределение: ``KIND_K8S_KUBECONFIG_CLIENT_HOST`` +(например IP хоста в LAN). + +**Два файла в ``clusters/<имя>/``:** + +- ``kubeconfig`` — как выдал ``kind get kubeconfig``; для ``kubectl`` **в процессе веб-приложения** + :func:`kubeconfig_path_for_container_kubectl` задаёт ``server=https://host.docker.internal:<порт>`` + и ``tls-server-name=localhost`` (иначе x509 не совпадает с SAN kind); см. ``KIND_K8S_KUBECONFIG_TLS_SERVER_NAME``. + Запасной URL: ``https://<имя>-control-plane:6443`` (без доп. SNI). +- ``kubeconfig.host`` — копия с ``server=`` для kubectl на хосте (см. + :func:`kubeconfig_download_client_hostname`). + +Если патч отключён или не удалился, ``kubeconfig.host`` может отсутствовать — тогда +отдаётся основной ``kubeconfig``. Автор: Сергей Антропов Сайт: https://devops.org.ru @@ -12,11 +26,17 @@ from __future__ import annotations import logging import os +import shutil import subprocess +import tempfile +import threading from pathlib import Path logger = logging.getLogger("kind_k8s.kubeconfig_patch") +# Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения). +KUBECONFIG_HOST_BASENAME = "kubeconfig.host" + def _container_cli() -> str: """CLI для `port` (обычно docker к сокету Docker или Podman).""" @@ -57,8 +77,19 @@ def _host_bind_for_kubeconfig(host: str) -> str: return host +def kubeconfig_download_client_hostname() -> str: + """ + Имя хоста для поля ``server`` в kubeconfig, скачиваемом пользователем. + + По умолчанию ``localhost`` (совпадает с SAN в сертификате kind чаще, чем проблемный 127.0.0.1). + Задаётся ``KIND_K8S_KUBECONFIG_CLIENT_HOST`` (например ``192.168.1.10`` для удалённого клиента). + """ + raw = (os.environ.get("KIND_K8S_KUBECONFIG_CLIENT_HOST") or "localhost").strip() + return raw or "localhost" + + def get_apiserver_host_port(cluster_name: str) -> tuple[str, str] | None: - """Узнать (host, port) с хоста для доступа к apiserver.""" + """Узнать (host, port) привязки проброса 6443/tcp (host из вывода docker port, порт — число).""" cli = _container_cli() ctr = _control_plane_container_name(cluster_name) p = subprocess.run( @@ -126,8 +157,9 @@ def patch_kubeconfig_server_for_host( "kubeconfig оставлен как выдал kind (с хоста может не открываться).", ) return False - host, port = hp - server = f"https://{host}:{port}" + _bind_host, port = hp + client_host = kubeconfig_download_client_hostname() + server = f"https://{client_host}:{port}" cluster_id = _kubeconfig_cluster_name(kube_path, cluster_name) p = subprocess.run( [ @@ -155,3 +187,155 @@ def should_patch_after_create() -> bool: if os.environ.get("KIND_K8S_PATCH_KUBECONFIG", "").strip().lower() in ("1", "true", "yes", "да"): return True return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" + + +def in_kind_k8s_container() -> bool: + """Процесс веб-приложения в Docker/Podman (``KIND_K8S_IN_CONTAINER=1``).""" + return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1" + + +def kubeconfig_host_file(cluster_data_dir: Path) -> Path: + """Путь к kubeconfig для скачивания на хост: ``clusters/<имя>/kubeconfig.host``.""" + return cluster_data_dir / KUBECONFIG_HOST_BASENAME + + +def apiserver_url_docker_bridge(cluster_name: str) -> str: + """ + URL apiserver по имени узла kind в общей Docker-сети (если она есть). + + Стандартное имя контрольной плоскости: ``-control-plane``. + """ + return f"https://{_control_plane_container_name(cluster_name)}:6443" + + +def _web_container_apiserver_endpoint(cluster_name: str) -> tuple[str, str | None]: + """ + Пара ``(server URL, tls-server-name)`` для kubeconfig процесса в контейнере веб-UI. + + Через шлюз (``host.docker.internal:<порт>``) в сертификате kind нет этого имени; + задаём ``tls-server-name`` (по умолчанию ``localhost``), которое есть в SAN. + Без проброса порта — прямой ``https://<имя>-control-plane:6443``, ``tls-server-name`` не нужен. + """ + hp = get_apiserver_host_port(cluster_name) + if hp: + _bind_host, port = hp + gw = (os.environ.get("KIND_K8S_APISERVER_GATEWAY_HOST") or "host.docker.internal").strip() + if not gw: + gw = "host.docker.internal" + server = f"https://{gw}:{port}" + tls_name = (os.environ.get("KIND_K8S_KUBECONFIG_TLS_SERVER_NAME") or "localhost").strip() or "localhost" + return server, tls_name + return apiserver_url_docker_bridge(cluster_name), None + + +def apiserver_url_for_web_container_process(cluster_name: str) -> str: + """Только URL apiserver для ``kubectl`` в контейнере веб-приложения (см. :func:`_web_container_apiserver_endpoint`).""" + url, _tls = _web_container_apiserver_endpoint(cluster_name) + return url + + +# Кэш временных kubeconfig для kubectl из контейнера: ключ → (mtime_ns исходника, путь). +# Версия в ключе — сброс кэша при смене логики (например tls-server-name). +_RUNTIME_KUBECONFIG_CACHE_VER = 2 +_kubectl_runtime_cache: dict[str, tuple[int, Path]] = {} +_kubectl_runtime_lock = threading.Lock() + + +def kubeconfig_path_for_container_kubectl(*, cluster_name: str, kube_source_path: Path) -> Path: + """ + Путь к kubeconfig для вызова ``kubectl`` из процесса в контейнере. + + Если ``KIND_K8S_IN_CONTAINER=1``, копия во временный файл: ``server`` через шлюз хоста + и при необходимости ``tls-server-name`` (SAN kind: ``localhost``, …). + Иначе возвращает ``kube_source_path`` без изменений. + + Поддерживает старые установки с одним «хостовым» kubeconfig на диске. + """ + if not in_kind_k8s_container(): + return kube_source_path + + try: + src = kube_source_path.resolve() + mtime_ns = int(src.stat().st_mtime_ns) + except OSError as e: + logger.debug("kubeconfig runtime: нет исходника %s: %s", kube_source_path, e) + return kube_source_path + + key = f"{cluster_name}\x00{src}\x00{_RUNTIME_KUBECONFIG_CACHE_VER}" + with _kubectl_runtime_lock: + hit = _kubectl_runtime_cache.get(key) + if hit is not None and hit[0] == mtime_ns: + return hit[1] + + try: + fd, temp_name = tempfile.mkstemp(suffix=".kubeconfig", prefix="kind-k8s-kubectl-") + os.close(fd) + tmp_path = Path(temp_name) + shutil.copyfile(src, tmp_path, follow_symlinks=True) + except OSError as e: + logger.warning("kubeconfig runtime: не удалось скопировать %s: %s", src, e) + return kube_source_path + + cluster_id = _kubeconfig_cluster_name(tmp_path, cluster_name) + server, tls_server_name = _web_container_apiserver_endpoint(cluster_name) + p = subprocess.run( + [ + "kubectl", + "--kubeconfig", + str(tmp_path), + "config", + "set-cluster", + cluster_id, + f"--server={server}", + ], + capture_output=True, + text=True, + ) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + logger.warning( + "kubeconfig runtime: kubectl set-cluster не удался для %s: %s", + cluster_name, + err[:500], + ) + tmp_path.unlink(missing_ok=True) + return kube_source_path + + if tls_server_name: + p_tls = subprocess.run( + [ + "kubectl", + "--kubeconfig", + str(tmp_path), + "config", + "set-cluster", + cluster_id, + f"--tls-server-name={tls_server_name}", + ], + capture_output=True, + text=True, + ) + if p_tls.returncode != 0: + err_tls = (p_tls.stderr or p_tls.stdout or "").strip() + logger.warning( + "kubeconfig runtime: tls-server-name для %s не задан: %s", + cluster_name, + err_tls[:400], + ) + + prev = _kubectl_runtime_cache.pop(key, None) + if prev is not None: + try: + prev[1].unlink(missing_ok=True) + except OSError: + pass + + _kubectl_runtime_cache[key] = (mtime_ns, tmp_path) + logger.debug( + "kubeconfig runtime: кластер «%s» server=%s tls-sni=%s (cluster=%s)", + cluster_name, + server, + tls_server_name or "—", + cluster_id, + ) + return tmp_path diff --git a/app/main.py b/app/main.py index c743425..ce6fe17 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from api.v1.router import api_router +from core.cluster_lifecycle import validate_cluster_name from core.config import get_settings _BASE = Path(__file__).resolve().parent @@ -75,6 +76,28 @@ async def dashboard(request: Request) -> HTMLResponse: ) +@app.get("/cluster/{cluster_name}", response_class=HTMLResponse, summary="Страница кластера") +async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLResponse: + """Ресурсы узлов, сводка, kubectl (поды, Deployments и др.), действия как в таблице на главной.""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="

Шаблоны не найдены. Ожидается каталог app/templates/

", + status_code=500, + ) + n = cluster_name.strip() + if not validate_cluster_name(n): + raise HTTPException(status_code=404, detail="Некорректное имя кластера") + return templates.TemplateResponse( + request, + "cluster_detail.html", + { + "app_title": settings.app_title, + "nav_active": "panel", + "cluster_name": n, + }, + ) + + @app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания") async def cluster_create_page(request: Request) -> HTMLResponse: """Форма создания кластера, прогресс и список последних заданий.""" diff --git a/app/models/schemas.py b/app/models/schemas.py index d84c580..cf45978 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -140,3 +140,87 @@ class ClusterWorkloadsResponse(BaseModel): pods_rc: int | None = None pods_output: str | None = None error: str | None = None + + +class K8sListJsonBlock(BaseModel): + """Фрагмент ответа ``kubectl get … -o json`` (items) для таблицы в UI.""" + + ok: bool = True + rc: int | None = Field(default=None, description="Код выхода kubectl") + items: list[dict[str, Any]] = Field(default_factory=list) + message: str | None = Field(default=None, description="Ошибка kubectl или разбора JSON") + + +class PodRestartRequest(BaseModel): + """Тело POST: перезапуск пода через ``kubectl delete pod`` (воссоздание контроллером).""" + + namespace: str = Field(..., min_length=1, max_length=253, description="Namespace пода") + pod: str = Field(..., min_length=1, max_length=253, description="Имя пода (metadata.name)") + + +class PodRestartResponse(BaseModel): + """Результат запроса на удаление/рестарт пода.""" + + cluster_name: str + namespace: str + pod: str + rc: int + message: str + + +class ClusterOverviewResponse(BaseModel): + """Сводка для страницы кластера: мета, ресурсы узлов, kubectl (поды, workloads).""" + + cluster_name: str + registered_in_kind: bool = False + kind_nodes_running: bool = False + has_local_kubeconfig: bool = False + has_provision_log: bool = False + meta: dict[str, Any] = Field(default_factory=dict) + resources_error: str | None = Field(default=None, description="Ошибка сбора docker/podman stats") + cluster_resources: KindClusterResources = Field( + default_factory=lambda: KindClusterResources(cluster_name="", nodes=[], note=None), + ) + aggregate_resources: AggregateResourcesSummary = Field(default_factory=AggregateResourcesSummary) + kubeconfig_error: str | None = Field(default=None, description="Нет kubeconfig или ошибка kubectl") + nodes_rc: int | None = None + nodes_output: str | None = None + pods_rc: int | None = None + pods_output: str | None = None + deployments_rc: int | None = None + deployments_output: str | None = None + statefulsets_rc: int | None = None + statefulsets_output: str | None = None + daemonsets_rc: int | None = None + daemonsets_output: str | None = None + services_rc: int | None = None + services_output: str | None = None + ingresses_rc: int | None = None + ingresses_output: str | None = None + k8s_nodes: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get nodes -o json") + k8s_pods: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock, description="kubectl get pods -A -o json") + k8s_deployments: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) + k8s_statefulsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) + k8s_daemonsets: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) + k8s_services: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) + k8s_ingresses: K8sListJsonBlock = Field(default_factory=K8sListJsonBlock) + k8s_namespaces: K8sListJsonBlock = Field( + default_factory=K8sListJsonBlock, + description="kubectl get namespaces -o json", + ) + k8s_replicasets: K8sListJsonBlock = Field( + default_factory=K8sListJsonBlock, + description="kubectl get replicasets -A -o json", + ) + k8s_jobs: K8sListJsonBlock = Field( + default_factory=K8sListJsonBlock, + description="kubectl get jobs -A -o json", + ) + k8s_cronjobs: K8sListJsonBlock = Field( + default_factory=K8sListJsonBlock, + description="kubectl get cronjobs -A -o json", + ) + k8s_pvcs: K8sListJsonBlock = Field( + default_factory=K8sListJsonBlock, + description="kubectl get pvc -A -o json", + ) diff --git a/app/scripts/effective_kubeconfig_path.py b/app/scripts/effective_kubeconfig_path.py new file mode 100644 index 0000000..e53d061 --- /dev/null +++ b/app/scripts/effective_kubeconfig_path.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Печать пути kubeconfig с подстановкой apiserver для kubectl из контейнера веб-UI. + +Совпадает с логикой ``kubeconfig_path_for_container_kubectl`` (шлюз хоста + порт). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Запуск из каталога app/ (рабочий каталог контейнера). +from kind_k8s_paths import clusters_dir +from kubeconfig_patch import kubeconfig_path_for_container_kubectl + + +def main() -> None: + if len(sys.argv) != 2: + print("Использование: effective_kubeconfig_path.py <имя_кластера>", file=sys.stderr) + raise SystemExit(2) + name = sys.argv[1].strip() + p = clusters_dir() / name / "kubeconfig" + if not p.is_file(): + print(f"Нет файла {p}", file=sys.stderr) + raise SystemExit(1) + resolved = kubeconfig_path_for_container_kubectl(cluster_name=name, kube_source_path=p) + print(resolved, end="") + + +if __name__ == "__main__": + main() diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index e227686..76c7928 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -55,6 +55,35 @@ return document.body.getAttribute("data-dashboard-mode") === "create"; } + /** Страница одного кластера: ``/cluster/<имя>``. */ + function isClusterPage() { + return document.body.getAttribute("data-dashboard-mode") === "cluster"; + } + + /** Имя кластера с ``body[data-cluster-name]`` (страница кластера). */ + function getClusterPageName() { + const raw = document.body.getAttribute("data-cluster-name"); + return raw && raw.trim() ? raw.trim() : null; + } + + /** Скрыть полноэкранный спиннер первой загрузки страницы кластера. */ + function hideClusterPageLoadingOverlay() { + const el = document.getElementById("cluster-page-loading-overlay"); + if (!el) return; + el.classList.add("hidden"); + el.setAttribute("aria-busy", "false"); + document.body.classList.remove("cluster-page-loading"); + } + + /** Скрыть спиннер первой загрузки главной панели. */ + function hideHomePageLoadingOverlay() { + const el = document.getElementById("home-page-loading-overlay"); + if (!el) return; + el.classList.add("hidden"); + el.setAttribute("aria-busy", "false"); + document.body.classList.remove("dashboard-home-loading"); + } + function formatApiError(data, fallback) { if (!data) return fallback; if (typeof data.detail === "string") return data.detail; @@ -182,6 +211,8 @@ '', logFile: '', + restart: + '', }; /** @type {ReturnType | null} */ @@ -461,13 +492,13 @@ } /** - * SVG-донаты по полю aggregate_cluster_resources (главная страница). + * Отрисовка донатов в заданный корень (главная: ``#aggregate-donuts``, страница кластера: ``#cluster-page-aggregate-donuts``). + * @param {HTMLElement | null} root * @param {object | null | undefined} agg * @param {string | null | undefined} resourcesError - * @param {string | undefined} containerCliRaw имя CLI из /stats (docker/podman) для подписи доната RAM + * @param {string | undefined} containerCliRaw docker/podman для подписи доната RAM */ - function renderAggregateDonuts(agg, resourcesError, containerCliRaw) { - const root = document.getElementById("aggregate-donuts"); + function renderAggregateDonutsToRoot(root, agg, resourcesError, containerCliRaw) { if (!root) return; root.replaceChildren(); @@ -550,59 +581,836 @@ donutItem("RAM used/limit", agg.memory_used_ratio_ring, String(agg.memory_used_ratio_label || "—"), ""), ); grid.appendChild( - donutItem("Сеть Σ", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"), + donutItem("Сеть (всего)", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"), ); grid.appendChild( - donutItem("Диск Σ", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"), + donutItem("Диск (всего)", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"), ); root.appendChild(grid); } /** - * Раскрывающийся блок метрик узлов под строкой таблицы кластеров. - * @param {string} clusterName + * SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``). */ - function buildClusterResourcesDetails(clusterName) { - const cr = cachedClusterResourcesByName[clusterName]; - const details = document.createElement("details"); - details.className = "cluster-nodes-resources"; - const summ = document.createElement("summary"); - summ.textContent = "Ресурсы узлов (CPU, RAM, сеть, диск)"; - details.appendChild(summ); + function renderAggregateDonuts(agg, resourcesError, containerCliRaw) { + renderAggregateDonutsToRoot( + document.getElementById("aggregate-donuts"), + agg, + resourcesError, + containerCliRaw, + ); + } + + /** + * Доля из строки вида «12.3%» для мини-полосы (0–100). + * @param {unknown} value + * @returns {number | null} + */ + function parseLoosePercent(value) { + if (value == null || value === "") return null; + const m = String(value).match(/([\d.,]+)\s*%/); + if (!m) return null; + const x = parseFloat(m[1].replace(",", ".")); + if (Number.isNaN(x)) return null; + return Math.max(0, Math.min(100, x)); + } + + /** Множители суффиксов Docker/Podman (как в ``container_resource_stats.py``). */ + var DOCKER_SUFFIX_TO_BYTES = { + b: 1, + kb: 1e3, + kib: 1024, + mb: 1e6, + mib: Math.pow(1024, 2), + gb: 1e9, + gib: Math.pow(1024, 3), + tb: 1e12, + tib: Math.pow(1024, 4), + pb: 1e15, + pib: Math.pow(1024, 5), + }; + + /** + * Разбор одного значения вроде ``512MiB``, ``800kB`` в байты. + * @param {string} chunk + * @returns {number | null} + */ + function parseDockerSizeBytes(chunk) { + const t = String(chunk || "") + .trim() + .toLowerCase() + .replace(/\s/g, ""); + if (!t) return null; + const m = t.match(/^([\d.]+)([a-z]+)$/); + if (!m) return null; + const mult = DOCKER_SUFFIX_TO_BYTES[m[2]]; + if (mult == null) return null; + const v = parseFloat(m[1]); + if (Number.isNaN(v)) return null; + return v * mult; + } + + /** + * Сумма байт по NetIO/BlockIO (чтение + запись, обе части до ``/``). + * @param {string | null | undefined} raw + * @returns {number | null} + */ + function parseIoPairSumBytes(raw) { + if (raw == null || String(raw).indexOf("/") < 0) return null; + const parts = String(raw).split("/"); + const ba = parseDockerSizeBytes(parts[0]); + const bb = parseDockerSizeBytes(parts[1]); + if (ba == null && bb == null) return null; + return (ba || 0) + (bb || 0); + } + + /** + * Процент занятой памяти: левая часть MemUsage / правая (лимит контейнера). + * @param {string | null | undefined} memoryUsage + * @returns {number | null} + */ + function memoryUsedRatioPercent(memoryUsage) { + if (memoryUsage == null || String(memoryUsage).indexOf("/") < 0) return null; + const pair = String(memoryUsage).split("/"); + const used = parseDockerSizeBytes(pair[0]); + const limit = parseDockerSizeBytes(pair[1]); + if (used == null || limit == null || limit <= 0) return null; + return Math.min(100, (100 * used) / limit); + } + + /** + * Карточка одного узла: все метрики ``docker stats`` полосами, как CPU/RAM. + * Сеть и диск нормализуются к максимуму среди узлов этого кластера (сравнение нагрузки). + * @param {object} n KindNodeResourceStat + * @param {{ netPct: number, blkPct: number }} rel + */ + function buildClusterResourceNodeCard(n, rel) { + const card = document.createElement("article"); + card.className = "cluster-node-res-card"; + const head = document.createElement("div"); + head.className = "cluster-node-res-card__head"; + const code = document.createElement("code"); + code.textContent = n.container_name || "—"; + head.appendChild(code); + card.appendChild(head); + + const bars = document.createElement("div"); + bars.className = "cluster-node-res-card__bars"; + /** + * @param {string} label + * @param {number | null | undefined} pct 0–100 + * @param {string} [hint] краткое значение справа + * @param {string} [rowTitle] подсказка при наведении на строку + */ + function addBar(label, pct, hint, rowTitle) { + const row = document.createElement("div"); + row.className = "cluster-node-res-card__bar-row"; + if (rowTitle) row.title = rowTitle; + const lab = document.createElement("span"); + lab.className = "cluster-node-res-card__bar-label"; + lab.textContent = label; + const track = document.createElement("div"); + track.className = "cluster-node-res-card__bar-track"; + const fill = document.createElement("div"); + fill.className = "cluster-node-res-card__bar-fill"; + const p = pct != null && !Number.isNaN(pct) ? pct : 0; + fill.style.width = Math.min(100, Math.max(0, p)) + "%"; + track.appendChild(fill); + row.appendChild(lab); + row.appendChild(track); + const hi = document.createElement("span"); + hi.className = "cluster-node-res-card__bar-hint muted"; + hi.textContent = hint != null && hint !== "" ? hint : "—"; + row.appendChild(hi); + bars.appendChild(row); + } + + const cpuP = parseLoosePercent(n.cpu_percent); + addBar("CPU", cpuP, n.cpu_percent || "—", "Загрузка CPU (docker stats CPUPerc)"); + + const ramP = parseLoosePercent(n.memory_percent); + addBar("RAM %", ramP, n.memory_percent || "—", "Процент памяти от лимита контейнера (MemPerc)"); + + const memRatio = memoryUsedRatioPercent(n.memory_usage); + addBar( + "Память", + memRatio, + n.memory_usage || "—", + "Использовано / лимит контейнера (MemUsage): доля в процентах", + ); + + addBar( + "Сеть", + rel.netPct, + n.net_io || "—", + "Суммарный сетевой I/O (вход+выход); полоса относительно максимума среди узлов кластера", + ); + addBar( + "Диск", + rel.blkPct, + n.block_io || "—", + "Суммарный блочный I/O; полоса относительно максимума среди узлов кластера", + ); + + card.appendChild(bars); + return card; + } + + /** + * Карточки узлов в контейнер (страница кластера), без ``
``. + * @param {HTMLElement | null} parent + * @param {object | null | undefined} cr поле ``cluster_resources`` из overview + */ + function appendClusterNodeCardsRow(parent, cr) { + if (!parent) return; + parent.replaceChildren(); if (!cr || !(cr.nodes && cr.nodes.length)) { const p = document.createElement("p"); p.className = "muted cluster-nodes-resources__empty"; p.textContent = cr && cr.note ? cr.note : "Нет данных: узлы остановлены или метрики недоступны."; - details.appendChild(p); - return details; + parent.appendChild(p); + return; } - cr.nodes.forEach(function (n) { - const row = document.createElement("div"); - row.className = "stats-node-res cluster-row-node-res"; - const code = document.createElement("code"); - code.textContent = n.container_name; - row.appendChild(code); - const ul = document.createElement("ul"); - ul.className = "stats-node-res__metrics"; - function addMetric(label, val) { - if (val == null || val === "") return; - const li = document.createElement("li"); - const strong = document.createElement("strong"); - strong.textContent = label + ": "; - li.appendChild(strong); - li.appendChild(document.createTextNode(String(val))); - ul.appendChild(li); - } - addMetric("CPU", n.cpu_percent); - addMetric("Память", n.memory_usage); - addMetric("% RAM", n.memory_percent); - addMetric("Сеть", n.net_io); - addMetric("Диск", n.block_io); - addMetric("PID", n.pids != null ? String(n.pids) : null); - row.appendChild(ul); - details.appendChild(row); + const nodes = cr.nodes; + const netBytes = nodes.map(function (node) { + return parseIoPairSumBytes(node.net_io) || 0; }); - return details; + const blkBytes = nodes.map(function (node) { + return parseIoPairSumBytes(node.block_io) || 0; + }); + const maxNet = Math.max(1, Math.max.apply(null, netBytes)); + const maxBlk = Math.max(1, Math.max.apply(null, blkBytes)); + nodes.forEach(function (n, i) { + parent.appendChild( + buildClusterResourceNodeCard(n, { + netPct: (100 * netBytes[i]) / maxNet, + blkPct: (100 * blkBytes[i]) / maxBlk, + }), + ); + }); + } + + /** + * Карточка мета справа: kind, kubeconfig, состояние, версия образа, workers (сетка плиток). + * @param {HTMLElement | null} el + * @param {object} d ответ ``GET /clusters/{name}/overview`` + */ + function paintClusterDetailMeta(el, d) { + if (!el) return; + el.replaceChildren(); + const card = document.createElement("div"); + card.className = "cluster-detail-meta-card"; + const grid = document.createElement("div"); + grid.className = "cluster-detail-meta-grid"; + + /** + * Одна плитка: подпись сверху, значение снизу. + * @param {string} label + * @param {HTMLElement} valueEl + * @param {boolean} [fullRow] на всю ширину сетки (последняя плитка при нечётном числе) + */ + function addTile(label, valueEl, fullRow) { + const tile = document.createElement("div"); + tile.className = "cluster-meta-tile" + (fullRow ? " cluster-meta-tile--wide" : ""); + const lab = document.createElement("span"); + lab.className = "cluster-meta-tile__label muted"; + lab.textContent = label; + const val = document.createElement("div"); + val.className = "cluster-meta-tile__value"; + val.appendChild(valueEl); + tile.appendChild(lab); + tile.appendChild(val); + grid.appendChild(tile); + } + + function badgeYesNo(yes) { + const s = document.createElement("span"); + s.className = yes ? "badge badge-ok cluster-meta-badge" : "badge cluster-meta-badge"; + s.textContent = yes ? "да" : "нет"; + return s; + } + + addTile("kind", badgeYesNo(!!d.registered_in_kind)); + addTile("kubeconfig", badgeYesNo(!!d.has_local_kubeconfig)); + + const stateBadge = document.createElement("span"); + stateBadge.className = "badge cluster-meta-badge"; + if (d.registered_in_kind) { + if (d.kind_nodes_running) { + stateBadge.classList.add("badge-ok"); + stateBadge.textContent = "Запущен"; + stateBadge.title = "Контейнеры узлов запущены"; + } else { + stateBadge.classList.add("badge-stopped"); + stateBadge.textContent = "Остановлен"; + stateBadge.title = "Зарегистрирован в kind, узлы остановлены"; + } + } else { + stateBadge.classList.add("cluster-meta-badge--muted"); + stateBadge.textContent = "—"; + stateBadge.title = "Нет в kind — только локальный каталог"; + } + addTile("Состояние", stateBadge); + + const ver = + (d.meta && (d.meta.kubernetes_version_tag || d.meta.node_image)) || "—"; + const codeVer = document.createElement("code"); + codeVer.className = "cluster-meta-version-tag"; + codeVer.textContent = String(ver); + addTile("Версия образа", codeVer); + + const workersEl = document.createElement("span"); + workersEl.className = "cluster-meta-workers"; + workersEl.textContent = + d.meta && d.meta.worker_nodes != null ? String(d.meta.worker_nodes) : "—"; + addTile("Workers", workersEl, false); + + card.appendChild(grid); + el.appendChild(card); + } + + /** + * @param {HTMLElement} root + * @param {string} text + */ + function clusterK8sShowMessage(root, text) { + if (!root) return; + root.replaceChildren(); + const p = document.createElement("p"); + p.className = "cluster-k8s-msg muted"; + p.textContent = text; + root.appendChild(p); + } + + /** + * Ячейка: текст (через textContent) или готовый DOM-элемент (кнопка). + * @param {HTMLElement} root + * @param {string[]} headers + * @param {Array>} rows + */ + function clusterK8sBuildTable(root, headers, rows) { + if (!root) return; + root.replaceChildren(); + if (!rows.length) { + clusterK8sShowMessage(root, "Нет объектов."); + return; + } + const wrap = document.createElement("div"); + wrap.className = "cluster-k8s-table-scroll"; + const table = document.createElement("table"); + table.className = "cluster-k8s-table"; + const thead = document.createElement("thead"); + const trh = document.createElement("tr"); + headers.forEach(function (h) { + const th = document.createElement("th"); + th.textContent = h; + trh.appendChild(th); + }); + thead.appendChild(trh); + table.appendChild(thead); + const tbody = document.createElement("tbody"); + rows.forEach(function (cells) { + const tr = document.createElement("tr"); + cells.forEach(function (cell) { + const td = document.createElement("td"); + if (cell != null && typeof cell === "object" && cell.nodeType === 1) { + td.appendChild(cell); + } else { + td.textContent = cell == null ? "" : String(cell); + } + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + wrap.appendChild(table); + root.appendChild(wrap); + } + + /** + * @param {object | null | undefined} block поле k8s_* из overview + * @param {string | null} kubeErr + * @param {HTMLElement | null} root + * @param {function(HTMLElement, object[]): void} renderRows + */ + function clusterK8sRenderBlock(block, kubeErr, root, renderRows) { + if (!root) return; + if (kubeErr) { + clusterK8sShowMessage(root, kubeErr); + return; + } + if (!block || !block.ok) { + clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны."); + return; + } + const items = block.items || []; + renderRows(root, items); + } + + function k8sNodeReady(item) { + const cond = (item.status && item.status.conditions) || []; + for (var i = 0; i < cond.length; i++) { + if (cond[i].type === "Ready") return cond[i].status === "True" ? "True" : "False"; + } + return "—"; + } + + function k8sNodeRoles(item) { + const labels = (item.metadata && item.metadata.labels) || {}; + if (labels["node-role.kubernetes.io/control-plane"] || labels["node-role.kubernetes.io/master"]) + return "control-plane"; + return "worker"; + } + + function k8sNodeInternalIP(item) { + const addrs = (item.status && item.status.addresses) || []; + for (var i = 0; i < addrs.length; i++) { + if (addrs[i].type === "InternalIP") return addrs[i].address || "—"; + } + return "—"; + } + + function renderK8sNodesTable(root, items) { + const rows = items.map(function (it) { + const name = (it.metadata && it.metadata.name) || "—"; + const ver = (it.status && it.status.nodeInfo && it.status.nodeInfo.kubeletVersion) || "—"; + return [name, k8sNodeReady(it), k8sNodeRoles(it), String(ver), k8sNodeInternalIP(it)]; + }); + clusterK8sBuildTable(root, ["Имя", "Ready", "Роль", "Версия", "Internal IP"], rows); + } + + function k8sPodReadySummary(item) { + const cst = (item.status && item.status.containerStatuses) || []; + if (!cst.length) return "—"; + var ready = 0; + cst.forEach(function (c) { + if (c.ready) ready++; + }); + return String(ready) + "/" + String(cst.length); + } + + /** + * Рестарт пода: DELETE на API; контроллер пересоздаёт Pod. + * @param {string} clusterName + * @param {string} namespace + * @param {string} podName + */ + async function restartClusterPod(clusterName, namespace, podName) { + const ok = await openConfirmModal({ + title: "Перезапустить под?", + message: + "Namespace «" + + namespace + + "», под «" + + podName + + "»: под будет удалён; при наличии контроллера (Deployment и т.д.) будет создан новый экземпляр.", + confirmLabel: "Рестарт", + danger: false, + }); + if (!ok) return; + try { + await api("/clusters/" + encodeURIComponent(clusterName) + "/pods/restart", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ namespace: namespace, pod: podName }), + }); + showToast("Под отправлен на пересоздание", false); + await loadClusterOverview(clusterName); + } catch (e) { + showToast(e.message || "Ошибка рестарта", true); + } + } + + function renderK8sPodsTable(root, items, clusterName) { + const tip = + "Перезапуск пода: удаление; при наличии контроллера (Deployment и т.д.) будет создан новый Pod."; + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const ns = meta.namespace || "—"; + const name = meta.name || "—"; + const phase = (it.status && it.status.phase) || "—"; + const node = (it.spec && it.spec.nodeName) || "—"; + const host = document.createElement("span"); + host.className = "icon-tooltip-host"; + host.setAttribute("data-tooltip", tip); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "icon-btn icon-btn--secondary btn-k8s-restart--solo"; + btn.innerHTML = ICONS.restart; + btn.setAttribute("aria-label", tip); + btn.addEventListener("click", function () { + restartClusterPod(clusterName, ns, name); + }); + host.appendChild(btn); + return [ns, name, k8sPodReadySummary(it), String(phase), String(node), host]; + }); + clusterK8sBuildTable(root, ["Namespace", "Под", "Ready", "Фаза", "Узел", ""], rows); + bindActionTooltipHosts(root); + } + + function renderK8sNamespacesTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const st = it.status || {}; + return [meta.name || "—", String(st.phase || "—")]; + }); + clusterK8sBuildTable(root, ["Имя", "Фаза"], rows); + } + + function renderK8sReplicaSetsTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const st = it.status || {}; + const spec = it.spec || {}; + const want = spec.replicas != null ? spec.replicas : "—"; + const cur = st.replicas != null ? st.replicas : 0; + const ready = st.readyReplicas != null ? st.readyReplicas : 0; + return [ + meta.namespace || "—", + meta.name || "—", + String(ready) + " / " + String(cur) + " / " + String(want), + ]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready / Текущие / Задано"], rows); + } + + function renderK8sJobsTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const st = it.status || {}; + const spec = it.spec || {}; + const want = spec.completions != null ? spec.completions : "—"; + const succ = st.succeeded != null ? st.succeeded : 0; + const act = st.active != null ? st.active : 0; + const fail = st.failed != null ? st.failed : 0; + return [ + meta.namespace || "—", + meta.name || "—", + String(succ) + " / " + String(want), + "активн.:" + act + ", неудач:" + fail, + ]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Успешно / всего", "Активные / неудач"], rows); + } + + function renderK8sCronJobsTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const spec = it.spec || {}; + const susp = spec.suspend ? "да" : "нет"; + return [meta.namespace || "—", meta.name || "—", String(spec.schedule || "—"), susp]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Schedule", "Suspend"], rows); + } + + function renderK8sPvcsTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const st = it.status || {}; + const spec = it.spec || {}; + const req = spec.resources && spec.resources.requests && spec.resources.requests.storage; + const cap = st.capacity && st.capacity.storage; + const size = cap || req || "—"; + return [meta.namespace || "—", meta.name || "—", String(st.phase || "—"), String(size)]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Фаза", "Размер"], rows); + } + + function renderK8sDeploymentsTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const st = it.status || {}; + const spec = it.spec || {}; + const want = spec.replicas != null ? spec.replicas : "—"; + const ready = st.readyReplicas != null ? st.readyReplicas : 0; + return [ + meta.namespace || "—", + meta.name || "—", + String(ready) + " / " + String(want), + String(st.updatedReplicas != null ? st.updatedReplicas : "—"), + ]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready", "Up-to-date"], rows); + } + + function renderK8sStsDsTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const st = it.status || {}; + const spec = it.spec || {}; + const want = spec.replicas != null ? spec.replicas : "—"; + const ready = st.readyReplicas != null ? st.readyReplicas : 0; + return [meta.namespace || "—", meta.name || "—", String(ready) + " / " + String(want)]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready"], rows); + } + + function renderK8sServicesTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const spec = it.spec || {}; + const st = spec.type || "—"; + const ports = (spec.ports || []) + .map(function (p) { + return (p.port || "") + (p.protocol ? "/" + p.protocol : ""); + }) + .join(", "); + return [meta.namespace || "—", meta.name || "—", String(st), ports || "—"]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Тип", "Порты"], rows); + } + + function renderK8sIngressesTable(root, items) { + const rows = items.map(function (it) { + const meta = it.metadata || {}; + const rules = (it.spec && it.spec.rules) || []; + const hosts = rules + .map(function (r) { + return r.host || ""; + }) + .filter(Boolean) + .join(", "); + return [meta.namespace || "—", meta.name || "—", hosts || "—"]; + }); + clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows); + } + + /** + * Таблицы Kubernetes из полей ``k8s_*`` ответа overview. + * @param {object} d + */ + function fillClusterK8sTablesFromOverview(d) { + const kubeErr = d.kubeconfig_error || null; + const cname = d.cluster_name || getClusterPageName() || ""; + clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable); + clusterK8sRenderBlock( + d.k8s_namespaces, + kubeErr, + document.getElementById("cluster-k8s-namespaces-root"), + renderK8sNamespacesTable, + ); + clusterK8sRenderBlock(d.k8s_pods, kubeErr, document.getElementById("cluster-k8s-pods-root"), function (root, items) { + renderK8sPodsTable(root, items, cname); + }); + clusterK8sRenderBlock( + d.k8s_deployments, + kubeErr, + document.getElementById("cluster-k8s-deployments-root"), + renderK8sDeploymentsTable, + ); + clusterK8sRenderBlock( + d.k8s_statefulsets, + kubeErr, + document.getElementById("cluster-k8s-statefulsets-root"), + renderK8sStsDsTable, + ); + clusterK8sRenderBlock( + d.k8s_daemonsets, + kubeErr, + document.getElementById("cluster-k8s-daemonsets-root"), + renderK8sStsDsTable, + ); + clusterK8sRenderBlock( + d.k8s_replicasets, + kubeErr, + document.getElementById("cluster-k8s-replicasets-root"), + renderK8sReplicaSetsTable, + ); + clusterK8sRenderBlock(d.k8s_jobs, kubeErr, document.getElementById("cluster-k8s-jobs-root"), renderK8sJobsTable); + clusterK8sRenderBlock( + d.k8s_cronjobs, + kubeErr, + document.getElementById("cluster-k8s-cronjobs-root"), + renderK8sCronJobsTable, + ); + clusterK8sRenderBlock( + d.k8s_services, + kubeErr, + document.getElementById("cluster-k8s-services-root"), + renderK8sServicesTable, + ); + clusterK8sRenderBlock( + d.k8s_ingresses, + kubeErr, + document.getElementById("cluster-k8s-ingresses-root"), + renderK8sIngressesTable, + ); + clusterK8sRenderBlock(d.k8s_pvcs, kubeErr, document.getElementById("cluster-k8s-pvcs-root"), renderK8sPvcsTable); + } + + /** + * Кнопки действий над кластером — те же, что в таблице на главной. + * @param {HTMLElement} host + * @param {{ name: string, registered_in_kind: boolean, kind_nodes_running: boolean, has_local_kubeconfig: boolean, has_provision_log?: boolean }} c + */ + function appendClusterActionButtons(host, c) { + host.replaceChildren(); + const name = c.name; + host.appendChild( + iconActionButton( + ICONS.state, + "Состояние: узлы и поды (kubectl get nodes / pods)", + "", + function () { + openWorkloadsModal(name); + }, + false, + ), + ); + if (c.registered_in_kind && c.kind_nodes_running) { + host.appendChild( + iconActionButton( + ICONS.stop, + "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется", + "icon-btn--secondary", + function () { + stopCluster(name); + }, + false, + ), + ); + } else { + host.appendChild( + iconActionButton( + ICONS.play, + "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне", + "", + function () { + startCluster(name); + }, + false, + ), + ); + } + host.appendChild( + iconActionButton( + ICONS.download, + c.has_local_kubeconfig + ? "Скачать kubeconfig для kubectl на хосте" + : "Kubeconfig ещё нет — появится после создания или подъёма кластера", + "icon-btn--secondary", + c.has_local_kubeconfig + ? function () { + downloadKubeconfig(name); + } + : null, + !c.has_local_kubeconfig, + ), + ); + if (c.has_provision_log) { + host.appendChild( + iconActionButton( + ICONS.logFile, + "Журнал развёртывания: полный JSON последнего создания или старта кластера", + "icon-btn--secondary", + function () { + openProvisionLogModal(name); + }, + false, + ), + ); + } + host.appendChild( + iconActionButton( + ICONS.trash, + "Удалить кластер (kind delete) и каталог clusters/<имя>/", + "icon-btn--danger", + function () { + deleteCluster(name); + }, + false, + ), + ); + bindActionTooltipHosts(host); + } + + /** + * Загрузка ``GET /clusters/{name}/overview`` и заполнение блоков страницы кластера. + * @param {string} name + */ + async function loadClusterOverview(name) { + const msg = document.getElementById("cluster-overview-msg"); + const hint = document.getElementById("cluster-kube-global-hint"); + try { + const d = await api("/clusters/" + encodeURIComponent(name) + "/overview"); + if (msg) msg.textContent = ""; + if (d.cluster_resources && d.cluster_resources.cluster_name) { + cachedClusterResourcesByName[d.cluster_resources.cluster_name] = d.cluster_resources; + } + paintClusterDetailMeta(document.getElementById("cluster-detail-meta"), d); + appendClusterActionButtons(document.getElementById("cluster-detail-toolbar"), { + name: d.cluster_name, + registered_in_kind: !!d.registered_in_kind, + kind_nodes_running: !!d.kind_nodes_running, + has_local_kubeconfig: !!d.has_local_kubeconfig, + has_provision_log: !!d.has_provision_log, + }); + const cliRaw = + (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker"; + renderAggregateDonutsToRoot( + document.getElementById("cluster-page-aggregate-donuts"), + d.aggregate_resources, + d.resources_error, + cliRaw, + ); + appendClusterNodeCardsRow(document.getElementById("cluster-page-node-cards"), d.cluster_resources); + const kubeErr = d.kubeconfig_error || null; + if (hint) { + if (kubeErr) { + hint.textContent = kubeErr; + hint.classList.remove("hidden"); + } else { + hint.textContent = ""; + hint.classList.add("hidden"); + } + } + fillClusterK8sTablesFromOverview(d); + } catch (e) { + const errText = e && e.message ? e.message : String(e); + if (msg) { + msg.textContent = + "Кластер или API недоступны: " + + errText + + ". Проверьте сеть, имя кластера и что узлы kind запущены."; + } + if (hint) { + hint.textContent = ""; + hint.classList.add("hidden"); + } + [ + "cluster-k8s-nodes-root", + "cluster-k8s-namespaces-root", + "cluster-k8s-pods-root", + "cluster-k8s-deployments-root", + "cluster-k8s-statefulsets-root", + "cluster-k8s-daemonsets-root", + "cluster-k8s-replicasets-root", + "cluster-k8s-jobs-root", + "cluster-k8s-cronjobs-root", + "cluster-k8s-services-root", + "cluster-k8s-ingresses-root", + "cluster-k8s-pvcs-root", + ].forEach(function (id) { + const el = document.getElementById(id); + if (el) { + clusterK8sShowMessage( + el, + "Данные Kubernetes недоступны (ошибка загрузки сводки). " + errText, + ); + } + }); + } finally { + hideClusterPageLoadingOverlay(); + } + } + + /** После старт/стоп/создания — обновить страницу кластера, если открыта та же запись. */ + async function refreshClusterPageIfNeeded(affectedClusterName) { + if (!isClusterPage() || !affectedClusterName) return; + const n = getClusterPageName(); + if (n && n === affectedClusterName) { + await loadClusterOverview(n); + } } async function loadStats() { @@ -636,9 +1444,6 @@ grid.appendChild(tile); }); cachedClusterResourcesByName = {}; - (s.cluster_resources || []).forEach(function (cr) { - cachedClusterResourcesByName[cr.cluster_name] = cr; - }); renderAggregateDonuts( s.aggregate_cluster_resources, s.cluster_resources_error, @@ -830,9 +1635,11 @@ ""; } tr.innerHTML = - "" + + "" + nameEsc + - "" + + "" + "" + (c.registered_in_kind ? "да" : "нет") + "" + @@ -850,92 +1657,14 @@ "" + ""; const td = tr.querySelector(".actions-toolbar"); - td.appendChild( - iconActionButton( - ICONS.state, - "Состояние: узлы и поды (kubectl get nodes / pods)", - "", - function () { - openWorkloadsModal(c.name); - }, - false, - ), - ); - if (c.registered_in_kind && c.kind_nodes_running) { - td.appendChild( - iconActionButton( - ICONS.stop, - "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется", - "icon-btn--secondary", - function () { - stopCluster(c.name); - }, - false, - ), - ); - } else { - td.appendChild( - iconActionButton( - ICONS.play, - "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне", - "", - function () { - startCluster(c.name); - }, - false, - ), - ); - } - td.appendChild( - iconActionButton( - ICONS.download, - c.has_local_kubeconfig - ? "Скачать kubeconfig для kubectl на хосте" - : "Kubeconfig ещё нет — появится после создания или подъёма кластера", - "icon-btn--secondary", - c.has_local_kubeconfig - ? function () { - downloadKubeconfig(c.name); - } - : null, - !c.has_local_kubeconfig, - ), - ); - if (c.has_provision_log) { - td.appendChild( - iconActionButton( - ICONS.logFile, - "Журнал развёртывания: полный JSON последнего создания или старта кластера", - "icon-btn--secondary", - function () { - openProvisionLogModal(c.name); - }, - false, - ), - ); - } - td.appendChild( - iconActionButton( - ICONS.trash, - "Удалить кластер (kind delete) и каталог clusters/<имя>/", - "icon-btn--danger", - function () { - deleteCluster(c.name); - }, - false, - ), - ); + appendClusterActionButtons(td, { + name: c.name, + registered_in_kind: !!c.registered_in_kind, + kind_nodes_running: !!c.kind_nodes_running, + has_local_kubeconfig: !!c.has_local_kubeconfig, + has_provision_log: !!c.has_provision_log, + }); frag.appendChild(tr); - if (isHomePage()) { - const trExp = document.createElement("tr"); - trExp.className = "cluster-resources-expand-row"; - const tdExp = document.createElement("td"); - tdExp.colSpan = 7; - tdExp.className = "cluster-resources-expand-cell"; - tdExp.appendChild(buildClusterResourcesDetails(c.name)); - trExp.appendChild(tdExp); - frag.appendChild(trExp); - } }); tbody.replaceChildren(frag); bindActionTooltipHosts(document.getElementById("tbl-clusters")); @@ -1190,6 +1919,7 @@ await loadClusters(); await loadStats(); await loadHealth(); + await refreshClusterPageIfNeeded(name); } catch (e) { showToast(e.message, true); } @@ -1223,6 +1953,7 @@ await loadClusters(); await loadStats(); await loadHealth(); + await refreshClusterPageIfNeeded(name); } async function deleteCluster(name) { @@ -1244,6 +1975,10 @@ const res = await api("/clusters/" + encodeURIComponent(name), { method: "DELETE" }); if (msg) msg.textContent = res.summary || "Готово."; showToast("Кластер «" + name + "» удалён", false); + if (isClusterPage() && getClusterPageName() === name) { + window.location.href = "/"; + return; + } await loadClusters(); await loadStats(); await loadJobs(); @@ -1405,6 +2140,7 @@ await loadStats(); await loadJobs(); await loadHealth(); + await refreshClusterPageIfNeeded(j.cluster_name || ""); } } catch (e) { if (msg) msg.textContent = "Ошибка опроса задания: " + e.message; @@ -1424,6 +2160,10 @@ if (isCreatePage()) { await loadJobs(); } + if (isClusterPage()) { + const n = getClusterPageName(); + if (n) await loadClusterOverview(n); + } } catch (e) { /* loadHealth/loadStats/loadClusters сами сообщают об ошибках в UI */ } @@ -1594,8 +2334,19 @@ if (isCreatePage()) { await loadJobs(); } + if (isClusterPage()) { + const n = getClusterPageName(); + if (n) await loadClusterOverview(n); + else hideClusterPageLoadingOverlay(); + } } catch (e) { - /* см. сообщения в блоках */ + const errText = e && e.message ? e.message : String(e); + const lm = document.getElementById("list-msg"); + if (isHomePage() && lm) { + lm.textContent = "Ошибка загрузки панели: " + errText; + } + } finally { + hideHomePageLoadingOverlay(); } })(); if (isCreatePage()) { diff --git a/app/static/style.css b/app/static/style.css index e4fd96b..3ee0ec1 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -619,12 +619,19 @@ a.btn-cta-large:hover { } .donut-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); - gap: 1rem 1.25rem; - align-items: start; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-evenly; + align-items: flex-start; + gap: 1rem 1.5rem; + overflow-x: auto; + padding-bottom: 0.35rem; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; } .donut-item { + flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; @@ -671,34 +678,94 @@ a.btn-cta-large:hover { max-width: 40rem; } -/* Вторая строка таблицы: раскрытие ресурсов */ -.cluster-resources-expand-row td { - border-top: none; - padding-top: 0; -} -.cluster-resources-expand-cell { - padding: 0 0.75rem 0.65rem; - background: rgba(0, 0, 0, 0.06); -} -html[data-theme="light"] .cluster-resources-expand-cell { - background: rgba(59, 130, 246, 0.06); -} -.cluster-nodes-resources { - margin: 0; -} -.cluster-nodes-resources summary { - cursor: pointer; - font-weight: 600; - font-size: 0.88rem; - padding: 0.35rem 0; - color: var(--accent); -} .cluster-nodes-resources__empty { margin: 0.25rem 0 0; font-size: 0.85rem; } -.cluster-row-node-res { + +/* Карточки узлов: полосы CPU, RAM %, память от лимита, сеть, диск + подсказка справа */ +.cluster-nodes-resources__cards-row { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + gap: 0.75rem; + margin-top: 0.35rem; + overflow-x: auto; + padding-bottom: 0.25rem; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; +} +.cluster-node-res-card { + flex: 0 0 auto; + width: min(17.5rem, 88vw); + min-width: 14rem; + box-sizing: border-box; + padding: 0.55rem 0.65rem 0.5rem; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); + border-left: 3px solid var(--accent); +} +html[data-theme="light"] .cluster-node-res-card { + background: rgba(59, 130, 246, 0.08); +} +.cluster-node-res-card__head { + margin: 0 0 0.45rem; +} +.cluster-node-res-card__head code { + font-size: 0.78rem; + word-break: break-all; + line-height: 1.35; +} +.cluster-node-res-card__bars { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.cluster-node-res-card__bar-row { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr) minmax(2.8rem, 4.5rem); + align-items: center; + gap: 0.3rem 0.35rem; +} +.cluster-node-res-card__bar-hint { + font-size: 0.62rem; + line-height: 1.2; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.cluster-node-res-card__bar-label { + font-size: 0.68rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--muted); +} +.cluster-node-res-card__bar-track { + height: 0.45rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} +html[data-theme="light"] .cluster-node-res-card__bar-track { + background: rgba(0, 0, 0, 0.06); +} +.cluster-node-res-card__bar-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgba(59, 130, 246, 0.55), var(--accent)); + min-width: 2px; + transition: width 0.25s ease; +} +.cluster-node-res-card__foot { margin-top: 0.45rem; + font-size: 0.72rem; + line-height: 1.4; + word-break: break-word; } .cluster-create-hero { @@ -961,6 +1028,12 @@ html[data-theme="light"] .badge { border-color: #15803d; color: #4ade80; } +/* Светлая тема: насыщенный зелёный текст и фон — иначе «Запущен» сливается с белым карточным фоном */ +html[data-theme="light"] .badge-ok { + border-color: #166534; + color: #14532d; + background: rgba(22, 101, 52, 0.12); +} .badge-err { border-color: #b91c1c; color: #f87171; @@ -1675,3 +1748,483 @@ html[data-theme="light"] .readme-section-card.markdown-body pre { html[data-theme="light"] .readme-section-card.markdown-body th { background: rgba(59, 130, 246, 0.14); } + +/* Страница кластера: /cluster/<имя> — сводка, kubectl, действия */ +.cluster-detail-breadcrumb { + margin: 0 0 1rem; + font-size: 0.9rem; +} +.cluster-detail-breadcrumb a { + color: var(--accent); + text-decoration: none; +} +.cluster-detail-breadcrumb a:hover { + text-decoration: underline; +} +/* Ссылка на страницу кластера в таблице: контраст в светлой и тёмной теме */ +.cluster-name-link { + text-decoration: none; + font-weight: 600; + border-bottom: 1px solid transparent; +} +html[data-theme="light"] .cluster-name-link { + color: #0369a1; +} +html[data-theme="light"] .cluster-name-link:hover { + color: #0c4a6e; + border-bottom-color: #0369a1; +} +html[data-theme="light"] .cluster-name-link .cluster-name { + color: inherit; +} +html[data-theme="dark"] .cluster-name-link { + color: #7dd3fc; +} +html[data-theme="dark"] .cluster-name-link:hover { + color: #e0f2fe; + border-bottom-color: #38bdf8; +} +html[data-theme="dark"] .cluster-name-link .cluster-name { + color: inherit; +} + +/* Страница кластера: отступы между карточками */ +body.cluster-detail-page .app-main > .card { + margin-bottom: 1.35rem; +} +body.cluster-detail-page .app-main > .card:last-child { + margin-bottom: 0; +} + +/* Шапка страницы кластера: слева заголовок и кнопки, справа мета столбиком */ +.cluster-detail-hero-layout { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem 1.75rem; +} +.cluster-detail-hero-left { + flex: 1 1 14rem; + min-width: 0; +} +.cluster-detail-hero-meta { + flex: 0 0 auto; + align-self: flex-start; +} +.cluster-detail-hero-card .page-title { + margin-bottom: 0.5rem; +} +.cluster-detail-meta { + min-width: 14rem; + max-width: 28rem; +} + +/* Компактная карточка сводки (kind, kubeconfig, состояние, версия, workers) */ +.cluster-detail-meta-card { + border-radius: 12px; + border: 1px solid var(--border); + background: linear-gradient( + 155deg, + rgba(59, 130, 246, 0.09) 0%, + rgba(15, 23, 42, 0.35) 48%, + rgba(15, 23, 42, 0.2) 100% + ); + padding: 0.65rem 0.75rem 0.7rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); +} +html[data-theme="light"] .cluster-detail-meta-card { + background: linear-gradient( + 155deg, + rgba(59, 130, 246, 0.12) 0%, + rgba(255, 255, 255, 0.95) 55%, + rgba(241, 245, 249, 0.9) 100% + ); + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.08); +} +.cluster-detail-meta-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem 0.55rem; +} +@media (max-width: 720px) { + .cluster-detail-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 400px) { + .cluster-detail-meta-grid { + grid-template-columns: 1fr; + } +} +.cluster-meta-tile { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.35rem 0.4rem; + border-radius: 8px; + background: rgba(0, 0, 0, 0.14); + border: 1px solid rgba(255, 255, 255, 0.06); + min-width: 0; +} +html[data-theme="light"] .cluster-meta-tile { + background: rgba(255, 255, 255, 0.65); + border-color: rgba(59, 130, 246, 0.15); +} +.cluster-meta-tile__label { + font-size: 0.65rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1.2; +} +.cluster-meta-tile__value { + font-size: 0.88rem; + font-weight: 600; + line-height: 1.25; + min-height: 1.25rem; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.2rem; +} +.cluster-meta-tile--wide { + grid-column: 1 / -1; +} +.cluster-meta-badge { + font-size: 0.78rem !important; + padding: 0.12rem 0.45rem !important; +} +.cluster-meta-badge--muted { + background: transparent !important; + border: 1px dashed var(--border) !important; + color: var(--muted) !important; + font-weight: 500 !important; +} +.cluster-meta-version-tag { + font-size: 0.78rem; + font-weight: 600; + padding: 0.1rem 0.35rem; + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border); +} +html[data-theme="light"] .cluster-meta-version-tag { + background: rgba(59, 130, 246, 0.08); +} +.cluster-meta-workers { + font-variant-numeric: tabular-nums; + font-size: 0.95rem; +} + +/* Обёртка кнопок действий под заголовком кластера */ +.cluster-detail-actions-card { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.45rem; + margin-bottom: 0.35rem; + border-radius: 10px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} +html[data-theme="light"] .cluster-detail-actions-card { + background: rgba(15, 23, 42, 0.03); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} +.cluster-detail-actions-card .cluster-detail-toolbar { + margin: 0; +} + +.cluster-k8s-scope-hint { + margin: 0 0 0.85rem; + font-size: 0.86rem; + line-height: 1.45; + max-width: 48rem; +} +.cluster-k8s-scope-hint code { + font-size: 0.8em; +} + +.cluster-detail-meta__column { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35rem 0; + text-align: right; +} +.cluster-detail-meta__line { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.35rem 0.65rem; + align-items: baseline; + font-size: 0.88rem; + width: 100%; + max-width: 22rem; +} +.cluster-detail-meta__label { + justify-self: end; + white-space: nowrap; +} +.cluster-detail-meta__value { + justify-self: start; + text-align: left; + min-width: 0; +} +.cluster-detail-hero-main { + flex: 1 1 12rem; + min-width: 0; +} +.cluster-detail-hero-actions { + flex: 0 0 auto; + align-self: flex-end; +} +.cluster-detail-hero-actions .actions-toolbar { + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.35rem; + max-width: 22rem; +} +@media (max-width: 640px) { + .cluster-detail-hero-layout { + flex-direction: column; + align-items: stretch; + } + .cluster-detail-hero-meta { + align-self: stretch; + } + .cluster-detail-meta { + max-width: none; + } + .cluster-detail-meta__column { + align-items: flex-start; + text-align: left; + } + .cluster-detail-meta__line { + grid-template-columns: 6.5rem 1fr; + max-width: none; + } + .cluster-detail-meta__label { + justify-self: start; + } + .cluster-detail-meta__value { + justify-self: start; + } + .cluster-detail-hero-actions { + align-self: stretch; + } + .cluster-detail-hero-actions .actions-toolbar { + justify-content: flex-start; + max-width: none; + } +} +.cluster-detail-section-title { + font-size: 1.05rem; + margin: 0 0 0.65rem; +} +.cluster-detail-meta__row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + margin-top: 0.5rem; +} +.cluster-detail-meta__item { + font-size: 0.88rem; +} +.cluster-detail-toolbar { + margin: 0; +} +.cluster-kube-hint { + margin: 0; + padding: 0.5rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(234, 179, 8, 0.12); +} + +/* Вводная карточка kubectl + отдельная карточка на каждый ресурс (как в шаблоне cluster_detail) */ +.cluster-k8s-intro-card .cluster-k8s-scope-hint { + margin: 0 0 0.75rem; +} +.cluster-k8s-resource-card .cluster-detail-section-title { + margin: 0 0 0.2rem; + 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; +} + +/* Сетка карточек узлов на странице кластера — на всю ширину секции */ +.cluster-page-node-cards--wide.cluster-nodes-resources__cards-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + flex-wrap: wrap; + overflow-x: visible; + width: 100%; +} +.cluster-page-node-cards--wide .cluster-node-res-card { + width: auto; + min-width: 0; + flex: none; +} + +/* Таблицы Kubernetes (kubectl -o json) */ +.cluster-k8s-root { + min-height: 1.5rem; +} +.cluster-k8s-msg { + margin: 0; + font-size: 0.85rem; + line-height: 1.45; +} +.cluster-k8s-table-scroll { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.12); +} +html[data-theme="light"] .cluster-k8s-table-scroll { + background: rgba(15, 23, 42, 0.04); +} +.cluster-k8s-table { + width: 100%; + min-width: 36rem; + border-collapse: collapse; + font-size: 0.8rem; +} +.cluster-k8s-table th, +.cluster-k8s-table td { + padding: 0.4rem 0.55rem; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.cluster-k8s-table th { + font-weight: 650; + color: var(--muted); + white-space: nowrap; + background: rgba(0, 0, 0, 0.18); +} +html[data-theme="light"] .cluster-k8s-table th { + background: rgba(59, 130, 246, 0.1); +} +.cluster-k8s-table tbody tr:last-child td { + border-bottom: none; +} +.cluster-k8s-table td:last-child { + width: 1%; + white-space: nowrap; +} +.cluster-k8s-table .icon-tooltip-host { + display: inline-flex; + vertical-align: middle; +} +.btn-k8s-restart { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.5rem; + font-size: 0.78rem; +} +.btn-k8s-restart svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} +/* Рестарт пода: только иконка; подсказка — #action-tooltip (.icon-tooltip-host) */ +.btn-k8s-restart--solo { + width: 2rem; + height: 2rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; +} +.btn-k8s-restart--solo svg { + width: 1.05rem; + height: 1.05rem; +} + +.cluster-kube-pre { + max-height: 16rem; + overflow: auto; + margin: 0; + padding: 0.5rem 0.65rem; + font-size: 0.78rem; + line-height: 1.35; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); +} +html[data-theme="light"] .cluster-kube-pre { + background: rgba(15, 23, 42, 0.04); +} + +/* Полноэкранный спиннер первой загрузки главной панели */ +body.dashboard-home-loading { + overflow: hidden; +} + +/* Полноэкранный спиннер первой загрузки страницы кластера */ +body.cluster-page-loading { + overflow: hidden; +} +.page-loading-overlay { + position: fixed; + inset: 0; + z-index: 9998; + display: flex; + align-items: center; + justify-content: center; +} +.page-loading-overlay.hidden { + display: none; + pointer-events: none; +} +.page-loading-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.52); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} +html[data-theme="light"] .page-loading-backdrop { + background: rgba(241, 245, 249, 0.72); +} +.page-loading-center { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 1.35rem 1.75rem; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--card); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); +} +html[data-theme="light"] .page-loading-center { + box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12); +} +.page-loading-spinner { + width: 2.5rem; + height: 2.5rem; + border-width: 4px; +} +.page-loading-label { + font-size: 0.92rem; + color: var(--muted); + text-align: center; + max-width: 18rem; + line-height: 1.4; +} diff --git a/app/templates/cluster_detail.html b/app/templates/cluster_detail.html new file mode 100644 index 0000000..478bcdf --- /dev/null +++ b/app/templates/cluster_detail.html @@ -0,0 +1,164 @@ +{# Страница одного кластера: сводка ресурсов, kubectl, действия. + Автор: Сергей Антропов — https://devops.org.ru #} +{% extends "base.html" %} + +{% block page_title %}Кластер {{ cluster_name }}{% endblock %} + +{% block body_extra_class %} cluster-detail-page cluster-page-loading{% endblock %} + +{% block body_attrs %}data-dashboard-mode="cluster" data-cluster-name="{{ cluster_name | e }}"{% endblock %} + +{% block footer %} + +{% endblock %} + +{% block content %} +{# Полноэкранный спиннер до первой загрузки overview; скрывается в dashboard.js #} +
+ +
+ + Загрузка данных кластера… +
+
+ +

+ Панель + + Кластер {{ cluster_name | e }} +

+ +
+
+
+

Кластер {{ cluster_name | e }}

+
+ +
+

+
+ +
+
+ +
+

Ресурсы узлов (сводка)

+
+
+ +
+

Ресурсы узлов

+
+
+ +
+

Kubernetes (kubectl)

+

+ Данные по всему кластеру: для ресурсов в namespace используется + kubectl get … -A (все namespace); узлы и список namespace — без фильтра по ns. + Ниже каждый тип ресурса — отдельная карточка с тем же запросом, что выполняет API. +

+ +
+ +
+

Узлы

+

kubectl get nodes -o json

+
+
+ +
+

Namespaces

+

kubectl get namespaces -o json

+
+
+ +
+

Поды

+

kubectl get pods -A -o json

+
+
+ +
+

Deployments

+

kubectl get deployments -A -o json

+
+
+ +
+

StatefulSets

+

kubectl get statefulsets -A -o json

+
+
+ +
+

DaemonSets

+

kubectl get daemonsets -A -o json

+
+
+ +
+

ReplicaSets

+

kubectl get replicasets -A -o json

+
+
+ +
+

Jobs

+

kubectl get jobs -A -o json

+
+
+ +
+

CronJobs

+

kubectl get cronjobs -A -o json

+
+
+ +
+

Сервисы

+

kubectl get services -A -o json

+
+
+ +
+

Ingresses

+

kubectl get ingresses.networking.k8s.io -A -o json

+
+
+ +
+

PVC

+

kubectl get pvc -A -o json

+
+
+ +{% include "partials/dashboard_modals.html" %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 7f27927..03aad05 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -2,6 +2,8 @@ Автор: Сергей Антропов — https://devops.org.ru #} {% extends "base.html" %} +{% block body_extra_class %} dashboard-home-loading{% endblock %} + {% block body_attrs %}data-dashboard-mode="home"{% endblock %} {% block footer %} @@ -14,6 +16,21 @@ {% endblock %} {% block content %} +
+ +
+ + Загрузка панели… +
+
+
diff --git a/docker-compose.yml b/docker-compose.yml index b73f472..ecfa7b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,12 +28,18 @@ services: # Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000. ports: - "${KIND_K8S_WEB_PORT:-8080}:6000" + # Доступ к apiserver kind с проброшенного порта хоста (kubectl внутри контейнера веб-UI). + extra_hosts: + - "host.docker.internal:host-gateway" environment: DOCKER_HOST: unix:///var/run/docker.sock KIND_K8S_IN_CONTAINER: "1" KIND_K8S_WORKDIR: /work - # По умолчанию включено: kubeconfig с хоста открывает API через проброшенный порт. + # kubeconfig.host на томе; скачивание в UI патчит копию с KIND_K8S_KUBECONFIG_CLIENT_HOST (по умолчанию localhost). KIND_K8S_PATCH_KUBECONFIG: ${KIND_K8S_PATCH_KUBECONFIG:-1} + KIND_K8S_KUBECONFIG_CLIENT_HOST: ${KIND_K8S_KUBECONFIG_CLIENT_HOST:-localhost} + # Для kubectl внутри контейнера при server=host.docker.internal: SAN kind — localhost (редко менять). + KIND_K8S_KUBECONFIG_TLS_SERVER_NAME: ${KIND_K8S_KUBECONFIG_TLS_SERVER_NAME:-localhost} CONTAINER_CLI: ${CONTAINER_CLI:-docker} KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-} KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-} diff --git a/scripts/setup_env_interactive.py b/scripts/setup_env_interactive.py index 021bab2..590c738 100644 --- a/scripts/setup_env_interactive.py +++ b/scripts/setup_env_interactive.py @@ -97,9 +97,13 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = ( _EnvPrompt( "# --- docker-compose.yml: environment сервиса kind-k8s-web ---", "KIND_K8S_PATCH_KUBECONFIG", - "1/true/yes — после create всегда патчить server в kubeconfig на 127.0.0.1:<порт> для доступа с хоста; " - "0 или пусто — не форсировать (в контейнере compose всё равно может сработать KIND_K8S_IN_CONTAINER). " - "По умолчанию в скрипте: 1 (включено).", + "1/true/yes — после create писать clusters/<имя>/kubeconfig.host (server с KIND_K8S_KUBECONFIG_CLIENT_HOST, иначе localhost); " + "основной kubeconfig — как у kind. 0 или пусто — не форсировать. По умолчанию в скрипте: 1 (включено).", + ), + _EnvPrompt( + None, + "KIND_K8S_KUBECONFIG_CLIENT_HOST", + "Имя хоста в server= для скачиваемого kubeconfig (kubectl на машине пользователя). Пусто — localhost (лучше 127.0.0.1 с TLS kind). Для доступа с другой машины — IP/имя хоста Docker.", ), _EnvPrompt(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."), _EnvPrompt(