Веб-UI кластера: страница деталей, kubectl по карточкам, мета 3 колонки
- Страница /cluster/<имя>: сводка, ресурсы узлов полосами, отдельные карточки на каждый kubectl get … -o json, рестарт пода. - API: overview с блоками k8s_*, POST pods/restart, расширенный набор ресурсов (-A). - Панель: спиннер загрузки, правки дашборда и стилей; документация api_routes, compose и прочие сопутствующие изменения.
This commit is contained in:
@@ -10,10 +10,16 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from core.cluster_lifecycle import (
|
||||
KindClusterError,
|
||||
@@ -21,6 +27,9 @@ from core.cluster_lifecycle import (
|
||||
configured_container_cli,
|
||||
create_cluster_non_interactive,
|
||||
delete_kind_cluster_and_data,
|
||||
kubectl_delete_pod,
|
||||
kubectl_get_json,
|
||||
kubectl_get_all_namespaces_wide,
|
||||
kubectl_nodes_wide,
|
||||
kubectl_pods_all_namespaces,
|
||||
list_registered_kind_clusters,
|
||||
@@ -32,6 +41,7 @@ from core.cluster_lifecycle import (
|
||||
from core.container_resource_stats import (
|
||||
aggregate_kind_cluster_resources,
|
||||
collect_kind_clusters_resource_stats,
|
||||
collect_single_cluster_resource_stats,
|
||||
running_kind_clusters_mask,
|
||||
)
|
||||
from core.job_store import (
|
||||
@@ -47,18 +57,51 @@ from core.job_store import (
|
||||
from core.provision_log import provision_log_file_path, write_cluster_provision_log
|
||||
from core.kind_guard import kind_cluster_lock
|
||||
from kind_k8s_paths import clusters_dir
|
||||
from kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host
|
||||
from models.schemas import (
|
||||
AggregateResourcesSummary,
|
||||
ClusterCreateAccepted,
|
||||
ClusterCreateRequest,
|
||||
ClusterOverviewResponse,
|
||||
ClusterSummary,
|
||||
ClusterWorkloadsResponse,
|
||||
JobView,
|
||||
K8sListJsonBlock,
|
||||
PodRestartRequest,
|
||||
PodRestartResponse,
|
||||
StatsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kind_k8s.api.clusters")
|
||||
|
||||
# Безопасные имена namespace/pod для kubectl (без shell-метасимволов).
|
||||
_K8S_NAME_SAFE = re.compile(r"^[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$")
|
||||
|
||||
|
||||
def _k8s_ns_pod_valid(s: str) -> bool:
|
||||
if not s or len(s) > 253:
|
||||
return False
|
||||
return bool(_K8S_NAME_SAFE.match(s))
|
||||
|
||||
|
||||
def _k8s_list_from_kubectl(
|
||||
rc: int,
|
||||
items: list[dict[str, Any]] | None,
|
||||
err: str,
|
||||
) -> K8sListJsonBlock:
|
||||
"""Собрать блок JSON для UI из результата :func:`kubectl_get_json`."""
|
||||
if items is not None and rc == 0:
|
||||
return K8sListJsonBlock(ok=True, rc=0, items=items, message=None)
|
||||
return K8sListJsonBlock(ok=False, rc=rc, items=[], message=err or "ошибка kubectl")
|
||||
|
||||
|
||||
def _unlink_temp_kubeconfig(path: Path) -> None:
|
||||
"""Удалить временный файл после отдачи ответа (скачивание kubeconfig)."""
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug("Не удалось удалить временный kubeconfig %s: %s", path, e)
|
||||
|
||||
router = APIRouter(tags=["clusters"])
|
||||
|
||||
|
||||
@@ -202,16 +245,56 @@ async def list_clusters() -> list[ClusterSummary]:
|
||||
responses={404: {"description": "Файл не найден"}},
|
||||
)
|
||||
async def download_kubeconfig(name: str) -> FileResponse:
|
||||
"""Файл ``clusters/<имя>/kubeconfig`` для использования с хоста (локальная dev-среда)."""
|
||||
"""
|
||||
Kubeconfig для **kubectl на машине пользователя**: копия ``clusters/<имя>/kubeconfig``
|
||||
с ``server=https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`` (порт из ``docker port``).
|
||||
|
||||
Патч выполняется при каждом скачивании, чтобы учитывать переменные окружения и актуальный порт.
|
||||
Если патч невозможен — отдаётся сохранённый ``kubeconfig.host`` или сырой ``kubeconfig``.
|
||||
"""
|
||||
if not validate_cluster_name(name):
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||
path = clusters_dir() / name / "kubeconfig"
|
||||
if not path.is_file():
|
||||
n = name.strip()
|
||||
base = clusters_dir() / n
|
||||
primary = base / "kubeconfig"
|
||||
if not primary.is_file():
|
||||
raise HTTPException(status_code=404, detail="kubeconfig не найден")
|
||||
logger.info("Отдача kubeconfig для кластера %s", name)
|
||||
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="kind-k8s-kube-")
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
shutil.copyfile(primary, tmp_path, follow_symlinks=True)
|
||||
except OSError as e:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
logger.warning("Копирование kubeconfig для скачивания: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Не удалось подготовить kubeconfig") from e
|
||||
|
||||
patched = await asyncio.to_thread(
|
||||
partial(patch_kubeconfig_server_for_host, cluster_name=n, kube_path=tmp_path),
|
||||
)
|
||||
if patched:
|
||||
logger.info("Отдача kubeconfig для кластера %s (сгенерировано с актуальным server)", n)
|
||||
return FileResponse(
|
||||
path=str(tmp_path),
|
||||
filename=f"kubeconfig-{n}.yaml",
|
||||
media_type="application/x-yaml",
|
||||
background=BackgroundTask(_unlink_temp_kubeconfig, tmp_path),
|
||||
)
|
||||
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
host_path = kubeconfig_host_file(base)
|
||||
if host_path.is_file():
|
||||
logger.info("Отдача kubeconfig для кластера %s (файл %s, патч при скачивании не удался)", n, host_path.name)
|
||||
return FileResponse(
|
||||
path=host_path,
|
||||
filename=f"kubeconfig-{n}.yaml",
|
||||
media_type="application/x-yaml",
|
||||
)
|
||||
logger.info("Отдача kubeconfig для кластера %s (сырой kubeconfig)", n)
|
||||
return FileResponse(
|
||||
path=path,
|
||||
filename=f"kubeconfig-{name}.yaml",
|
||||
path=primary,
|
||||
filename=f"kubeconfig-{n}.yaml",
|
||||
media_type="application/x-yaml",
|
||||
)
|
||||
|
||||
@@ -250,8 +333,16 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
|
||||
if not kc.is_file():
|
||||
return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/")
|
||||
|
||||
nodes_rc, nodes_out = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=kc)
|
||||
pods_rc, pods_out = await asyncio.to_thread(kubectl_pods_all_namespaces, kubeconfig=kc)
|
||||
nodes_rc, nodes_out = await asyncio.to_thread(
|
||||
kubectl_nodes_wide,
|
||||
cluster_name=name,
|
||||
kubeconfig=kc,
|
||||
)
|
||||
pods_rc, pods_out = await asyncio.to_thread(
|
||||
kubectl_pods_all_namespaces,
|
||||
cluster_name=name,
|
||||
kubeconfig=kc,
|
||||
)
|
||||
return ClusterWorkloadsResponse(
|
||||
cluster_name=name,
|
||||
nodes_rc=nodes_rc,
|
||||
@@ -261,6 +352,169 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/clusters/{name}/overview",
|
||||
response_model=ClusterOverviewResponse,
|
||||
summary="Сводка кластера (страница UI)",
|
||||
)
|
||||
async def cluster_overview(name: str) -> ClusterOverviewResponse:
|
||||
"""
|
||||
Мета, метрики узлов (docker/podman), агрегаты как на главной, вывод kubectl по **всему кластеру**:
|
||||
узлы; для namespace-ресурсов везде **-A** (все пространства имён); отдельно namespaces, RS, jobs и т.д.
|
||||
"""
|
||||
if not validate_cluster_name(name):
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||
n = name.strip()
|
||||
summary = cluster_summary_for_api(n)
|
||||
run_mask = await asyncio.to_thread(running_kind_clusters_mask, [n])
|
||||
kind_running = bool(run_mask.get(n, False))
|
||||
|
||||
block, res_err = await asyncio.to_thread(collect_single_cluster_resource_stats, n)
|
||||
agg = aggregate_kind_cluster_resources([block])
|
||||
|
||||
kc = clusters_dir() / n / "kubeconfig"
|
||||
kube_err: str | None = None
|
||||
k8s_nodes = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_pods = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_deployments = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_statefulsets = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_daemonsets = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_services = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_ingresses = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_namespaces = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_replicasets = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_jobs = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_cronjobs = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
k8s_pvcs = K8sListJsonBlock(ok=False, items=[], message=None)
|
||||
|
||||
if not kc.is_file():
|
||||
kube_err = "Нет сохранённого kubeconfig в clusters/<имя>/"
|
||||
_kube_missing = K8sListJsonBlock(ok=False, items=[], message=kube_err)
|
||||
k8s_nodes = _kube_missing
|
||||
k8s_pods = k8s_deployments = k8s_statefulsets = k8s_daemonsets = _kube_missing
|
||||
k8s_services = k8s_ingresses = k8s_namespaces = k8s_replicasets = _kube_missing
|
||||
k8s_jobs = k8s_cronjobs = k8s_pvcs = _kube_missing
|
||||
else:
|
||||
(
|
||||
n_res,
|
||||
p_res,
|
||||
d_res,
|
||||
st_res,
|
||||
dm_res,
|
||||
svc_res,
|
||||
ing_res,
|
||||
ns_res,
|
||||
rs_res,
|
||||
job_res,
|
||||
cj_res,
|
||||
pvc_res,
|
||||
) = await asyncio.gather(
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["nodes"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pods", "-A"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["deployments", "-A"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["statefulsets", "-A"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["daemonsets", "-A"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["services", "-A"]),
|
||||
asyncio.to_thread(
|
||||
kubectl_get_json,
|
||||
cluster_name=n,
|
||||
kubeconfig=kc,
|
||||
resource_args=["ingresses.networking.k8s.io", "-A"],
|
||||
),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["namespaces"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["replicasets", "-A"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["jobs", "-A"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["cronjobs", "-A"]),
|
||||
asyncio.to_thread(kubectl_get_json, cluster_name=n, kubeconfig=kc, resource_args=["pvc", "-A"]),
|
||||
)
|
||||
k8s_nodes = _k8s_list_from_kubectl(*n_res)
|
||||
k8s_pods = _k8s_list_from_kubectl(*p_res)
|
||||
k8s_deployments = _k8s_list_from_kubectl(*d_res)
|
||||
k8s_statefulsets = _k8s_list_from_kubectl(*st_res)
|
||||
k8s_daemonsets = _k8s_list_from_kubectl(*dm_res)
|
||||
k8s_services = _k8s_list_from_kubectl(*svc_res)
|
||||
k8s_ingresses = _k8s_list_from_kubectl(*ing_res)
|
||||
k8s_namespaces = _k8s_list_from_kubectl(*ns_res)
|
||||
k8s_replicasets = _k8s_list_from_kubectl(*rs_res)
|
||||
k8s_jobs = _k8s_list_from_kubectl(*job_res)
|
||||
k8s_cronjobs = _k8s_list_from_kubectl(*cj_res)
|
||||
k8s_pvcs = _k8s_list_from_kubectl(*pvc_res)
|
||||
|
||||
meta = summary.get("meta")
|
||||
return ClusterOverviewResponse(
|
||||
cluster_name=n,
|
||||
registered_in_kind=bool(summary["registered_in_kind"]),
|
||||
kind_nodes_running=kind_running,
|
||||
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
|
||||
has_provision_log=bool(summary.get("has_provision_log")),
|
||||
meta=dict(meta) if isinstance(meta, dict) else {},
|
||||
resources_error=res_err,
|
||||
cluster_resources=block,
|
||||
aggregate_resources=agg,
|
||||
kubeconfig_error=kube_err,
|
||||
nodes_rc=None,
|
||||
nodes_output=None,
|
||||
pods_rc=None,
|
||||
pods_output=None,
|
||||
deployments_rc=None,
|
||||
deployments_output=None,
|
||||
statefulsets_rc=None,
|
||||
statefulsets_output=None,
|
||||
daemonsets_rc=None,
|
||||
daemonsets_output=None,
|
||||
services_rc=None,
|
||||
services_output=None,
|
||||
ingresses_rc=None,
|
||||
ingresses_output=None,
|
||||
k8s_nodes=k8s_nodes,
|
||||
k8s_pods=k8s_pods,
|
||||
k8s_deployments=k8s_deployments,
|
||||
k8s_statefulsets=k8s_statefulsets,
|
||||
k8s_daemonsets=k8s_daemonsets,
|
||||
k8s_services=k8s_services,
|
||||
k8s_ingresses=k8s_ingresses,
|
||||
k8s_namespaces=k8s_namespaces,
|
||||
k8s_replicasets=k8s_replicasets,
|
||||
k8s_jobs=k8s_jobs,
|
||||
k8s_cronjobs=k8s_cronjobs,
|
||||
k8s_pvcs=k8s_pvcs,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/clusters/{name}/pods/restart",
|
||||
response_model=PodRestartResponse,
|
||||
summary="Рестарт пода (kubectl delete pod)",
|
||||
)
|
||||
async def restart_cluster_pod(name: str, body: PodRestartRequest) -> PodRestartResponse:
|
||||
"""
|
||||
Удаление пода: Deployment/RS/Job при необходимости создают новый Pod (типичный «рестарт»).
|
||||
"""
|
||||
if not validate_cluster_name(name):
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||
n = name.strip()
|
||||
ns = body.namespace.strip()
|
||||
pod = body.pod.strip()
|
||||
if not _k8s_ns_pod_valid(ns) or not _k8s_ns_pod_valid(pod):
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя namespace или пода")
|
||||
kc = clusters_dir() / n / "kubeconfig"
|
||||
if not kc.is_file():
|
||||
raise HTTPException(status_code=404, detail="kubeconfig не найден")
|
||||
rc, msg = await asyncio.to_thread(
|
||||
kubectl_delete_pod,
|
||||
cluster_name=n,
|
||||
kubeconfig=kc,
|
||||
namespace=ns,
|
||||
pod_name=pod,
|
||||
grace_period=30,
|
||||
)
|
||||
if rc != 0:
|
||||
logger.warning("restart pod %s/%s в кластере %s: rc=%s %s", ns, pod, n, rc, msg[:500])
|
||||
raise HTTPException(status_code=502, detail=msg[:4000] if msg else "kubectl delete pod завершился с ошибкой")
|
||||
logger.info("Рестарт пода %s/%s в кластере %s", ns, pod, n)
|
||||
return PodRestartResponse(cluster_name=n, namespace=ns, pod=pod, rc=rc, message=msg or "ok")
|
||||
|
||||
|
||||
@router.get("/clusters/{name}", summary="Детали кластера")
|
||||
async def get_cluster(name: str) -> dict[str, object]:
|
||||
"""Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig)."""
|
||||
@@ -269,7 +523,11 @@ async def get_cluster(name: str) -> dict[str, object]:
|
||||
kubectl_rc: int | None = None
|
||||
kubectl_msg: str | None = None
|
||||
if saved.is_file():
|
||||
rc, msg = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=saved)
|
||||
rc, msg = await asyncio.to_thread(
|
||||
kubectl_nodes_wide,
|
||||
cluster_name=name.strip(),
|
||||
kubeconfig=saved,
|
||||
)
|
||||
kubectl_rc = rc
|
||||
kubectl_msg = msg
|
||||
return {
|
||||
|
||||
@@ -17,13 +17,19 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from kind_k8s_paths import clusters_dir, data_root
|
||||
from kindest_node_tags import normalize_tag_v_prefix
|
||||
from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_after_create
|
||||
from kubeconfig_patch import (
|
||||
kubeconfig_host_file,
|
||||
kubeconfig_path_for_container_kubectl,
|
||||
patch_kubeconfig_server_for_host,
|
||||
should_patch_after_create,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
|
||||
|
||||
@@ -398,19 +404,31 @@ def _wait_nodes_timeout_sec() -> int:
|
||||
return 300
|
||||
|
||||
|
||||
def wait_nodes_ready(*, kubeconfig_path: Path, timeout_sec: int | None = None) -> tuple[bool, str]:
|
||||
def wait_nodes_ready(
|
||||
*,
|
||||
cluster_name: str,
|
||||
kubeconfig_path: Path,
|
||||
timeout_sec: int | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Дождаться condition=Ready для всех нод через kubectl wait.
|
||||
|
||||
В контейнере веб-приложения путь к kubeconfig проходит через
|
||||
``kubeconfig_path_for_container_kubectl`` (шлюз хоста + проброшенный порт или имя узла kind).
|
||||
|
||||
Возвращает (успех, сообщение для лога/UI).
|
||||
"""
|
||||
if timeout_sec is None:
|
||||
timeout_sec = _wait_nodes_timeout_sec()
|
||||
cfg = kubeconfig_path_for_container_kubectl(
|
||||
cluster_name=cluster_name,
|
||||
kube_source_path=kubeconfig_path,
|
||||
)
|
||||
t = f"{timeout_sec}s"
|
||||
cmd = [
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(kubeconfig_path),
|
||||
str(cfg),
|
||||
"wait",
|
||||
"--for=condition=Ready",
|
||||
"nodes",
|
||||
@@ -561,10 +579,14 @@ def create_cluster_non_interactive(
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Операция отменена")
|
||||
|
||||
host_kube_path = kubeconfig_host_file(kube_path.parent)
|
||||
patched = False
|
||||
if should_patch_after_create():
|
||||
_progress("Настройка доступа с вашего компьютера", 72)
|
||||
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
|
||||
shutil.copyfile(kube_path, host_kube_path, follow_symlinks=True)
|
||||
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=host_kube_path)
|
||||
if not patched:
|
||||
host_kube_path.unlink(missing_ok=True)
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
@@ -574,7 +596,7 @@ def create_cluster_non_interactive(
|
||||
nodes_msg: str | None = None
|
||||
if _wait_nodes_enabled():
|
||||
_progress("Ожидание готовности узлов", 82)
|
||||
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
|
||||
ok, msg = wait_nodes_ready(cluster_name=name, kubeconfig_path=kube_path)
|
||||
nodes_ready = ok
|
||||
nodes_msg = msg
|
||||
if ok:
|
||||
@@ -606,6 +628,8 @@ def create_cluster_non_interactive(
|
||||
"nodes_ready_message": nodes_msg,
|
||||
"provisioned_from_existing_config": use_existing_config,
|
||||
}
|
||||
if patched:
|
||||
meta["kubeconfig_host_path"] = str(host_kube_path.relative_to(root))
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
_progress("Завершение", 95)
|
||||
@@ -872,13 +896,15 @@ def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]
|
||||
return False, err[:800], cli
|
||||
|
||||
|
||||
def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
|
||||
def kubectl_nodes_wide(*, cluster_name: str, kubeconfig: str | Path) -> tuple[int, str]:
|
||||
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
|
||||
src = Path(kubeconfig)
|
||||
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
|
||||
p = subprocess.run(
|
||||
[
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(kubeconfig),
|
||||
str(cfg),
|
||||
"get",
|
||||
"nodes",
|
||||
"-o",
|
||||
@@ -894,13 +920,15 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
|
||||
return p.returncode, msg
|
||||
|
||||
|
||||
def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
|
||||
def kubectl_pods_all_namespaces(*, cluster_name: str, kubeconfig: str | Path) -> tuple[int, str]:
|
||||
"""``kubectl get pods -A``; сводка подов по кластеру."""
|
||||
src = Path(kubeconfig)
|
||||
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
|
||||
p = subprocess.run(
|
||||
[
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(kubeconfig),
|
||||
str(cfg),
|
||||
"get",
|
||||
"pods",
|
||||
"-A",
|
||||
@@ -915,6 +943,128 @@ def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
|
||||
return p.returncode, msg
|
||||
|
||||
|
||||
def kubectl_get_all_namespaces_wide(
|
||||
*,
|
||||
cluster_name: str,
|
||||
kubeconfig: str | Path,
|
||||
resource: str,
|
||||
timeout: str = "30s",
|
||||
) -> tuple[int, str]:
|
||||
"""
|
||||
``kubectl get <resource> -A -o wide`` — Deployments, StatefulSets, DaemonSets, Service, Ingress и т.д.
|
||||
|
||||
``resource`` — аргумент kubectl (``deployments``, ``pods``, ``svc`` …).
|
||||
"""
|
||||
src = Path(kubeconfig)
|
||||
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
|
||||
p = subprocess.run(
|
||||
[
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(cfg),
|
||||
"get",
|
||||
resource,
|
||||
"-A",
|
||||
"-o",
|
||||
"wide",
|
||||
f"--request-timeout={timeout}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
out = (p.stdout or "").strip()
|
||||
err = (p.stderr or "").strip()
|
||||
msg = out if out else err
|
||||
return p.returncode, msg
|
||||
|
||||
|
||||
def _parse_kubectl_list_json(stdout: str) -> tuple[list[dict[str, Any]] | None, str]:
|
||||
"""Разбор вывода ``kubectl get … -o json`` (Kind List)."""
|
||||
if not (stdout or "").strip():
|
||||
return [], ""
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
return None, f"JSON: {e}"
|
||||
if not isinstance(data, dict):
|
||||
return None, "корень ответа не объект"
|
||||
raw_items = data.get("items")
|
||||
if raw_items is None:
|
||||
return [], ""
|
||||
if not isinstance(raw_items, list):
|
||||
return None, "поле items не список"
|
||||
out: list[dict[str, Any]] = [x for x in raw_items if isinstance(x, dict)]
|
||||
return out, ""
|
||||
|
||||
|
||||
def kubectl_get_json(
|
||||
*,
|
||||
cluster_name: str,
|
||||
kubeconfig: str | Path,
|
||||
resource_args: list[str],
|
||||
timeout: str = "30s",
|
||||
) -> tuple[int, list[dict[str, Any]] | None, str]:
|
||||
"""
|
||||
``kubectl get <resource_args…> -o json``.
|
||||
|
||||
``resource_args`` — например ``["nodes"]`` или ``["pods", "-A"]``, ``["deployments", "-A"]``.
|
||||
Возвращает (код, список items или None, сообщение об ошибке).
|
||||
"""
|
||||
src = Path(kubeconfig)
|
||||
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
|
||||
cmd = [
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(cfg),
|
||||
"get",
|
||||
*resource_args,
|
||||
"-o",
|
||||
"json",
|
||||
f"--request-timeout={timeout}",
|
||||
]
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
out = (p.stdout or "").strip()
|
||||
err = (p.stderr or "").strip()
|
||||
if p.returncode != 0:
|
||||
return p.returncode, None, err or out or f"код {p.returncode}"
|
||||
items, perr = _parse_kubectl_list_json(out)
|
||||
if items is None:
|
||||
return p.returncode, None, perr
|
||||
return 0, items, ""
|
||||
|
||||
|
||||
def kubectl_delete_pod(
|
||||
*,
|
||||
cluster_name: str,
|
||||
kubeconfig: str | Path,
|
||||
namespace: str,
|
||||
pod_name: str,
|
||||
grace_period: int = 30,
|
||||
) -> tuple[int, str]:
|
||||
"""
|
||||
Удаление пода (контроллер при необходимости создаст новый) — «мягкий рестарт».
|
||||
|
||||
``grace_period`` — секунды до SIGKILL (0 — сразу; для системных подов осторожно).
|
||||
"""
|
||||
src = Path(kubeconfig)
|
||||
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
|
||||
cmd = [
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
str(cfg),
|
||||
"delete",
|
||||
"pod",
|
||||
pod_name,
|
||||
"-n",
|
||||
namespace,
|
||||
f"--grace-period={grace_period}",
|
||||
"--request-timeout=60s",
|
||||
]
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
msg = ((p.stdout or "").strip() or (p.stderr or "").strip() or f"код {p.returncode}")[:8000]
|
||||
return p.returncode, msg
|
||||
|
||||
|
||||
def cluster_summary_for_api(name: str) -> dict[str, object]:
|
||||
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
|
||||
from core.provision_log import provision_log_file_path
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-6
@@ -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=<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>.",
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
+121
-7
@@ -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` на `<body>` (по умолчанию `/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://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>` (порт из `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` (если файл есть).
|
||||
|
||||
+190
-6
@@ -1,8 +1,22 @@
|
||||
"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера).
|
||||
|
||||
Kind внутри Docker видит другой адрес apiserver; на хосте нужен 127.0.0.1:<порт>
|
||||
из проброса `docker port <cluster>-control-plane 6443/tcp` (Podman — тот же клиент
|
||||
`docker` к docker-совместимому сокету).
|
||||
Kind внутри Docker видит другой адрес apiserver; для **kubectl на машине пользователя**
|
||||
в ``kubeconfig.host`` подставляется ``https://<клиентский_хост>:<порт>`` (порт из
|
||||
``docker port <cluster>-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-сети (если она есть).
|
||||
|
||||
Стандартное имя контрольной плоскости: ``<cluster>-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
|
||||
|
||||
+23
@@ -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="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||||
status_code=500,
|
||||
)
|
||||
n = cluster_name.strip()
|
||||
if not validate_cluster_name(n):
|
||||
raise HTTPException(status_code=404, detail="Некорректное имя кластера")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"cluster_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:
|
||||
"""Форма создания кластера, прогресс и список последних заданий."""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
+885
-134
File diff suppressed because it is too large
Load Diff
+580
-27
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
<div class="footer-inner">
|
||||
<p class="footer-copyright">
|
||||
© {{ app_title }} ·
|
||||
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Полноэкранный спиннер до первой загрузки overview; скрывается в dashboard.js #}
|
||||
<div
|
||||
id="cluster-page-loading-overlay"
|
||||
class="page-loading-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
aria-label="Загрузка данных кластера"
|
||||
>
|
||||
<div class="page-loading-backdrop" aria-hidden="true"></div>
|
||||
<div class="page-loading-center">
|
||||
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
|
||||
<span class="page-loading-label">Загрузка данных кластера…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
|
||||
<a href="/">Панель</a>
|
||||
<span aria-hidden="true"> / </span>
|
||||
<span>Кластер <code>{{ cluster_name | e }}</code></span>
|
||||
</p>
|
||||
|
||||
<section class="card cluster-detail-hero-card" aria-labelledby="cluster-detail-title">
|
||||
<div class="cluster-detail-hero-layout">
|
||||
<div class="cluster-detail-hero-left">
|
||||
<h1 class="page-title" id="cluster-detail-title">Кластер <code>{{ cluster_name | e }}</code></h1>
|
||||
<div class="cluster-detail-actions-card" aria-label="Панель действий">
|
||||
<div
|
||||
id="cluster-detail-toolbar"
|
||||
class="actions-toolbar cluster-detail-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="Действия над кластером"
|
||||
></div>
|
||||
</div>
|
||||
<p id="cluster-overview-msg" class="msg muted cluster-overview-msg" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<aside class="cluster-detail-hero-meta" aria-label="Сводка кластера">
|
||||
<div id="cluster-detail-meta" class="cluster-detail-meta" aria-live="polite"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="cluster-aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="cluster-donuts-heading">
|
||||
<h2 id="cluster-donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
|
||||
<div id="cluster-page-aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-detail-nodes-card" aria-labelledby="cluster-nodes-heading">
|
||||
<h2 id="cluster-nodes-heading" class="cluster-detail-section-title">Ресурсы узлов</h2>
|
||||
<div
|
||||
id="cluster-page-node-cards"
|
||||
class="cluster-nodes-resources__cards-row cluster-page-node-cards--wide"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-intro-card" aria-labelledby="cluster-k8s-heading">
|
||||
<h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2>
|
||||
<p class="muted cluster-k8s-scope-hint">
|
||||
Данные по <strong>всему кластеру</strong>: для ресурсов в namespace используется
|
||||
<code>kubectl get … -A</code> (все namespace); узлы и список namespace — без фильтра по ns.
|
||||
Ниже каждый тип ресурса — отдельная карточка с тем же запросом, что выполняет API.
|
||||
</p>
|
||||
<p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-nodes-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-nodes-title">Узлы</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get nodes -o json</code></p>
|
||||
<div id="cluster-k8s-nodes-root" class="cluster-k8s-root" role="region" aria-label="Узлы кластера"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ns-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-ns-title">Namespaces</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get namespaces -o json</code></p>
|
||||
<div id="cluster-k8s-namespaces-root" class="cluster-k8s-root" role="region" aria-label="Namespaces"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get pods -A -o json</code></p>
|
||||
<div id="cluster-k8s-pods-root" class="cluster-k8s-root" role="region" aria-label="Поды"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get deployments -A -o json</code></p>
|
||||
<div id="cluster-k8s-deployments-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get statefulsets -A -o json</code></p>
|
||||
<div id="cluster-k8s-statefulsets-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get daemonsets -A -o json</code></p>
|
||||
<div id="cluster-k8s-daemonsets-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get replicasets -A -o json</code></p>
|
||||
<div id="cluster-k8s-replicasets-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get jobs -A -o json</code></p>
|
||||
<div id="cluster-k8s-jobs-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get cronjobs -A -o json</code></p>
|
||||
<div id="cluster-k8s-cronjobs-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get services -A -o json</code></p>
|
||||
<div id="cluster-k8s-services-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get ingresses.networking.k8s.io -A -o json</code></p>
|
||||
<div id="cluster-k8s-ingresses-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2>
|
||||
<p class="muted cluster-k8s-cmd"><code>kubectl get pvc -A -o json</code></p>
|
||||
<div id="cluster-k8s-pvcs-root" class="cluster-k8s-root" role="region" aria-label="PersistentVolumeClaim"></div>
|
||||
</section>
|
||||
|
||||
{% include "partials/dashboard_modals.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
<div
|
||||
id="home-page-loading-overlay"
|
||||
class="page-loading-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
aria-label="Загрузка панели"
|
||||
>
|
||||
<div class="page-loading-backdrop" aria-hidden="true"></div>
|
||||
<div class="page-loading-center">
|
||||
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
|
||||
<span class="page-loading-label">Загрузка панели…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="top-dashboard-row">
|
||||
<section class="card cta-hero-card" aria-labelledby="cta-hero-title">
|
||||
<div class="cta-hero-inner">
|
||||
|
||||
Reference in New Issue
Block a user