Веб-UI кластера: страница деталей, kubectl по карточкам, мета 3 колонки
- Страница /cluster/<имя>: сводка, ресурсы узлов полосами, отдельные карточки на каждый kubectl get … -o json, рестарт пода. - API: overview с блоками k8s_*, POST pods/restart, расширенный набор ресурсов (-A). - Панель: спиннер загрузки, правки дашборда и стилей; документация api_routes, compose и прочие сопутствующие изменения.
This commit is contained in:
@@ -86,7 +86,7 @@ check-docker: _require_runtime ## (с docker/podman) Проверить CLI и c
|
|||||||
@$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; }
|
@$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; }
|
||||||
@echo "$(COMPOSE): OK"
|
@echo "$(COMPOSE): OK"
|
||||||
|
|
||||||
# kubectl и kind в образе; kubeconfig в томе /work/clusters/<CLUSTER>/kubeconfig — kubectl на хосте не нужен.
|
# kubectl в образе; путь к kubeconfig — как у веб-UI (подстановка server через host.docker.internal, см. kubeconfig_patch.py).
|
||||||
kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"]
|
kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"]
|
||||||
@if [ -z "$(CLUSTER)" ]; then \
|
@if [ -z "$(CLUSTER)" ]; then \
|
||||||
echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \
|
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\""; \
|
echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
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
|
build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local
|
||||||
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS)
|
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS)
|
||||||
|
|||||||
@@ -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`** внутри уже запущенного сервиса.
|
**На хост не нужны:** **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://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>`** — см. `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`** без пересборки образа).
|
Смонтированы **сокет** 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`**.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from core.cluster_lifecycle import (
|
from core.cluster_lifecycle import (
|
||||||
KindClusterError,
|
KindClusterError,
|
||||||
@@ -21,6 +27,9 @@ from core.cluster_lifecycle import (
|
|||||||
configured_container_cli,
|
configured_container_cli,
|
||||||
create_cluster_non_interactive,
|
create_cluster_non_interactive,
|
||||||
delete_kind_cluster_and_data,
|
delete_kind_cluster_and_data,
|
||||||
|
kubectl_delete_pod,
|
||||||
|
kubectl_get_json,
|
||||||
|
kubectl_get_all_namespaces_wide,
|
||||||
kubectl_nodes_wide,
|
kubectl_nodes_wide,
|
||||||
kubectl_pods_all_namespaces,
|
kubectl_pods_all_namespaces,
|
||||||
list_registered_kind_clusters,
|
list_registered_kind_clusters,
|
||||||
@@ -32,6 +41,7 @@ from core.cluster_lifecycle import (
|
|||||||
from core.container_resource_stats import (
|
from core.container_resource_stats import (
|
||||||
aggregate_kind_cluster_resources,
|
aggregate_kind_cluster_resources,
|
||||||
collect_kind_clusters_resource_stats,
|
collect_kind_clusters_resource_stats,
|
||||||
|
collect_single_cluster_resource_stats,
|
||||||
running_kind_clusters_mask,
|
running_kind_clusters_mask,
|
||||||
)
|
)
|
||||||
from core.job_store import (
|
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.provision_log import provision_log_file_path, write_cluster_provision_log
|
||||||
from core.kind_guard import kind_cluster_lock
|
from core.kind_guard import kind_cluster_lock
|
||||||
from kind_k8s_paths import clusters_dir
|
from kind_k8s_paths import clusters_dir
|
||||||
|
from kubeconfig_patch import kubeconfig_host_file, patch_kubeconfig_server_for_host
|
||||||
from models.schemas import (
|
from models.schemas import (
|
||||||
AggregateResourcesSummary,
|
AggregateResourcesSummary,
|
||||||
ClusterCreateAccepted,
|
ClusterCreateAccepted,
|
||||||
ClusterCreateRequest,
|
ClusterCreateRequest,
|
||||||
|
ClusterOverviewResponse,
|
||||||
ClusterSummary,
|
ClusterSummary,
|
||||||
ClusterWorkloadsResponse,
|
ClusterWorkloadsResponse,
|
||||||
JobView,
|
JobView,
|
||||||
|
K8sListJsonBlock,
|
||||||
|
PodRestartRequest,
|
||||||
|
PodRestartResponse,
|
||||||
StatsResponse,
|
StatsResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("kind_k8s.api.clusters")
|
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"])
|
router = APIRouter(tags=["clusters"])
|
||||||
|
|
||||||
|
|
||||||
@@ -202,16 +245,56 @@ async def list_clusters() -> list[ClusterSummary]:
|
|||||||
responses={404: {"description": "Файл не найден"}},
|
responses={404: {"description": "Файл не найден"}},
|
||||||
)
|
)
|
||||||
async def download_kubeconfig(name: str) -> FileResponse:
|
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):
|
if not validate_cluster_name(name):
|
||||||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||||
path = clusters_dir() / name / "kubeconfig"
|
n = name.strip()
|
||||||
if not path.is_file():
|
base = clusters_dir() / n
|
||||||
|
primary = base / "kubeconfig"
|
||||||
|
if not primary.is_file():
|
||||||
raise HTTPException(status_code=404, detail="kubeconfig не найден")
|
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(
|
return FileResponse(
|
||||||
path=path,
|
path=str(tmp_path),
|
||||||
filename=f"kubeconfig-{name}.yaml",
|
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=primary,
|
||||||
|
filename=f"kubeconfig-{n}.yaml",
|
||||||
media_type="application/x-yaml",
|
media_type="application/x-yaml",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,8 +333,16 @@ async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
|
|||||||
if not kc.is_file():
|
if not kc.is_file():
|
||||||
return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/")
|
return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/")
|
||||||
|
|
||||||
nodes_rc, nodes_out = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=kc)
|
nodes_rc, nodes_out = await asyncio.to_thread(
|
||||||
pods_rc, pods_out = await asyncio.to_thread(kubectl_pods_all_namespaces, kubeconfig=kc)
|
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(
|
return ClusterWorkloadsResponse(
|
||||||
cluster_name=name,
|
cluster_name=name,
|
||||||
nodes_rc=nodes_rc,
|
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="Детали кластера")
|
@router.get("/clusters/{name}", summary="Детали кластера")
|
||||||
async def get_cluster(name: str) -> dict[str, object]:
|
async def get_cluster(name: str) -> dict[str, object]:
|
||||||
"""Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig)."""
|
"""Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig)."""
|
||||||
@@ -269,7 +523,11 @@ async def get_cluster(name: str) -> dict[str, object]:
|
|||||||
kubectl_rc: int | None = None
|
kubectl_rc: int | None = None
|
||||||
kubectl_msg: str | None = None
|
kubectl_msg: str | None = None
|
||||||
if saved.is_file():
|
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_rc = rc
|
||||||
kubectl_msg = msg
|
kubectl_msg = msg
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from kind_k8s_paths import clusters_dir, data_root
|
from kind_k8s_paths import clusters_dir, data_root
|
||||||
from kindest_node_tags import normalize_tag_v_prefix
|
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")
|
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
|
||||||
|
|
||||||
@@ -398,19 +404,31 @@ def _wait_nodes_timeout_sec() -> int:
|
|||||||
return 300
|
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.
|
Дождаться condition=Ready для всех нод через kubectl wait.
|
||||||
|
|
||||||
|
В контейнере веб-приложения путь к kubeconfig проходит через
|
||||||
|
``kubeconfig_path_for_container_kubectl`` (шлюз хоста + проброшенный порт или имя узла kind).
|
||||||
|
|
||||||
Возвращает (успех, сообщение для лога/UI).
|
Возвращает (успех, сообщение для лога/UI).
|
||||||
"""
|
"""
|
||||||
if timeout_sec is None:
|
if timeout_sec is None:
|
||||||
timeout_sec = _wait_nodes_timeout_sec()
|
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"
|
t = f"{timeout_sec}s"
|
||||||
cmd = [
|
cmd = [
|
||||||
"kubectl",
|
"kubectl",
|
||||||
"--kubeconfig",
|
"--kubeconfig",
|
||||||
str(kubeconfig_path),
|
str(cfg),
|
||||||
"wait",
|
"wait",
|
||||||
"--for=condition=Ready",
|
"--for=condition=Ready",
|
||||||
"nodes",
|
"nodes",
|
||||||
@@ -561,10 +579,14 @@ def create_cluster_non_interactive(
|
|||||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||||
raise KindClusterError("Операция отменена")
|
raise KindClusterError("Операция отменена")
|
||||||
|
|
||||||
|
host_kube_path = kubeconfig_host_file(kube_path.parent)
|
||||||
patched = False
|
patched = False
|
||||||
if should_patch_after_create():
|
if should_patch_after_create():
|
||||||
_progress("Настройка доступа с вашего компьютера", 72)
|
_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():
|
if _cancelled():
|
||||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||||
@@ -574,7 +596,7 @@ def create_cluster_non_interactive(
|
|||||||
nodes_msg: str | None = None
|
nodes_msg: str | None = None
|
||||||
if _wait_nodes_enabled():
|
if _wait_nodes_enabled():
|
||||||
_progress("Ожидание готовности узлов", 82)
|
_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_ready = ok
|
||||||
nodes_msg = msg
|
nodes_msg = msg
|
||||||
if ok:
|
if ok:
|
||||||
@@ -606,6 +628,8 @@ def create_cluster_non_interactive(
|
|||||||
"nodes_ready_message": nodes_msg,
|
"nodes_ready_message": nodes_msg,
|
||||||
"provisioned_from_existing_config": use_existing_config,
|
"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")
|
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
_progress("Завершение", 95)
|
_progress("Завершение", 95)
|
||||||
@@ -872,13 +896,15 @@ def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]
|
|||||||
return False, err[:800], cli
|
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``; возвращает (код, объединённый вывод)."""
|
"""``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(
|
p = subprocess.run(
|
||||||
[
|
[
|
||||||
"kubectl",
|
"kubectl",
|
||||||
"--kubeconfig",
|
"--kubeconfig",
|
||||||
str(kubeconfig),
|
str(cfg),
|
||||||
"get",
|
"get",
|
||||||
"nodes",
|
"nodes",
|
||||||
"-o",
|
"-o",
|
||||||
@@ -894,13 +920,15 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
|
|||||||
return p.returncode, msg
|
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``; сводка подов по кластеру."""
|
"""``kubectl get pods -A``; сводка подов по кластеру."""
|
||||||
|
src = Path(kubeconfig)
|
||||||
|
cfg = kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
|
||||||
p = subprocess.run(
|
p = subprocess.run(
|
||||||
[
|
[
|
||||||
"kubectl",
|
"kubectl",
|
||||||
"--kubeconfig",
|
"--kubeconfig",
|
||||||
str(kubeconfig),
|
str(cfg),
|
||||||
"get",
|
"get",
|
||||||
"pods",
|
"pods",
|
||||||
"-A",
|
"-A",
|
||||||
@@ -915,6 +943,128 @@ def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
|
|||||||
return p.returncode, msg
|
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]:
|
def cluster_summary_for_api(name: str) -> dict[str, object]:
|
||||||
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
|
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
|
||||||
from core.provision_log import provision_log_file_path
|
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 "—"
|
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 "—"
|
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 "—"
|
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 "—"
|
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 "—"
|
disk_l = f"всего {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else "—"
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"aggregate_kind_cluster_resources: узлов=%s cpu_ring=%.1f net_ring=%.1f",
|
"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))
|
blocks.append(KindClusterResources(cluster_name=cluster_name, nodes=stats, note=None))
|
||||||
|
|
||||||
return blocks, 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
|
||||||
|
|||||||
+17
-2
@@ -198,6 +198,16 @@ def _run_interactive() -> None:
|
|||||||
print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}")
|
print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}")
|
||||||
if _in_container():
|
if _in_container():
|
||||||
print(f" Том на хосте: clusters/{result.cluster_name}/ в корне репозитория (рядом с Makefile)")
|
print(f" Том на хосте: clusters/{result.cluster_name}/ в корне репозитория (рядом с Makefile)")
|
||||||
|
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(
|
print(
|
||||||
f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes",
|
f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes",
|
||||||
)
|
)
|
||||||
@@ -205,9 +215,14 @@ def _run_interactive() -> None:
|
|||||||
print(" Проверка без kubectl на хосте: веб-интерфейс (кластер → узлы/поды) или из корня репозитория:")
|
print(" Проверка без kubectl на хосте: веб-интерфейс (кластер → узлы/поды) или из корня репозитория:")
|
||||||
print(f" make docker kubectl CLUSTER={result.cluster_name} # или: make podman kubectl …")
|
print(f" make docker kubectl CLUSTER={result.cluster_name} # или: make podman kubectl …")
|
||||||
print(" (сервис kind-k8s-web должен быть запущен: make docker up).")
|
print(" (сервис kind-k8s-web должен быть запущен: make docker up).")
|
||||||
print(f" Локально при установленном kubectl: kubectl --kubeconfig={result.kubeconfig_path} get nodes")
|
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:
|
if result.kubeconfig_patched_for_host:
|
||||||
print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.")
|
print(" (файл kubeconfig.host — apiserver: localhost или KIND_K8S_KUBECONFIG_CLIENT_HOST и порт Docker.)")
|
||||||
if result.nodes_ready is False and result.nodes_ready_message:
|
if result.nodes_ready is False and result.nodes_ready_message:
|
||||||
print(f" Предупреждение (ожидание нод): {result.nodes_ready_message}", file=sys.stderr)
|
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 /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 /ui` | Редирект **307** на `/` (удобный ярлык). |
|
||||||
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
|
| `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}/kubeconfig` | Скачать файл kubeconfig |
|
||||||
| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) |
|
| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) |
|
||||||
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
|
| 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/` |
|
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
|
||||||
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
|
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
|
||||||
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
|
| 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_ring": 5.8,
|
||||||
"memory_used_ratio_label": "ср. 5.8%",
|
"memory_used_ratio_label": "ср. 5.8%",
|
||||||
"network_ring": 12.5,
|
"network_ring": 12.5,
|
||||||
"network_label": "Σ 1.71 MiB",
|
"network_label": "всего 1.71 MiB",
|
||||||
"disk_ring": 45.0,
|
"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
|
## 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:** некорректное имя кластера.
|
**Ошибка 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}
|
## GET /api/v1/clusters/{name}
|
||||||
|
|
||||||
Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть).
|
Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть).
|
||||||
|
|||||||
+190
-6
@@ -1,8 +1,22 @@
|
|||||||
"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера).
|
"""Правка kubeconfig kind для доступа к API с хоста (после create из контейнера).
|
||||||
|
|
||||||
Kind внутри Docker видит другой адрес apiserver; на хосте нужен 127.0.0.1:<порт>
|
Kind внутри Docker видит другой адрес apiserver; для **kubectl на машине пользователя**
|
||||||
из проброса `docker port <cluster>-control-plane 6443/tcp` (Podman — тот же клиент
|
в ``kubeconfig.host`` подставляется ``https://<клиентский_хост>:<порт>`` (порт из
|
||||||
`docker` к docker-совместимому сокету).
|
``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
|
Сайт: https://devops.org.ru
|
||||||
@@ -12,11 +26,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger("kind_k8s.kubeconfig_patch")
|
logger = logging.getLogger("kind_k8s.kubeconfig_patch")
|
||||||
|
|
||||||
|
# Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения).
|
||||||
|
KUBECONFIG_HOST_BASENAME = "kubeconfig.host"
|
||||||
|
|
||||||
|
|
||||||
def _container_cli() -> str:
|
def _container_cli() -> str:
|
||||||
"""CLI для `port` (обычно docker к сокету Docker или Podman)."""
|
"""CLI для `port` (обычно docker к сокету Docker или Podman)."""
|
||||||
@@ -57,8 +77,19 @@ def _host_bind_for_kubeconfig(host: str) -> str:
|
|||||||
return host
|
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:
|
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()
|
cli = _container_cli()
|
||||||
ctr = _control_plane_container_name(cluster_name)
|
ctr = _control_plane_container_name(cluster_name)
|
||||||
p = subprocess.run(
|
p = subprocess.run(
|
||||||
@@ -126,8 +157,9 @@ def patch_kubeconfig_server_for_host(
|
|||||||
"kubeconfig оставлен как выдал kind (с хоста может не открываться).",
|
"kubeconfig оставлен как выдал kind (с хоста может не открываться).",
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
host, port = hp
|
_bind_host, port = hp
|
||||||
server = f"https://{host}:{port}"
|
client_host = kubeconfig_download_client_hostname()
|
||||||
|
server = f"https://{client_host}:{port}"
|
||||||
cluster_id = _kubeconfig_cluster_name(kube_path, cluster_name)
|
cluster_id = _kubeconfig_cluster_name(kube_path, cluster_name)
|
||||||
p = subprocess.run(
|
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", "да"):
|
if os.environ.get("KIND_K8S_PATCH_KUBECONFIG", "").strip().lower() in ("1", "true", "yes", "да"):
|
||||||
return True
|
return True
|
||||||
return os.environ.get("KIND_K8S_IN_CONTAINER", "").strip() == "1"
|
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 fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from api.v1.router import api_router
|
from api.v1.router import api_router
|
||||||
|
from core.cluster_lifecycle import validate_cluster_name
|
||||||
from core.config import get_settings
|
from core.config import get_settings
|
||||||
|
|
||||||
_BASE = Path(__file__).resolve().parent
|
_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="Создание кластера и задания")
|
@app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания")
|
||||||
async def cluster_create_page(request: Request) -> HTMLResponse:
|
async def cluster_create_page(request: Request) -> HTMLResponse:
|
||||||
"""Форма создания кластера, прогресс и список последних заданий."""
|
"""Форма создания кластера, прогресс и список последних заданий."""
|
||||||
|
|||||||
@@ -140,3 +140,87 @@ class ClusterWorkloadsResponse(BaseModel):
|
|||||||
pods_rc: int | None = None
|
pods_rc: int | None = None
|
||||||
pods_output: str | None = None
|
pods_output: str | None = None
|
||||||
error: 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 {
|
.donut-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
flex-direction: row;
|
||||||
gap: 1rem 1.25rem;
|
flex-wrap: nowrap;
|
||||||
align-items: start;
|
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 {
|
.donut-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -671,34 +678,94 @@ a.btn-cta-large:hover {
|
|||||||
max-width: 40rem;
|
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 {
|
.cluster-nodes-resources__empty {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
font-size: 0.85rem;
|
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;
|
margin-top: 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cluster-create-hero {
|
.cluster-create-hero {
|
||||||
@@ -961,6 +1028,12 @@ html[data-theme="light"] .badge {
|
|||||||
border-color: #15803d;
|
border-color: #15803d;
|
||||||
color: #4ade80;
|
color: #4ade80;
|
||||||
}
|
}
|
||||||
|
/* Светлая тема: насыщенный зелёный текст и фон — иначе «Запущен» сливается с белым карточным фоном */
|
||||||
|
html[data-theme="light"] .badge-ok {
|
||||||
|
border-color: #166534;
|
||||||
|
color: #14532d;
|
||||||
|
background: rgba(22, 101, 52, 0.12);
|
||||||
|
}
|
||||||
.badge-err {
|
.badge-err {
|
||||||
border-color: #b91c1c;
|
border-color: #b91c1c;
|
||||||
color: #f87171;
|
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 {
|
html[data-theme="light"] .readme-section-card.markdown-body th {
|
||||||
background: rgba(59, 130, 246, 0.14);
|
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 #}
|
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block body_extra_class %} dashboard-home-loading{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %}data-dashboard-mode="home"{% endblock %}
|
{% block body_attrs %}data-dashboard-mode="home"{% endblock %}
|
||||||
|
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
@@ -14,6 +16,21 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="top-dashboard-row">
|
||||||
<section class="card cta-hero-card" aria-labelledby="cta-hero-title">
|
<section class="card cta-hero-card" aria-labelledby="cta-hero-title">
|
||||||
<div class="cta-hero-inner">
|
<div class="cta-hero-inner">
|
||||||
|
|||||||
+7
-1
@@ -28,12 +28,18 @@ services:
|
|||||||
# Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000.
|
# Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000.
|
||||||
ports:
|
ports:
|
||||||
- "${KIND_K8S_WEB_PORT:-8080}:6000"
|
- "${KIND_K8S_WEB_PORT:-8080}:6000"
|
||||||
|
# Доступ к apiserver kind с проброшенного порта хоста (kubectl внутри контейнера веб-UI).
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
DOCKER_HOST: unix:///var/run/docker.sock
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
KIND_K8S_IN_CONTAINER: "1"
|
KIND_K8S_IN_CONTAINER: "1"
|
||||||
KIND_K8S_WORKDIR: /work
|
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_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}
|
CONTAINER_CLI: ${CONTAINER_CLI:-docker}
|
||||||
KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-}
|
KIND_K8S_SKIP_VERSION_LIST: ${KIND_K8S_SKIP_VERSION_LIST:-}
|
||||||
KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-}
|
KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-}
|
||||||
|
|||||||
@@ -97,9 +97,13 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
|
|||||||
_EnvPrompt(
|
_EnvPrompt(
|
||||||
"# --- docker-compose.yml: environment сервиса kind-k8s-web ---",
|
"# --- docker-compose.yml: environment сервиса kind-k8s-web ---",
|
||||||
"KIND_K8S_PATCH_KUBECONFIG",
|
"KIND_K8S_PATCH_KUBECONFIG",
|
||||||
"1/true/yes — после create всегда патчить server в kubeconfig на 127.0.0.1:<порт> для доступа с хоста; "
|
"1/true/yes — после create писать clusters/<имя>/kubeconfig.host (server с KIND_K8S_KUBECONFIG_CLIENT_HOST, иначе localhost); "
|
||||||
"0 или пусто — не форсировать (в контейнере compose всё равно может сработать KIND_K8S_IN_CONTAINER). "
|
"основной kubeconfig — как у kind. 0 или пусто — не форсировать. По умолчанию в скрипте: 1 (включено).",
|
||||||
"По умолчанию в скрипте: 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(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."),
|
||||||
_EnvPrompt(
|
_EnvPrompt(
|
||||||
|
|||||||
Reference in New Issue
Block a user