Веб-UI: темы, навигация API, статистика и доработки API
- Шапка: логотип Kubernetes, ссылка на главную, выпадающее меню API (Swagger/ReDoc/Health), переключатель светлой/тёмной темы (localStorage). - Светлая тема в синей гамме; выравнивание кнопки темы в ряду с пилюлями. - Дашборд: единая карточка ошибки health/stats, подсказка Docker/Podman, поле container_cli в GET /stats, total_workers_from_meta всегда число (0 без meta). - Правки кластеров, job_store, compose, документация и частичные шаблоны.
This commit is contained in:
@@ -9,3 +9,5 @@ __pycache__/
|
|||||||
*$py.class
|
*$py.class
|
||||||
clusters/*/
|
clusters/*/
|
||||||
!clusters/.gitkeep
|
!clusters/.gitkeep
|
||||||
|
# История фоновых заданий (восстанавливается после перезапуска контейнера)
|
||||||
|
clusters/kind_k8s_jobs.json
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление.
|
- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление.
|
||||||
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
|
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
|
||||||
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
|
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
|
||||||
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа и длительные команды; для `docker pull` по умолчанию используется **`--progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
|
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
|
||||||
- Уведомления (toast) при успехе/ошибке; в подвале — копирайт и ссылка на **devops.org.ru**.
|
- Уведомления (toast) при успехе/ошибке; в подвале — копирайт и ссылка на **devops.org.ru**.
|
||||||
|
|
||||||
**Шапка:** навигация в виде **пилюль** (стили `.nav-pill`); пункты **Swagger**, **ReDoc** и **Health** открывают страницу в **отдельном именованном окне** (~1240×840), чтобы не уходить с панели (см. скрипт в `base.html`).
|
**Шапка:** навигация в виде **пилюль** (стили `.nav-pill`); пункты **Swagger**, **ReDoc** и **Health** открывают страницу в **отдельном именованном окне** (~1240×840), чтобы не уходить с панели (см. скрипт в `base.html`).
|
||||||
@@ -153,8 +153,9 @@ docker compose exec kind-k8s-web kubectl --kubeconfig=/work/clusters/<имя>/ku
|
|||||||
| **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах |
|
| **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах |
|
||||||
| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Сколько строк журнала хранить в памяти на задание (старые вытесняются); по умолчанию **2500** |
|
| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Сколько строк журнала хранить в памяти на задание (старые вытесняются); по умолчанию **2500** |
|
||||||
| **`KIND_K8S_JOB_API_LOG_MAX_LINES`** | приложение | Сколько строк отдавать в **GET /api/v1/jobs/{id}** (хвост); по умолчанию **5000**, максимум **20000** |
|
| **`KIND_K8S_JOB_API_LOG_MAX_LINES`** | приложение | Сколько строк отдавать в **GET /api/v1/jobs/{id}** (хвост); по умолчанию **5000**, максимум **20000** |
|
||||||
|
| **`KIND_K8S_JOBS_JSON`** | приложение | Путь к JSON с историей заданий; пусто — **`clusters/kind_k8s_jobs.json`** под **`KIND_K8S_WORKDIR`** |
|
||||||
| **`KIND_K8S_STREAM_PTY`** | приложение | **`1`** (по умолчанию) — для `kind` и **podman pull** псевдо-TTY; **`0`** — только pipe |
|
| **`KIND_K8S_STREAM_PTY`** | приложение | **`1`** (по умолчанию) — для `kind` и **podman pull** псевдо-TTY; **`0`** — только pipe |
|
||||||
| **`KIND_K8S_DOCKER_PULL_PLAIN`** | приложение | **`1`** (по умолчанию) — `docker pull --progress=plain` без PTY (построчный прогресс в журнале); **`0`** — обычный pull |
|
| **`KIND_K8S_DOCKER_PULL_PLAIN`** | приложение | **`1`** (по умолчанию) — если в выводе **`docker pull --help`** есть **`--progress`**, используется **`docker pull --progress=plain`** без PTY; иначе обычный pull. **`0`** — никогда не добавлять флаг |
|
||||||
| **`KIND_K8S_README_PATH`** | контейнер / приложение | Абсолютный путь к **README.md** для страницы **`/documentation`**; если пусто — используется `README.md` рядом с каталогом `app/` (в образе: `/opt/kind-k8s/README.md`) |
|
| **`KIND_K8S_README_PATH`** | контейнер / приложение | Абсолютный путь к **README.md** для страницы **`/documentation`**; если пусто — используется `README.md` рядом с каталогом `app/` (в образе: `/opt/kind-k8s/README.md`) |
|
||||||
| **`KIND_K8S_WORKDIR`** | локальный запуск | Корень данных на машине разработчика без compose |
|
| **`KIND_K8S_WORKDIR`** | локальный запуск | Корень данных на машине разработчика без compose |
|
||||||
| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (то же для **`make docker rebuild`**) |
|
| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (то же для **`make docker rebuild`**) |
|
||||||
@@ -176,7 +177,7 @@ make podman up
|
|||||||
| `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI |
|
| `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI |
|
||||||
| `requirements.txt` | pip-зависимости веб-приложения |
|
| `requirements.txt` | pip-зависимости веб-приложения |
|
||||||
| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет |
|
| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет |
|
||||||
| `app/main.py` | FastAPI: дашборд `/`, `/documentation`, редирект `/ui`, монтирование `/static` |
|
| `app/main.py` | FastAPI: главная `/`, создание кластера `/cluster-create`, `/documentation`, редирект `/ui`, монтирование `/static` |
|
||||||
| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) |
|
| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) |
|
||||||
| `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
|
| `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
|
||||||
| `app/models/schemas.py` | Pydantic-схемы запросов/ответов API |
|
| `app/models/schemas.py` | Pydantic-схемы запросов/ответов API |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from core.cluster_lifecycle import (
|
from core.cluster_lifecycle import (
|
||||||
KindClusterError,
|
KindClusterError,
|
||||||
cluster_summary_for_api,
|
cluster_summary_for_api,
|
||||||
|
configured_container_cli,
|
||||||
create_cluster_non_interactive,
|
create_cluster_non_interactive,
|
||||||
delete_kind_cluster_and_data,
|
delete_kind_cluster_and_data,
|
||||||
kubectl_nodes_wide,
|
kubectl_nodes_wide,
|
||||||
@@ -27,7 +28,11 @@ from core.cluster_lifecycle import (
|
|||||||
stop_kind_cluster_containers,
|
stop_kind_cluster_containers,
|
||||||
validate_cluster_name,
|
validate_cluster_name,
|
||||||
)
|
)
|
||||||
from core.container_resource_stats import collect_kind_clusters_resource_stats
|
from core.container_resource_stats import (
|
||||||
|
aggregate_kind_cluster_resources,
|
||||||
|
collect_kind_clusters_resource_stats,
|
||||||
|
running_kind_clusters_mask,
|
||||||
|
)
|
||||||
from core.job_store import (
|
from core.job_store import (
|
||||||
JobRecord,
|
JobRecord,
|
||||||
end_job_tracking,
|
end_job_tracking,
|
||||||
@@ -39,6 +44,7 @@ from core.job_store import (
|
|||||||
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 models.schemas import (
|
from models.schemas import (
|
||||||
|
AggregateResourcesSummary,
|
||||||
ClusterCreateAccepted,
|
ClusterCreateAccepted,
|
||||||
ClusterCreateRequest,
|
ClusterCreateRequest,
|
||||||
ClusterSummary,
|
ClusterSummary,
|
||||||
@@ -100,7 +106,6 @@ def _stats_sync() -> StatsResponse:
|
|||||||
subdirs = sorted(p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith("."))
|
subdirs = sorted(p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith("."))
|
||||||
|
|
||||||
total_workers = 0
|
total_workers = 0
|
||||||
counted = False
|
|
||||||
for name in subdirs:
|
for name in subdirs:
|
||||||
meta = read_meta_json(name)
|
meta = read_meta_json(name)
|
||||||
if not meta:
|
if not meta:
|
||||||
@@ -110,7 +115,6 @@ def _stats_sync() -> StatsResponse:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
total_workers += int(w)
|
total_workers += int(w)
|
||||||
counted = True
|
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -118,15 +122,22 @@ def _stats_sync() -> StatsResponse:
|
|||||||
failed = sum(1 for j in jobs if j.status == "failed")
|
failed = sum(1 for j in jobs if j.status == "failed")
|
||||||
|
|
||||||
res_blocks, res_err = collect_kind_clusters_resource_stats()
|
res_blocks, res_err = collect_kind_clusters_resource_stats()
|
||||||
|
agg: AggregateResourcesSummary
|
||||||
|
if res_err:
|
||||||
|
agg = AggregateResourcesSummary()
|
||||||
|
else:
|
||||||
|
agg = aggregate_kind_cluster_resources(res_blocks)
|
||||||
|
|
||||||
return StatsResponse(
|
return StatsResponse(
|
||||||
|
container_cli=configured_container_cli(),
|
||||||
kind_clusters_count=len(kind_names),
|
kind_clusters_count=len(kind_names),
|
||||||
local_cluster_dirs_count=len(subdirs),
|
local_cluster_dirs_count=len(subdirs),
|
||||||
total_workers_from_meta=total_workers if counted else None,
|
total_workers_from_meta=total_workers,
|
||||||
jobs_total=len(jobs),
|
jobs_total=len(jobs),
|
||||||
jobs_recent_failed=failed,
|
jobs_recent_failed=failed,
|
||||||
cluster_resources=res_blocks,
|
cluster_resources=res_blocks,
|
||||||
cluster_resources_error=res_err,
|
cluster_resources_error=res_err,
|
||||||
|
aggregate_cluster_resources=agg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +174,7 @@ async def list_clusters() -> list[ClusterSummary]:
|
|||||||
if cdir.is_dir():
|
if cdir.is_dir():
|
||||||
dir_names = {p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith(".")}
|
dir_names = {p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith(".")}
|
||||||
all_names = sorted(kind_names | dir_names)
|
all_names = sorted(kind_names | dir_names)
|
||||||
|
running_map = running_kind_clusters_mask(all_names)
|
||||||
out: list[ClusterSummary] = []
|
out: list[ClusterSummary] = []
|
||||||
for name in all_names:
|
for name in all_names:
|
||||||
summary = cluster_summary_for_api(name)
|
summary = cluster_summary_for_api(name)
|
||||||
@@ -170,6 +182,7 @@ async def list_clusters() -> list[ClusterSummary]:
|
|||||||
ClusterSummary(
|
ClusterSummary(
|
||||||
name=str(summary["name"]),
|
name=str(summary["name"]),
|
||||||
registered_in_kind=bool(summary["registered_in_kind"]),
|
registered_in_kind=bool(summary["registered_in_kind"]),
|
||||||
|
kind_nodes_running=bool(running_map.get(name, False)),
|
||||||
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
|
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
|
||||||
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
|
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -295,14 +295,49 @@ def _run_checked_stream(
|
|||||||
|
|
||||||
def _docker_pull_plain_progress_enabled() -> bool:
|
def _docker_pull_plain_progress_enabled() -> bool:
|
||||||
"""
|
"""
|
||||||
``docker pull --progress plain`` — построчный текст без TTY-ANSI; удобно с pipe (без PTY).
|
Разрешить попытку ``docker pull --progress=plain`` (если CLI это поддерживает).
|
||||||
|
|
||||||
Отключить: ``KIND_K8S_DOCKER_PULL_PLAIN=0`` (если старая версия docker не знает флаг).
|
Полностью отключить: ``KIND_K8S_DOCKER_PULL_PLAIN=0``.
|
||||||
"""
|
"""
|
||||||
v = (os.environ.get("KIND_K8S_DOCKER_PULL_PLAIN") or "1").strip().lower()
|
v = (os.environ.get("KIND_K8S_DOCKER_PULL_PLAIN") or "1").strip().lower()
|
||||||
return v not in ("0", "false", "no", "off", "нет")
|
return v not in ("0", "false", "no", "off", "нет")
|
||||||
|
|
||||||
|
|
||||||
|
# Кэш: есть ли в выводе ``docker pull --help`` опция ``--progress`` (в старых CLI её нет).
|
||||||
|
_docker_pull_help_has_progress: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_pull_cli_supports_progress_flag() -> bool:
|
||||||
|
"""
|
||||||
|
Узнать по справке, объявляет ли данный ``docker`` флаг ``--progress`` для ``pull``.
|
||||||
|
|
||||||
|
Без этого на старых клиентах в журнал попадало «unknown flag: --progress», а код выхода часто **1**,
|
||||||
|
а не 125 — повтор без флага не срабатывал.
|
||||||
|
"""
|
||||||
|
global _docker_pull_help_has_progress
|
||||||
|
if _docker_pull_help_has_progress is not None:
|
||||||
|
return _docker_pull_help_has_progress
|
||||||
|
exe = shutil.which("docker")
|
||||||
|
if not exe:
|
||||||
|
_docker_pull_help_has_progress = False
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
[exe, "pull", "--help"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=12,
|
||||||
|
)
|
||||||
|
blob = (p.stdout or "") + (p.stderr or "")
|
||||||
|
_docker_pull_help_has_progress = "--progress" in blob
|
||||||
|
if not _docker_pull_help_has_progress:
|
||||||
|
logger.info("docker pull: в справке нет --progress — используем обычный pull (PTY по KIND_K8S_STREAM_PTY)")
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as e:
|
||||||
|
logger.warning("Не удалось выполнить docker pull --help (%s), без флага --progress", e)
|
||||||
|
_docker_pull_help_has_progress = False
|
||||||
|
return _docker_pull_help_has_progress
|
||||||
|
|
||||||
|
|
||||||
def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], job_id: str | None) -> None:
|
def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], job_id: str | None) -> None:
|
||||||
"""
|
"""
|
||||||
Скачать образ узлов через ``docker pull`` / ``podman pull`` с выводом в журнал задания.
|
Скачать образ узлов через ``docker pull`` / ``podman pull`` с выводом в журнал задания.
|
||||||
@@ -316,7 +351,12 @@ def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], jo
|
|||||||
"Скачивание образа недоступно: не найдена программа для работы с контейнерами (docker/podman).",
|
"Скачивание образа недоступно: не найдена программа для работы с контейнерами (docker/podman).",
|
||||||
exit_code=127,
|
exit_code=127,
|
||||||
)
|
)
|
||||||
if cli == "docker" and _docker_pull_plain_progress_enabled():
|
use_docker_plain = (
|
||||||
|
cli == "docker"
|
||||||
|
and _docker_pull_plain_progress_enabled()
|
||||||
|
and _docker_pull_cli_supports_progress_flag()
|
||||||
|
)
|
||||||
|
if use_docker_plain:
|
||||||
try:
|
try:
|
||||||
_run_checked_stream(
|
_run_checked_stream(
|
||||||
[cli, "pull", "--progress=plain", image],
|
[cli, "pull", "--progress=plain", image],
|
||||||
@@ -326,16 +366,12 @@ def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], jo
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
except KindClusterError as e:
|
except KindClusterError as e:
|
||||||
# У docker CLI код 125 — «неверная команда/флаг»; тогда пробуем pull без plain.
|
if e.exit_code == 130:
|
||||||
err_low = str(e).lower()
|
raise
|
||||||
if e.exit_code == 125 or any(
|
# На всякий случай, если справка устарела или другой код ошибки.
|
||||||
x in err_low
|
logger.warning("Повтор docker pull без --progress=plain (код %s): %s", e.exit_code, e)
|
||||||
for x in ("progress", "unknown flag", "bad flag", "invalid option", "unknown shorthand")
|
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
||||||
):
|
return
|
||||||
logger.warning("Повтор docker pull без --progress=plain: %s", e)
|
|
||||||
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
|
||||||
return
|
|
||||||
raise
|
|
||||||
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
||||||
|
|
||||||
|
|
||||||
@@ -800,6 +836,14 @@ def _container_cli_bin() -> str:
|
|||||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||||
|
|
||||||
|
|
||||||
|
def configured_container_cli() -> str:
|
||||||
|
"""Активное имя CLI из ``CONTAINER_CLI`` (docker/podman).
|
||||||
|
|
||||||
|
Единый источник для проверки движка, сбора ``docker stats``/``podman stats`` и поля ``container_cli`` в GET /stats.
|
||||||
|
"""
|
||||||
|
return _container_cli_bin()
|
||||||
|
|
||||||
|
|
||||||
def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]:
|
def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]:
|
||||||
"""
|
"""
|
||||||
Проверить доступ к движку контейнеров (``docker info`` / ``podman info``).
|
Проверить доступ к движку контейнеров (``docker info`` / ``podman info``).
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters
|
from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters
|
||||||
from models.schemas import KindClusterResources, KindNodeResourceStat
|
from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat
|
||||||
|
|
||||||
logger = logging.getLogger("kind_k8s.container_resource_stats")
|
logger = logging.getLogger("kind_k8s.container_resource_stats")
|
||||||
|
|
||||||
@@ -42,6 +43,25 @@ def _list_running_container_names(cli: str) -> set[str]:
|
|||||||
return {n.strip() for n in (p.stdout or "").splitlines() if n.strip()}
|
return {n.strip() for n in (p.stdout or "").splitlines() if n.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def running_kind_clusters_mask(cluster_names: list[str]) -> dict[str, bool]:
|
||||||
|
"""
|
||||||
|
Один вызов ``<cli> ps``: для каждого имени кластера — есть ли запущенный контейнер-узел
|
||||||
|
с префиксом ``{имя}-`` (как в ``collect_kind_clusters_resource_stats``).
|
||||||
|
"""
|
||||||
|
out: dict[str, bool] = {n: False for n in cluster_names}
|
||||||
|
if not cluster_names:
|
||||||
|
return out
|
||||||
|
cli = _container_cli_bin()
|
||||||
|
if not shutil.which(cli):
|
||||||
|
logger.debug("running_kind_clusters_mask: CLI «%s» не в PATH", cli)
|
||||||
|
return out
|
||||||
|
running = _list_running_container_names(cli)
|
||||||
|
for name in cluster_names:
|
||||||
|
pfx = f"{name}-"
|
||||||
|
out[name] = any(c.startswith(pfx) for c in running)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _all_container_names_ps_a(cli: str) -> tuple[list[str], str | None]:
|
def _all_container_names_ps_a(cli: str) -> tuple[list[str], str | None]:
|
||||||
"""Все имена контейнеров (``ps -a``). При ошибке — ([], сообщение)."""
|
"""Все имена контейнеров (``ps -a``). При ошибке — ([], сообщение)."""
|
||||||
p = subprocess.run(
|
p = subprocess.run(
|
||||||
@@ -67,6 +87,164 @@ def _parse_pids(raw: str) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Множители для суффиксов Docker/Podman (kB/MB — десятичные, KiB/MiB — двоичные).
|
||||||
|
_SUFFIX_TO_BYTES: dict[str, float] = {
|
||||||
|
"b": 1.0,
|
||||||
|
"kb": 1e3,
|
||||||
|
"kib": 1024.0,
|
||||||
|
"mb": 1e6,
|
||||||
|
"mib": 1024.0**2,
|
||||||
|
"gb": 1e9,
|
||||||
|
"gib": 1024.0**3,
|
||||||
|
"tb": 1e12,
|
||||||
|
"tib": 1024.0**4,
|
||||||
|
"pb": 1e15,
|
||||||
|
"pib": 1024.0**5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_docker_size_bytes(chunk: str) -> float | None:
|
||||||
|
"""Разбор одного значения вроде ``512MiB``, ``7.7GiB``, ``800kB`` в байты."""
|
||||||
|
t = chunk.strip().lower().replace(" ", "")
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
m = re.match(r"^([\d.]+)([a-z]+)$", t)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
val = float(m.group(1))
|
||||||
|
suf = m.group(2)
|
||||||
|
mult = _SUFFIX_TO_BYTES.get(suf)
|
||||||
|
if mult is None:
|
||||||
|
return None
|
||||||
|
return val * mult
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_percent_value(raw: str | None) -> float | None:
|
||||||
|
"""Число из строки вида ``12.34%``."""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
m = re.match(r"^\s*([\d.]+)\s*%", raw.strip())
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return float(m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_used_ratio_percent(memory_usage: str | None) -> float | None:
|
||||||
|
"""Процент занятой памяти: левая часть MemUsage / правая (лимит контейнера)."""
|
||||||
|
if not memory_usage or "/" not in memory_usage:
|
||||||
|
return None
|
||||||
|
left, right = memory_usage.split("/", 1)
|
||||||
|
used = _parse_docker_size_bytes(left.strip())
|
||||||
|
limit = _parse_docker_size_bytes(right.strip())
|
||||||
|
if used is None or limit is None or limit <= 0:
|
||||||
|
return None
|
||||||
|
return min(100.0, 100.0 * used / limit)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_io_pair_sum_bytes(raw: str | None) -> float | None:
|
||||||
|
"""Сумма байт по обеим частям NetIO/BlockIO до и после ``/`` (чтение + запись)."""
|
||||||
|
if not raw or "/" not in raw:
|
||||||
|
return None
|
||||||
|
a, b = raw.split("/", 1)
|
||||||
|
ba = _parse_docker_size_bytes(a.strip())
|
||||||
|
bb = _parse_docker_size_bytes(b.strip())
|
||||||
|
if ba is None and bb is None:
|
||||||
|
return None
|
||||||
|
return (ba or 0.0) + (bb or 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_bytes_ru(n: float) -> str:
|
||||||
|
"""Краткая подпись объёма для UI."""
|
||||||
|
if n <= 0:
|
||||||
|
return "0 B"
|
||||||
|
if n < 1024:
|
||||||
|
return f"{n:.0f} B"
|
||||||
|
for thresh, suffix in (
|
||||||
|
(1024.0**4, "TiB"),
|
||||||
|
(1024.0**3, "GiB"),
|
||||||
|
(1024.0**2, "MiB"),
|
||||||
|
(1024.0, "KiB"),
|
||||||
|
):
|
||||||
|
if n >= thresh:
|
||||||
|
v = n / thresh
|
||||||
|
s = f"{v:.2f}".rstrip("0").rstrip(".")
|
||||||
|
return f"{s} {suffix}"
|
||||||
|
return f"{n:.0f} B"
|
||||||
|
|
||||||
|
|
||||||
|
def _io_ring_percent(total_bytes: float, ref_bytes: float) -> float:
|
||||||
|
"""Нормализация суммарного I/O в 0–100 для кольца (ref ≈ «полное кольцо»)."""
|
||||||
|
if total_bytes <= 0 or ref_bytes <= 0:
|
||||||
|
return 0.0
|
||||||
|
return min(100.0, 100.0 * total_bytes / ref_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_kind_cluster_resources(blocks: list[KindClusterResources]) -> AggregateResourcesSummary:
|
||||||
|
"""
|
||||||
|
Средние и суммы по всем узлам из ``cluster_resources`` для донатов на главной.
|
||||||
|
|
||||||
|
Кольца сети/диска — условная шкала относительно порога (8 GiB суммарного I/O ≈ 100%).
|
||||||
|
"""
|
||||||
|
nodes: list[KindNodeResourceStat] = []
|
||||||
|
for b in blocks:
|
||||||
|
nodes.extend(b.nodes)
|
||||||
|
n = len(nodes)
|
||||||
|
if n == 0:
|
||||||
|
logger.debug("aggregate_kind_cluster_resources: узлов нет")
|
||||||
|
return AggregateResourcesSummary()
|
||||||
|
|
||||||
|
cpus = [_parse_percent_value(x.cpu_percent) for x in nodes]
|
||||||
|
mem_pcts = [_parse_percent_value(x.memory_percent) for x in nodes]
|
||||||
|
mem_ratios = [_memory_used_ratio_percent(x.memory_usage) for x in nodes]
|
||||||
|
nets = [_parse_io_pair_sum_bytes(x.net_io) for x in nodes]
|
||||||
|
blks = [_parse_io_pair_sum_bytes(x.block_io) for x in nodes]
|
||||||
|
|
||||||
|
def avg(vals: list[float | None]) -> float | None:
|
||||||
|
xs = [v for v in vals if v is not None]
|
||||||
|
if not xs:
|
||||||
|
return None
|
||||||
|
return sum(xs) / len(xs)
|
||||||
|
|
||||||
|
av_cpu = avg(cpus)
|
||||||
|
av_mem_pct = avg(mem_pcts)
|
||||||
|
av_mem_ratio = avg(mem_ratios)
|
||||||
|
sum_net = sum(v for v in nets if v is not None)
|
||||||
|
sum_blk = sum(v for v in blks if v is not None)
|
||||||
|
|
||||||
|
ref_io = 8.0 * 1024.0**3
|
||||||
|
cpu_ring = min(100.0, av_cpu) if av_cpu is not None else 0.0
|
||||||
|
mem_ring = min(100.0, av_mem_pct) if av_mem_pct is not None else 0.0
|
||||||
|
ratio_ring = min(100.0, av_mem_ratio) if av_mem_ratio is not None else 0.0
|
||||||
|
net_ring = _io_ring_percent(sum_net, ref_io)
|
||||||
|
disk_ring = _io_ring_percent(sum_blk, ref_io)
|
||||||
|
|
||||||
|
cpu_l = f"ср. {av_cpu:.1f}%" if av_cpu is not None else "—"
|
||||||
|
mem_l = f"ср. {av_mem_pct:.1f}%" if av_mem_pct is not None else "—"
|
||||||
|
ratio_l = f"ср. {av_mem_ratio:.1f}%" if av_mem_ratio is not None else "—"
|
||||||
|
net_l = f"Σ {_fmt_bytes_ru(sum_net)}" if sum_net > 0 else "—"
|
||||||
|
disk_l = f"Σ {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else "—"
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"aggregate_kind_cluster_resources: узлов=%s cpu_ring=%.1f net_ring=%.1f",
|
||||||
|
n,
|
||||||
|
cpu_ring,
|
||||||
|
net_ring,
|
||||||
|
)
|
||||||
|
return AggregateResourcesSummary(
|
||||||
|
nodes_count=n,
|
||||||
|
cpu_ring=cpu_ring,
|
||||||
|
cpu_label=cpu_l,
|
||||||
|
memory_percent_ring=mem_ring,
|
||||||
|
memory_percent_label=mem_l,
|
||||||
|
memory_used_ratio_ring=ratio_ring,
|
||||||
|
memory_used_ratio_label=ratio_l,
|
||||||
|
network_ring=net_ring,
|
||||||
|
network_label=net_l,
|
||||||
|
disk_ring=disk_ring,
|
||||||
|
disk_label=disk_l,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _stats_for_container_names(cli: str, names: list[str]) -> list[KindNodeResourceStat]:
|
def _stats_for_container_names(cli: str, names: list[str]) -> list[KindNodeResourceStat]:
|
||||||
"""Вызов ``<cli> stats --no-stream`` для списка имён (только запущенные)."""
|
"""Вызов ``<cli> stats --no-stream`` для списка имён (только запущенные)."""
|
||||||
if not names:
|
if not names:
|
||||||
|
|||||||
+158
-6
@@ -1,6 +1,8 @@
|
|||||||
"""Хранилище фоновых заданий (создание кластера) в памяти процесса.
|
"""Хранилище фоновых заданий (создание/старт/стоп кластера).
|
||||||
|
|
||||||
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
|
Снимок заданий сохраняется в JSON в каталоге ``clusters/`` (том на хосте), чтобы после
|
||||||
|
перезапуска контейнера история не терялась. Записи ``queued``/``running`` при старте
|
||||||
|
помечаются как ``failed`` — рабочий процесс уже не существует.
|
||||||
|
|
||||||
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
|
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
@@ -20,13 +23,19 @@ import uuid
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Literal
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
|
from kind_k8s_paths import clusters_dir
|
||||||
|
|
||||||
logger = logging.getLogger("kind_k8s.job_store")
|
logger = logging.getLogger("kind_k8s.job_store")
|
||||||
|
|
||||||
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
|
# Лимит записей в памяти и в файле (dev-инструмент; старые задания вытесняются)
|
||||||
_MAX_JOBS = 200
|
_MAX_JOBS = 200
|
||||||
|
|
||||||
|
# Версия формата JSON на диске
|
||||||
|
_JOBS_FILE_VERSION = 1
|
||||||
|
|
||||||
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||||
|
|
||||||
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
|
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
|
||||||
@@ -40,6 +49,19 @@ _process_lock = threading.Lock()
|
|||||||
_active_subprocess_by_job: dict[str, subprocess.Popen] = {}
|
_active_subprocess_by_job: dict[str, subprocess.Popen] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _jobs_file_path() -> Path:
|
||||||
|
"""
|
||||||
|
Путь к JSON с историей заданий.
|
||||||
|
|
||||||
|
Переопределение: ``KIND_K8S_JOBS_JSON`` (абсолютный или относительный путь).
|
||||||
|
По умолчанию: ``<clusters_dir>/kind_k8s_jobs.json``.
|
||||||
|
"""
|
||||||
|
override = (os.environ.get("KIND_K8S_JOBS_JSON") or "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
return clusters_dir() / "kind_k8s_jobs.json"
|
||||||
|
|
||||||
|
|
||||||
def _max_job_log_lines() -> int:
|
def _max_job_log_lines() -> int:
|
||||||
# Буфер в памяти: старые строки вытесняются; по умолчанию 2500 — виден почти весь типичный лог.
|
# Буфер в памяти: старые строки вытесняются; по умолчанию 2500 — виден почти весь типичный лог.
|
||||||
raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "2500").strip()
|
raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "2500").strip()
|
||||||
@@ -176,12 +198,135 @@ class JobRecord:
|
|||||||
log_lines: list[str] = field(default_factory=list)
|
log_lines: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_to_dict(rec: JobRecord) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"job_id": rec.job_id,
|
||||||
|
"kind": rec.kind,
|
||||||
|
"status": rec.status,
|
||||||
|
"cluster_name": rec.cluster_name,
|
||||||
|
"created_at_utc": rec.created_at_utc,
|
||||||
|
"message": rec.message,
|
||||||
|
"result": rec.result,
|
||||||
|
"log_lines": list(rec.log_lines),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_to_record(d: Any) -> JobRecord | None:
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return None
|
||||||
|
jid = d.get("job_id")
|
||||||
|
kind = d.get("kind")
|
||||||
|
status = d.get("status")
|
||||||
|
if not isinstance(jid, str) or not jid or not isinstance(kind, str) or not kind.strip():
|
||||||
|
return None
|
||||||
|
if status not in ("queued", "running", "success", "failed", "cancelled"):
|
||||||
|
return None
|
||||||
|
created = d.get("created_at_utc")
|
||||||
|
if not isinstance(created, str) or not created:
|
||||||
|
return None
|
||||||
|
cn = d.get("cluster_name")
|
||||||
|
if cn is None:
|
||||||
|
cluster_name = None
|
||||||
|
elif isinstance(cn, str):
|
||||||
|
cluster_name = cn
|
||||||
|
else:
|
||||||
|
cluster_name = str(cn)
|
||||||
|
msg = d.get("message")
|
||||||
|
if msg is None:
|
||||||
|
message = None
|
||||||
|
elif isinstance(msg, str):
|
||||||
|
message = msg
|
||||||
|
else:
|
||||||
|
message = str(msg)
|
||||||
|
result = d.get("result")
|
||||||
|
if result is not None and not isinstance(result, dict):
|
||||||
|
result = None
|
||||||
|
lines = d.get("log_lines")
|
||||||
|
log_lines: list[str] = []
|
||||||
|
if isinstance(lines, list):
|
||||||
|
log_lines = [str(x) for x in lines]
|
||||||
|
return JobRecord(
|
||||||
|
job_id=jid,
|
||||||
|
kind=kind,
|
||||||
|
status=cast(JobStatus, status),
|
||||||
|
cluster_name=cluster_name,
|
||||||
|
created_at_utc=created,
|
||||||
|
message=message,
|
||||||
|
result=result,
|
||||||
|
log_lines=log_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write_jobs_file(path: Path, jobs: list[dict[str, Any]]) -> None:
|
||||||
|
"""Запись JSON атомарно (temp + replace)."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"version": _JOBS_FILE_VERSION, "jobs": jobs}
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||||
|
tmp.write_text(text, encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
class JobStore:
|
class JobStore:
|
||||||
"""Потокобезопасное (asyncio) хранилище заданий."""
|
"""Потокобезопасное (asyncio) хранилище заданий с персистентностью в JSON."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._jobs: dict[str, JobRecord] = {}
|
self._jobs: dict[str, JobRecord] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._load_from_disk_sync()
|
||||||
|
|
||||||
|
def _load_from_disk_sync(self) -> None:
|
||||||
|
path = _jobs_file_path()
|
||||||
|
if not path.is_file():
|
||||||
|
logger.debug("Файл истории заданий отсутствует: %s", path)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("Не удалось прочитать историю заданий %s: %s", path, e)
|
||||||
|
return
|
||||||
|
jobs_raw = data.get("jobs") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(jobs_raw, list):
|
||||||
|
logger.warning("Некорректный формат истории заданий (ожидался ключ jobs или массив)")
|
||||||
|
return
|
||||||
|
|
||||||
|
interrupt_msg = "Прервано перезапуском сервиса (задание не было завершено)."
|
||||||
|
loaded: list[JobRecord] = []
|
||||||
|
for item in jobs_raw:
|
||||||
|
rec = _dict_to_record(item)
|
||||||
|
if rec is None:
|
||||||
|
continue
|
||||||
|
if rec.status in ("queued", "running"):
|
||||||
|
rec = JobRecord(
|
||||||
|
job_id=rec.job_id,
|
||||||
|
kind=rec.kind,
|
||||||
|
status="failed",
|
||||||
|
cluster_name=rec.cluster_name,
|
||||||
|
created_at_utc=rec.created_at_utc,
|
||||||
|
message=interrupt_msg,
|
||||||
|
result=rec.result,
|
||||||
|
log_lines=rec.log_lines,
|
||||||
|
)
|
||||||
|
loaded.append(rec)
|
||||||
|
|
||||||
|
loaded.sort(key=lambda r: r.created_at_utc)
|
||||||
|
if len(loaded) > _MAX_JOBS:
|
||||||
|
loaded = loaded[-_MAX_JOBS:]
|
||||||
|
self._jobs = {r.job_id: r for r in loaded}
|
||||||
|
logger.info("Восстановлено заданий из %s: %s", path, len(self._jobs))
|
||||||
|
|
||||||
|
async def _persist_async(self) -> None:
|
||||||
|
"""Сохранить текущий снимок заданий на диск (вне удержания lock во время записи)."""
|
||||||
|
async with self._lock:
|
||||||
|
items = sorted(self._jobs.values(), key=lambda r: r.created_at_utc)
|
||||||
|
snapshot = [_record_to_dict(r) for r in items]
|
||||||
|
path = _jobs_file_path()
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_atomic_write_jobs_file, path, snapshot)
|
||||||
|
logger.debug("История заданий записана: %s (%s записей)", path, len(snapshot))
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Не удалось сохранить историю заданий в %s: %s", path, e)
|
||||||
|
|
||||||
async def create_job(self, kind: str, *, cluster_name: str | None) -> JobRecord:
|
async def create_job(self, kind: str, *, cluster_name: str | None) -> JobRecord:
|
||||||
"""Зарегистрировать задание в статусе ``queued``."""
|
"""Зарегистрировать задание в статусе ``queued``."""
|
||||||
@@ -203,6 +348,7 @@ class JobStore:
|
|||||||
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
|
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
|
||||||
begin_job_tracking(jid)
|
begin_job_tracking(jid)
|
||||||
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
|
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
|
||||||
|
await self._persist_async()
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
async def set_running(
|
async def set_running(
|
||||||
@@ -217,6 +363,7 @@ class JobStore:
|
|||||||
self._jobs[job_id].status = "running"
|
self._jobs[job_id].status = "running"
|
||||||
self._jobs[job_id].message = None
|
self._jobs[job_id].message = None
|
||||||
set_progress_sync(job_id, stage, percent)
|
set_progress_sync(job_id, stage, percent)
|
||||||
|
await self._persist_async()
|
||||||
|
|
||||||
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
|
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
|
||||||
logs = take_logs_finalize_sync(job_id)
|
logs = take_logs_finalize_sync(job_id)
|
||||||
@@ -227,6 +374,7 @@ class JobStore:
|
|||||||
self._jobs[job_id].message = message
|
self._jobs[job_id].message = message
|
||||||
self._jobs[job_id].log_lines = logs
|
self._jobs[job_id].log_lines = logs
|
||||||
set_progress_sync(job_id, "Готово", 100)
|
set_progress_sync(job_id, "Готово", 100)
|
||||||
|
await self._persist_async()
|
||||||
|
|
||||||
async def set_failed(self, job_id: str, message: str) -> None:
|
async def set_failed(self, job_id: str, message: str) -> None:
|
||||||
logs = take_logs_finalize_sync(job_id)
|
logs = take_logs_finalize_sync(job_id)
|
||||||
@@ -236,6 +384,7 @@ class JobStore:
|
|||||||
self._jobs[job_id].message = message
|
self._jobs[job_id].message = message
|
||||||
self._jobs[job_id].log_lines = logs
|
self._jobs[job_id].log_lines = logs
|
||||||
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
|
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
|
||||||
|
await self._persist_async()
|
||||||
|
|
||||||
async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None:
|
async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None:
|
||||||
logs = take_logs_finalize_sync(job_id)
|
logs = take_logs_finalize_sync(job_id)
|
||||||
@@ -245,6 +394,7 @@ class JobStore:
|
|||||||
self._jobs[job_id].message = message
|
self._jobs[job_id].message = message
|
||||||
self._jobs[job_id].log_lines = logs
|
self._jobs[job_id].log_lines = logs
|
||||||
logger.info("Задание %s отменено: %s", job_id, message)
|
logger.info("Задание %s отменено: %s", job_id, message)
|
||||||
|
await self._persist_async()
|
||||||
|
|
||||||
async def get(self, job_id: str) -> JobRecord | None:
|
async def get(self, job_id: str) -> JobRecord | None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@@ -276,7 +426,9 @@ class JobStore:
|
|||||||
del self._jobs[jid]
|
del self._jobs[jid]
|
||||||
for jid in to_remove:
|
for jid in to_remove:
|
||||||
end_job_tracking(jid)
|
end_job_tracking(jid)
|
||||||
return len(to_remove)
|
n = len(to_remove)
|
||||||
|
await self._persist_async()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
# Синглтон на процесс uvicorn
|
# Синглтон на процесс uvicorn
|
||||||
|
|||||||
+25
-5
@@ -56,11 +56,12 @@
|
|||||||
|
|
||||||
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
|
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
|
||||||
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
|
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
|
||||||
|
- Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**.
|
||||||
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
||||||
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
|
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
|
||||||
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
|
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
|
||||||
- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются.
|
- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются.
|
||||||
- Для **`docker pull`** по умолчанию: **`--progress=plain`** и вывод **без PTY** (`KIND_K8S_DOCKER_PULL_PLAIN=1`) — в журнале идут строки с процентами/слоями. Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды.
|
- Для **`docker pull`**: если в справке **`docker pull --help`** объявлен флаг **`--progress`**, при **`KIND_K8S_DOCKER_PULL_PLAIN=1`** вызывается **`--progress=plain`** без PTY; на старых CLI флаг не передаётся (нет строки «unknown flag» в журнале). Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды.
|
||||||
- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов).
|
- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов).
|
||||||
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
|
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
|
||||||
|
|
||||||
@@ -163,12 +164,13 @@ Accept: text/markdown
|
|||||||
|
|
||||||
## GET /api/v1/stats
|
## GET /api/v1/stats
|
||||||
|
|
||||||
Сводная статистика для дашборда и **метрики запущенных узлов kind** (один вызов `docker stats` / `podman stats` на набор имён контейнеров `имя-control-plane`, `имя-worker`…).
|
Сводная статистика для дашборда и **метрики запущенных узлов kind** (один вызов `docker stats` / `podman stats` на набор имён контейнеров `имя-control-plane`, `имя-worker`…). Поле **`container_cli`** совпадает с **`CONTAINER_CLI`** в среде приложения (`docker` или `podman`) — по нему UI показывает, что **все** узлы kind и сбор метрик идут через выбранный CLI.
|
||||||
|
|
||||||
**Пример ответа 200:**
|
**Пример ответа 200:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"container_cli": "docker",
|
||||||
"kind_clusters_count": 1,
|
"kind_clusters_count": 1,
|
||||||
"local_cluster_dirs_count": 1,
|
"local_cluster_dirs_count": 1,
|
||||||
"total_workers_from_meta": 2,
|
"total_workers_from_meta": 2,
|
||||||
@@ -200,15 +202,30 @@ Accept: text/markdown
|
|||||||
"note": null
|
"note": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"cluster_resources_error": null
|
"cluster_resources_error": null,
|
||||||
|
"aggregate_cluster_resources": {
|
||||||
|
"nodes_count": 2,
|
||||||
|
"cpu_ring": 1.8,
|
||||||
|
"cpu_label": "ср. 1.8%",
|
||||||
|
"memory_percent_ring": 5.7,
|
||||||
|
"memory_percent_label": "ср. 5.7%",
|
||||||
|
"memory_used_ratio_ring": 5.8,
|
||||||
|
"memory_used_ratio_label": "ср. 5.8%",
|
||||||
|
"network_ring": 12.5,
|
||||||
|
"network_label": "Σ 1.71 MiB",
|
||||||
|
"disk_ring": 45.0,
|
||||||
|
"disk_label": "Σ 2.5 GiB"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `total_workers_from_meta` может быть `null`, если ни в одном `meta.json` нет `worker_nodes`.
|
- `container_cli` — активный движок для kind и `docker stats` / `podman stats` (как в GET /health).
|
||||||
|
- `total_workers_from_meta` — целое **≥ 0**; **0**, если ни в одном `meta.json` нет поля `worker_nodes` или оно не число.
|
||||||
- `jobs_total` — число заданий в текущей памяти процесса (не более 200).
|
- `jobs_total` — число заданий в текущей памяти процесса (не более 200).
|
||||||
- `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку).
|
- `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку).
|
||||||
- `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение.
|
- `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение.
|
||||||
- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым.
|
- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым, а `aggregate_cluster_resources` — нулевая сводка.
|
||||||
|
- `aggregate_cluster_resources` — агрегаты по **запущенным** узлам для донат-диаграмм на главной: средние проценты CPU/RAM, средняя доля RAM из строки `memory_usage`, кольца сети/диска по суммарному I/O (шкала 0–100 относительно порога 8 GiB на полное кольцо).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -223,6 +240,7 @@ Accept: text/markdown
|
|||||||
{
|
{
|
||||||
"name": "dev",
|
"name": "dev",
|
||||||
"registered_in_kind": true,
|
"registered_in_kind": true,
|
||||||
|
"kind_nodes_running": true,
|
||||||
"has_local_kubeconfig": true,
|
"has_local_kubeconfig": true,
|
||||||
"meta": {
|
"meta": {
|
||||||
"cluster_name": "dev",
|
"cluster_name": "dev",
|
||||||
@@ -235,6 +253,8 @@ Accept: text/markdown
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `kind_nodes_running` — в списке процессов контейнерного движка есть узлы kind с префиксом имени (`имя-control-plane`, `имя-worker`…); для UI: при `true` показывается действие «Стоп», иначе при необходимости подъёма — «Старт».
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## GET /api/v1/jobs
|
## GET /api/v1/jobs
|
||||||
|
|||||||
+16
-1
@@ -61,7 +61,7 @@ templates = Jinja2Templates(directory=str(_templates_dir))
|
|||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс")
|
@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс")
|
||||||
async def dashboard(request: Request) -> HTMLResponse:
|
async def dashboard(request: Request) -> HTMLResponse:
|
||||||
"""Главная страница с формой создания и таблицей кластеров."""
|
"""Главная страница: обзор, сводка по узлам, таблица кластеров."""
|
||||||
if not _templates_dir.is_dir():
|
if not _templates_dir.is_dir():
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||||||
@@ -75,6 +75,21 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания")
|
||||||
|
async def cluster_create_page(request: Request) -> HTMLResponse:
|
||||||
|
"""Форма создания кластера, прогресс и список последних заданий."""
|
||||||
|
if not _templates_dir.is_dir():
|
||||||
|
return HTMLResponse(
|
||||||
|
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"cluster_create.html",
|
||||||
|
{"app_title": settings.app_title, "nav_active": "cluster_create"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/ui", include_in_schema=False)
|
@app.get("/ui", include_in_schema=False)
|
||||||
async def ui_redirect() -> RedirectResponse:
|
async def ui_redirect() -> RedirectResponse:
|
||||||
"""Удобный алиас на корень UI."""
|
"""Удобный алиас на корень UI."""
|
||||||
|
|||||||
+32
-1
@@ -54,6 +54,10 @@ class ClusterSummary(BaseModel):
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
registered_in_kind: bool
|
registered_in_kind: bool
|
||||||
|
kind_nodes_running: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Запущены контейнеры узлов kind (имя-control-plane, имя-worker…; docker/podman ps)",
|
||||||
|
)
|
||||||
has_local_kubeconfig: bool
|
has_local_kubeconfig: bool
|
||||||
meta: dict[str, Any] = Field(default_factory=dict)
|
meta: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
@@ -78,12 +82,35 @@ class KindClusterResources(BaseModel):
|
|||||||
note: str | None = Field(default=None, description="Пояснение, если узлов нет или ошибка списка")
|
note: str | None = Field(default=None, description="Пояснение, если узлов нет или ошибка списка")
|
||||||
|
|
||||||
|
|
||||||
|
class AggregateResourcesSummary(BaseModel):
|
||||||
|
"""Сводка по всем запущенным узлам kind для донат-диаграмм на главной."""
|
||||||
|
|
||||||
|
nodes_count: int = Field(0, description="Число учтённых контейнеров-нод")
|
||||||
|
cpu_ring: float = Field(0, ge=0, le=100, description="Заполнение кольца «средний CPU», 0–100")
|
||||||
|
cpu_label: str = Field("—", description="Подпись в центре доната")
|
||||||
|
memory_percent_ring: float = Field(0, ge=0, le=100)
|
||||||
|
memory_percent_label: str = Field("—")
|
||||||
|
memory_used_ratio_ring: float = Field(0, ge=0, le=100, description="Доля занятой RAM от лимита по строке MemUsage")
|
||||||
|
memory_used_ratio_label: str = Field("—")
|
||||||
|
network_ring: float = Field(0, ge=0, le=100, description="Индикатор суммарного сетевого I/O (условная шкала)")
|
||||||
|
network_label: str = Field("—")
|
||||||
|
disk_ring: float = Field(0, ge=0, le=100, description="Индикатор суммарного блочного I/O")
|
||||||
|
disk_label: str = Field("—")
|
||||||
|
|
||||||
|
|
||||||
class StatsResponse(BaseModel):
|
class StatsResponse(BaseModel):
|
||||||
"""Краткая статистика для дашборда."""
|
"""Краткая статистика для дашборда."""
|
||||||
|
|
||||||
|
container_cli: str = Field(
|
||||||
|
description="CLI движка контейнеров (docker или podman): узлы kind и метрики собираются через него",
|
||||||
|
)
|
||||||
kind_clusters_count: int
|
kind_clusters_count: int
|
||||||
local_cluster_dirs_count: int
|
local_cluster_dirs_count: int
|
||||||
total_workers_from_meta: int | None
|
total_workers_from_meta: int = Field(
|
||||||
|
0,
|
||||||
|
ge=0,
|
||||||
|
description="Сумма worker_nodes по meta.json каталогов; 0 если ни в одном meta нет поля или данных",
|
||||||
|
)
|
||||||
jobs_total: int
|
jobs_total: int
|
||||||
jobs_recent_failed: int
|
jobs_recent_failed: int
|
||||||
cluster_resources: list[KindClusterResources] = Field(
|
cluster_resources: list[KindClusterResources] = Field(
|
||||||
@@ -94,6 +121,10 @@ class StatsResponse(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Глобальная ошибка сбора метрик (например CLI не найден)",
|
description="Глобальная ошибка сбора метрик (например CLI не найден)",
|
||||||
)
|
)
|
||||||
|
aggregate_cluster_resources: AggregateResourcesSummary = Field(
|
||||||
|
default_factory=AggregateResourcesSummary,
|
||||||
|
description="Агрегаты по узлам для донатов на главной странице",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClusterWorkloadsResponse(BaseModel):
|
class ClusterWorkloadsResponse(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true"><path fill="#326ce5" d="M10.204 14.35l.007.01-.999 2.413a5.171 5.171 0 0 1-2.075-2.597l2.578-.437.004.005a.44.44 0 0 1 .484.606zm-.833-2.129a.44.44 0 0 0 .173-.756l.002-.011L7.585 9.7a5.143 5.143 0 0 0-.73 3.255l2.514-.725.002-.009zm1.145-1.98a.44.44 0 0 0 .699-.337l.01-.005.15-2.62a5.144 5.144 0 0 0-3.01 1.442l2.147 1.523.004-.002zm.76 2.75l.723.349.722-.347.18-.78-.5-.623h-.804l-.5.623.179.779zm1.5-3.095a.44.44 0 0 0 .7.336l.008.003 2.134-1.513a5.188 5.188 0 0 0-2.992-1.442l.148 2.615.002.001zm10.876 5.97l-5.773 7.181a1.6 1.6 0 0 1-1.248.594l-9.261.003a1.6 1.6 0 0 1-1.247-.596l-5.776-7.18a1.583 1.583 0 0 1-.307-1.34L2.1 5.573c.108-.47.425-.864.863-1.073L11.305.513a1.606 1.606 0 0 1 1.385 0l8.345 3.985c.438.209.755.604.863 1.073l2.062 8.955c.108.47-.005.963-.308 1.34zm-3.289-2.057c-.042-.01-.103-.026-.145-.034-.174-.033-.315-.025-.479-.038-.35-.037-.638-.067-.895-.148-.105-.04-.18-.165-.216-.216l-.201-.059a6.45 6.45 0 0 0-.105-2.332 6.465 6.465 0 0 0-.936-2.163c.052-.047.15-.133.177-.159.008-.09.001-.183.094-.282.197-.185.444-.338.743-.522.142-.084.273-.137.415-.242.032-.024.076-.062.11-.089.24-.191.295-.52.123-.736-.172-.216-.506-.236-.745-.045-.034.027-.08.062-.111.088-.134.116-.217.23-.33.35-.246.25-.45.458-.673.609-.097.056-.239.037-.303.033l-.19.135a6.545 6.545 0 0 0-4.146-2.003l-.012-.223c-.065-.062-.143-.115-.163-.25-.022-.268.015-.557.057-.905.023-.163.061-.298.068-.475.001-.04-.001-.099-.001-.142 0-.306-.224-.555-.5-.555-.275 0-.499.249-.499.555l.001.014c0 .041-.002.092 0 .128.006.177.044.312.067.475.042.348.078.637.056.906a.545.545 0 0 1-.162.258l-.012.211a6.424 6.424 0 0 0-4.166 2.003 8.373 8.373 0 0 1-.18-.128c-.09.012-.18.04-.297-.029-.223-.15-.427-.358-.673-.608-.113-.12-.195-.234-.329-.349-.03-.026-.077-.062-.111-.088a.594.594 0 0 0-.348-.132.481.481 0 0 0-.398.176c-.172.216-.117.546.123.737l.007.005.104.083c.142.105.272.159.414.242.299.185.546.338.743.522.076.082.09.226.1.288l.16.143a6.462 6.462 0 0 0-1.02 4.506l-.208.06c-.055.072-.133.184-.215.217-.257.081-.546.11-.895.147-.164.014-.305.006-.48.039-.037.007-.09.02-.133.03l-.004.002-.007.002c-.295.071-.484.342-.423.608.061.267.349.429.645.365l.007-.001.01-.003.129-.029c.17-.046.294-.113.448-.172.33-.118.604-.217.87-.256.112-.009.23.069.288.101l.217-.037a6.5 6.5 0 0 0 2.88 3.596l-.09.218c.033.084.069.199.044.282-.097.252-.263.517-.452.813-.091.136-.185.242-.268.399-.02.037-.045.095-.064.134-.128.275-.034.591.213.71.248.12.556-.007.69-.282v-.002c.02-.039.046-.09.062-.127.07-.162.094-.301.144-.458.132-.332.205-.68.387-.897.05-.06.13-.082.215-.105l.113-.205a6.453 6.453 0 0 0 4.609.012l.106.192c.086.028.18.042.256.155.136.232.229.507.342.84.05.156.074.295.145.457.016.037.043.09.062.129.133.276.442.402.69.282.247-.118.341-.435.213-.71-.02-.039-.045-.096-.065-.134-.083-.156-.177-.261-.268-.398-.19-.296-.346-.541-.443-.793-.04-.13.007-.21.038-.294-.018-.022-.059-.144-.083-.202a6.499 6.499 0 0 0 2.88-3.622c.064.01.176.03.213.038.075-.05.144-.114.28-.104.266.039.54.138.87.256.154.06.277.128.448.173.036.01.088.019.13.028l.009.003.007.001c.297.064.584-.098.645-.365.06-.266-.128-.537-.423-.608zM16.4 9.701l-1.95 1.746v.005a.44.44 0 0 0 .173.757l.003.01 2.526.728a5.199 5.199 0 0 0-.108-1.674A5.208 5.208 0 0 0 16.4 9.7zm-4.013 5.325a.437.437 0 0 0-.404-.232.44.44 0 0 0-.372.233h-.002l-1.268 2.292a5.164 5.164 0 0 0 3.326.003l-1.27-2.296h-.01zm1.888-1.293a.44.44 0 0 0-.27.036.44.44 0 0 0-.214.572l-.003.004 1.01 2.438a5.15 5.15 0 0 0 2.081-2.615l-2.6-.44-.004.005z"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
+406
-125
@@ -28,6 +28,20 @@
|
|||||||
var currentPollJobId = null;
|
var currentPollJobId = null;
|
||||||
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
|
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
|
||||||
var currentModalClusterName = null;
|
var currentModalClusterName = null;
|
||||||
|
/** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */
|
||||||
|
var cachedClusterResourcesByName = {};
|
||||||
|
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
|
||||||
|
var cachedHealthPayload = null;
|
||||||
|
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
||||||
|
var cachedLocalClusterDirsCount = null;
|
||||||
|
|
||||||
|
function isHomePage() {
|
||||||
|
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCreatePage() {
|
||||||
|
return document.body.getAttribute("data-dashboard-mode") === "create";
|
||||||
|
}
|
||||||
|
|
||||||
function formatApiError(data, fallback) {
|
function formatApiError(data, fallback) {
|
||||||
if (!data) return fallback;
|
if (!data) return fallback;
|
||||||
@@ -240,143 +254,363 @@
|
|||||||
}, 4500);
|
}, 4500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatusBannerClass(ok, degraded) {
|
/**
|
||||||
const el = document.getElementById("status-banner");
|
* Карточки среды в блоке статистики (как плитки «Кластеров в kind»).
|
||||||
|
* @param {string} label подпись снизу (CAPS в стиле stats-tile__label)
|
||||||
|
* @param {string} valueText крупный текст сверху
|
||||||
|
* @param {boolean} isOk
|
||||||
|
* @param {string | null} [detail] доп. строка мелким шрифтом
|
||||||
|
*/
|
||||||
|
function buildStatsEnvCard(label, valueText, isOk, detail) {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className =
|
||||||
|
"stats-tile stats-env-card " + (isOk ? "stats-env-card--ok" : "stats-env-card--bad");
|
||||||
|
const valEl = document.createElement("span");
|
||||||
|
valEl.className = "stats-tile__value";
|
||||||
|
valEl.textContent = valueText;
|
||||||
|
const labEl = document.createElement("span");
|
||||||
|
labEl.className = "stats-tile__label";
|
||||||
|
labEl.textContent = label;
|
||||||
|
tile.appendChild(valEl);
|
||||||
|
tile.appendChild(labEl);
|
||||||
|
if (detail) {
|
||||||
|
const dEl = document.createElement("span");
|
||||||
|
dEl.className = "stats-env-card__detail muted";
|
||||||
|
dEl.textContent = detail.slice(0, 400);
|
||||||
|
tile.appendChild(dEl);
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нормализованное имя CLI для подписей (docker → Docker, podman → Podman). */
|
||||||
|
function titleCaseContainerCli(cliRaw) {
|
||||||
|
const s = String(cliRaw || "docker").trim() || "docker";
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст под заголовком «Статистика»: через какой движок идут узлы и метрики. */
|
||||||
|
function updateStatsEngineHint(cliRaw) {
|
||||||
|
const el = document.getElementById("stats-engine-hint");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
var cls = "hero-panel-status muted ";
|
const name = titleCaseContainerCli(cliRaw);
|
||||||
if (ok) cls += "ok";
|
el.textContent =
|
||||||
else if (degraded) cls += "degraded";
|
"Узлы kind (контейнеры control-plane и worker) и метрики ниже собираются через " + name + ".";
|
||||||
else cls += "err";
|
el.classList.remove("hidden");
|
||||||
el.className = cls;
|
}
|
||||||
|
|
||||||
|
function hideStatsEngineHint() {
|
||||||
|
const el = document.getElementById("stats-engine-hint");
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add("hidden");
|
||||||
|
el.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрыть ряд среды и метрик, показать одну карточку ошибки на всю карточку «Статистика».
|
||||||
|
* @param {"Health"|"Статистика"} sourceLabel подпись под «Ошибка»
|
||||||
|
*/
|
||||||
|
function coverStatsCardWithError(sourceLabel, message) {
|
||||||
|
hideStatsEngineHint();
|
||||||
|
const failureWrap = document.getElementById("stats-failure-wrap");
|
||||||
|
const env = document.getElementById("stats-env");
|
||||||
|
const metricsGrid = document.getElementById("stats-grid");
|
||||||
|
if (failureWrap) {
|
||||||
|
failureWrap.classList.remove("hidden");
|
||||||
|
failureWrap.replaceChildren();
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "stats-tile stats-env-card stats-env-card--bad stats-failure-card";
|
||||||
|
const valEl = document.createElement("span");
|
||||||
|
valEl.className = "stats-tile__value";
|
||||||
|
valEl.textContent = "Ошибка";
|
||||||
|
const labEl = document.createElement("span");
|
||||||
|
labEl.className = "stats-tile__label";
|
||||||
|
labEl.textContent = sourceLabel;
|
||||||
|
const dEl = document.createElement("span");
|
||||||
|
dEl.className = "stats-env-card__detail muted";
|
||||||
|
dEl.textContent = message || "";
|
||||||
|
tile.appendChild(valEl);
|
||||||
|
tile.appendChild(labEl);
|
||||||
|
tile.appendChild(dEl);
|
||||||
|
failureWrap.appendChild(tile);
|
||||||
|
}
|
||||||
|
if (env) env.classList.add("hidden");
|
||||||
|
if (metricsGrid) metricsGrid.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
|
||||||
|
function revealStatsCardBody() {
|
||||||
|
const failureWrap = document.getElementById("stats-failure-wrap");
|
||||||
|
const env = document.getElementById("stats-env");
|
||||||
|
const metricsGrid = document.getElementById("stats-grid");
|
||||||
|
if (failureWrap) {
|
||||||
|
failureWrap.classList.add("hidden");
|
||||||
|
failureWrap.replaceChildren();
|
||||||
|
}
|
||||||
|
if (env) env.classList.remove("hidden");
|
||||||
|
if (metricsGrid) metricsGrid.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Карточка-метрика в ряду среды (без зелёного/красного статуса), напр. «Каталогов». */
|
||||||
|
function buildStatsEnvMetricCard(label, valueText) {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "stats-tile stats-env-card stats-env-card--metric";
|
||||||
|
const valEl = document.createElement("span");
|
||||||
|
valEl.className = "stats-tile__value";
|
||||||
|
valEl.textContent = valueText;
|
||||||
|
const labEl = document.createElement("span");
|
||||||
|
labEl.className = "stats-tile__label";
|
||||||
|
labEl.textContent = label;
|
||||||
|
tile.appendChild(valEl);
|
||||||
|
tile.appendChild(labEl);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats).
|
||||||
|
*/
|
||||||
|
function paintStatsEnvRow() {
|
||||||
|
const el = document.getElementById("stats-env");
|
||||||
|
if (!el) return;
|
||||||
|
if (!cachedHealthPayload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.className = "stats-env-cards";
|
||||||
|
el.replaceChildren();
|
||||||
|
const h = cachedHealthPayload;
|
||||||
|
const cliRaw = String(h.container_cli || "docker").trim() || "docker";
|
||||||
|
const cliTitle = titleCaseContainerCli(cliRaw);
|
||||||
|
const apiOk = !!h.container_engine_ok;
|
||||||
|
el.appendChild(
|
||||||
|
buildStatsEnvCard(
|
||||||
|
cliTitle + " API",
|
||||||
|
apiOk ? "OK" : "Недоступно",
|
||||||
|
apiOk,
|
||||||
|
!apiOk && h.container_engine_detail ? String(h.container_engine_detail) : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const kindOk = !!h.kind_in_path;
|
||||||
|
el.appendChild(buildStatsEnvCard("Kind", kindOk ? "OK" : "нет в PATH", kindOk, null));
|
||||||
|
const kubectlOk = !!h.kubectl_in_path;
|
||||||
|
el.appendChild(
|
||||||
|
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
|
||||||
|
);
|
||||||
|
const dirVal =
|
||||||
|
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
|
||||||
|
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHealth() {
|
async function loadHealth() {
|
||||||
const el = document.getElementById("status-banner");
|
const el = document.getElementById("stats-env");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
const h = await api("/health");
|
const h = await api("/health");
|
||||||
const ok =
|
cachedHealthPayload = h;
|
||||||
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
|
revealStatsCardBody();
|
||||||
setStatusBannerClass(ok, !ok);
|
paintStatsEnvRow();
|
||||||
var lines = "<strong>Среда:</strong> ";
|
updateStatsEngineHint(h.container_cli);
|
||||||
lines += escapeHtml(String(h.container_cli || "?"));
|
|
||||||
lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен");
|
|
||||||
lines += " · kind: " + (h.kind_in_path ? "да" : "нет");
|
|
||||||
lines += " · kubectl: " + (h.kubectl_in_path ? "да" : "нет");
|
|
||||||
if (!h.container_engine_ok && h.container_engine_detail) {
|
|
||||||
lines +=
|
|
||||||
"<br/><span class=\"muted\">" +
|
|
||||||
escapeHtml(String(h.container_engine_detail).slice(0, 400)) +
|
|
||||||
"</span>";
|
|
||||||
}
|
|
||||||
el.innerHTML = lines;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatusBannerClass(false, false);
|
cachedHealthPayload = null;
|
||||||
el.textContent = "Не удалось запросить health: " + e.message;
|
el.replaceChildren();
|
||||||
|
coverStatsCardWithError("Health", e.message || String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Блок «Ресурсы узлов» из cluster_resources (CPU/RAM/I/O из docker stats).
|
* SVG-донаты по полю aggregate_cluster_resources (главная страница).
|
||||||
|
* @param {object | null | undefined} agg
|
||||||
|
* @param {string | null | undefined} resourcesError
|
||||||
|
* @param {string | undefined} containerCliRaw имя CLI из /stats (docker/podman) для подписи доната RAM
|
||||||
*/
|
*/
|
||||||
function renderStatsResources(s) {
|
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
|
||||||
const wrap = document.getElementById("stats-resources");
|
const root = document.getElementById("aggregate-donuts");
|
||||||
if (!wrap) return;
|
if (!root) return;
|
||||||
wrap.replaceChildren();
|
|
||||||
if (s.cluster_resources_error) {
|
root.replaceChildren();
|
||||||
|
if (resourcesError) {
|
||||||
const p = document.createElement("p");
|
const p = document.createElement("p");
|
||||||
p.className = "muted stats-resources-msg";
|
p.className = "muted aggregate-donuts-placeholder";
|
||||||
p.textContent = "Ресурсы узлов: " + s.cluster_resources_error;
|
p.textContent = "Метрики узлов недоступны: " + resourcesError;
|
||||||
wrap.appendChild(p);
|
root.appendChild(p);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list = s.cluster_resources || [];
|
if (!agg || !agg.nodes_count) {
|
||||||
if (!list.length) {
|
|
||||||
const p2 = document.createElement("p");
|
const p2 = document.createElement("p");
|
||||||
p2.className = "muted stats-resources-msg";
|
p2.className = "muted aggregate-donuts-placeholder";
|
||||||
p2.textContent = "Нет кластеров в kind — метрики узлов пусты.";
|
p2.textContent = "Нет запущенных узлов — диаграммы появятся после старта кластера.";
|
||||||
wrap.appendChild(p2);
|
root.appendChild(p2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const h3 = document.createElement("h3");
|
|
||||||
h3.className = "stats-resources-heading";
|
function donutItem(title, ringPct, centerLabel, subLine) {
|
||||||
h3.textContent = "Ресурсы узлов (CPU, RAM, I/O)";
|
const p = Math.max(0, Math.min(100, Number(ringPct) || 0));
|
||||||
wrap.appendChild(h3);
|
const wrap = document.createElement("div");
|
||||||
list.forEach(function (cr) {
|
wrap.className = "donut-item";
|
||||||
const sec = document.createElement("section");
|
wrap.setAttribute("role", "group");
|
||||||
sec.className = "stats-cluster-res";
|
wrap.setAttribute("aria-label", title);
|
||||||
const h4 = document.createElement("h4");
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
h4.className = "stats-cluster-res__title";
|
svg.setAttribute("viewBox", "0 0 110 110");
|
||||||
h4.textContent = cr.cluster_name;
|
svg.setAttribute("class", "donut-svg");
|
||||||
sec.appendChild(h4);
|
svg.setAttribute("aria-hidden", "true");
|
||||||
const nodes = cr.nodes || [];
|
const track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
if (!nodes.length) {
|
track.setAttribute("class", "donut-track");
|
||||||
const pn = document.createElement("p");
|
track.setAttribute("cx", "55");
|
||||||
pn.className = "muted stats-cluster-res__note";
|
track.setAttribute("cy", "55");
|
||||||
pn.textContent = cr.note || "Нет запущенных контейнеров узлов.";
|
track.setAttribute("r", "40");
|
||||||
sec.appendChild(pn);
|
track.setAttribute("fill", "none");
|
||||||
} else {
|
track.setAttribute("stroke-width", "9");
|
||||||
nodes.forEach(function (n) {
|
track.setAttribute("pathLength", "100");
|
||||||
const row = document.createElement("div");
|
const fill = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
row.className = "stats-node-res";
|
fill.setAttribute("class", "donut-fill");
|
||||||
const code = document.createElement("code");
|
fill.setAttribute("cx", "55");
|
||||||
code.textContent = n.container_name;
|
fill.setAttribute("cy", "55");
|
||||||
row.appendChild(code);
|
fill.setAttribute("r", "40");
|
||||||
const ul = document.createElement("ul");
|
fill.setAttribute("fill", "none");
|
||||||
ul.className = "stats-node-res__metrics";
|
fill.setAttribute("stroke-width", "9");
|
||||||
function addMetric(label, val) {
|
fill.setAttribute("pathLength", "100");
|
||||||
if (val == null || val === "") return;
|
fill.setAttribute("stroke-dasharray", p + " " + (100 - p));
|
||||||
const li = document.createElement("li");
|
fill.setAttribute("transform", "rotate(-90 55 55)");
|
||||||
const strong = document.createElement("strong");
|
svg.appendChild(track);
|
||||||
strong.textContent = label + ": ";
|
svg.appendChild(fill);
|
||||||
li.appendChild(strong);
|
wrap.appendChild(svg);
|
||||||
li.appendChild(document.createTextNode(String(val)));
|
const meta = document.createElement("div");
|
||||||
ul.appendChild(li);
|
meta.className = "donut-meta";
|
||||||
}
|
const tEl = document.createElement("span");
|
||||||
addMetric("CPU", n.cpu_percent);
|
tEl.className = "donut-meta__title";
|
||||||
addMetric("Память", n.memory_usage);
|
tEl.textContent = title;
|
||||||
addMetric("% RAM", n.memory_percent);
|
meta.appendChild(tEl);
|
||||||
addMetric("Сеть", n.net_io);
|
const vEl = document.createElement("span");
|
||||||
addMetric("Диск", n.block_io);
|
vEl.className = "donut-meta__val";
|
||||||
addMetric("PID", n.pids != null ? String(n.pids) : null);
|
vEl.textContent = centerLabel || "—";
|
||||||
row.appendChild(ul);
|
meta.appendChild(vEl);
|
||||||
sec.appendChild(row);
|
if (subLine) {
|
||||||
});
|
const sEl = document.createElement("span");
|
||||||
|
sEl.className = "donut-meta__sub muted";
|
||||||
|
sEl.textContent = subLine;
|
||||||
|
meta.appendChild(sEl);
|
||||||
}
|
}
|
||||||
wrap.appendChild(sec);
|
wrap.appendChild(meta);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ramEngine = titleCaseContainerCli(containerCliRaw);
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "donut-grid";
|
||||||
|
grid.appendChild(
|
||||||
|
donutItem("CPU (ср.)", agg.cpu_ring, String(agg.cpu_label || "—"), "узлов: " + agg.nodes_count),
|
||||||
|
);
|
||||||
|
grid.appendChild(
|
||||||
|
donutItem("% RAM (" + ramEngine + ")", agg.memory_percent_ring, String(agg.memory_percent_label || "—"), ""),
|
||||||
|
);
|
||||||
|
grid.appendChild(
|
||||||
|
donutItem("RAM used/limit", agg.memory_used_ratio_ring, String(agg.memory_used_ratio_label || "—"), ""),
|
||||||
|
);
|
||||||
|
grid.appendChild(
|
||||||
|
donutItem("Сеть Σ", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"),
|
||||||
|
);
|
||||||
|
grid.appendChild(
|
||||||
|
donutItem("Диск Σ", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"),
|
||||||
|
);
|
||||||
|
root.appendChild(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрывающийся блок метрик узлов под строкой таблицы кластеров.
|
||||||
|
* @param {string} clusterName
|
||||||
|
*/
|
||||||
|
function buildClusterResourcesDetails(clusterName) {
|
||||||
|
const cr = cachedClusterResourcesByName[clusterName];
|
||||||
|
const details = document.createElement("details");
|
||||||
|
details.className = "cluster-nodes-resources";
|
||||||
|
const summ = document.createElement("summary");
|
||||||
|
summ.textContent = "Ресурсы узлов (CPU, RAM, сеть, диск)";
|
||||||
|
details.appendChild(summ);
|
||||||
|
if (!cr || !(cr.nodes && cr.nodes.length)) {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = "muted cluster-nodes-resources__empty";
|
||||||
|
p.textContent = cr && cr.note ? cr.note : "Нет данных: узлы остановлены или метрики недоступны.";
|
||||||
|
details.appendChild(p);
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
cr.nodes.forEach(function (n) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "stats-node-res cluster-row-node-res";
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = n.container_name;
|
||||||
|
row.appendChild(code);
|
||||||
|
const ul = document.createElement("ul");
|
||||||
|
ul.className = "stats-node-res__metrics";
|
||||||
|
function addMetric(label, val) {
|
||||||
|
if (val == null || val === "") return;
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const strong = document.createElement("strong");
|
||||||
|
strong.textContent = label + ": ";
|
||||||
|
li.appendChild(strong);
|
||||||
|
li.appendChild(document.createTextNode(String(val)));
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
addMetric("CPU", n.cpu_percent);
|
||||||
|
addMetric("Память", n.memory_usage);
|
||||||
|
addMetric("% RAM", n.memory_percent);
|
||||||
|
addMetric("Сеть", n.net_io);
|
||||||
|
addMetric("Диск", n.block_io);
|
||||||
|
addMetric("PID", n.pids != null ? String(n.pids) : null);
|
||||||
|
row.appendChild(ul);
|
||||||
|
details.appendChild(row);
|
||||||
});
|
});
|
||||||
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
const dl = document.getElementById("stats-dl");
|
const grid = document.getElementById("stats-grid");
|
||||||
const errEl = document.getElementById("stats-err");
|
if (!grid) return;
|
||||||
if (!dl) return;
|
|
||||||
try {
|
try {
|
||||||
const s = await api("/stats");
|
const s = await api("/stats");
|
||||||
errEl.classList.add("hidden");
|
revealStatsCardBody();
|
||||||
|
cachedLocalClusterDirsCount = s.local_cluster_dirs_count;
|
||||||
|
paintStatsEnvRow();
|
||||||
|
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
|
||||||
|
updateStatsEngineHint(cliForHint);
|
||||||
const rows = [
|
const rows = [
|
||||||
["Кластеров в kind", s.kind_clusters_count],
|
["Кластеров в kind", s.kind_clusters_count],
|
||||||
["Локальных каталогов", s.local_cluster_dirs_count],
|
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
|
||||||
["Сумма workers (meta)", s.total_workers_from_meta != null ? s.total_workers_from_meta : "—"],
|
|
||||||
["Заданий в памяти", s.jobs_total],
|
["Заданий в памяти", s.jobs_total],
|
||||||
["Заданий с ошибкой", s.jobs_recent_failed],
|
["Заданий с ошибкой", s.jobs_recent_failed],
|
||||||
];
|
];
|
||||||
const frag = document.createDocumentFragment();
|
grid.replaceChildren();
|
||||||
rows.forEach(function (kv) {
|
rows.forEach(function (kv) {
|
||||||
const dt = document.createElement("dt");
|
const tile = document.createElement("div");
|
||||||
dt.textContent = kv[0];
|
tile.className = "stats-tile";
|
||||||
const dd = document.createElement("dd");
|
const valEl = document.createElement("span");
|
||||||
dd.textContent = String(kv[1]);
|
valEl.className = "stats-tile__value";
|
||||||
frag.appendChild(dt);
|
valEl.textContent = String(kv[1]);
|
||||||
frag.appendChild(dd);
|
const labEl = document.createElement("span");
|
||||||
|
labEl.className = "stats-tile__label";
|
||||||
|
labEl.textContent = kv[0];
|
||||||
|
tile.appendChild(valEl);
|
||||||
|
tile.appendChild(labEl);
|
||||||
|
grid.appendChild(tile);
|
||||||
});
|
});
|
||||||
dl.replaceChildren(frag);
|
cachedClusterResourcesByName = {};
|
||||||
renderStatsResources(s);
|
(s.cluster_resources || []).forEach(function (cr) {
|
||||||
|
cachedClusterResourcesByName[cr.cluster_name] = cr;
|
||||||
|
});
|
||||||
|
renderAggregateDonuts(
|
||||||
|
s.aggregate_cluster_resources,
|
||||||
|
s.cluster_resources_error,
|
||||||
|
s.container_cli || "docker",
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errEl.textContent = "Статистика: " + e.message;
|
coverStatsCardWithError("Статистика", e.message || String(e));
|
||||||
errEl.classList.remove("hidden");
|
grid.replaceChildren();
|
||||||
const wrap = document.getElementById("stats-resources");
|
cachedClusterResourcesByName = {};
|
||||||
if (wrap) wrap.replaceChildren();
|
const donuts = document.getElementById("aggregate-donuts");
|
||||||
|
if (donuts) {
|
||||||
|
donuts.replaceChildren();
|
||||||
|
const ph = document.createElement("p");
|
||||||
|
ph.className = "muted aggregate-donuts-placeholder";
|
||||||
|
ph.textContent = "Сводка по узлам недоступна (ошибка загрузки статистики).";
|
||||||
|
donuts.appendChild(ph);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +664,19 @@
|
|||||||
const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—";
|
const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—";
|
||||||
const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
|
const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
|
||||||
const nameEsc = escapeHtml(c.name);
|
const nameEsc = escapeHtml(c.name);
|
||||||
|
var stateCell = "";
|
||||||
|
if (c.registered_in_kind) {
|
||||||
|
if (c.kind_nodes_running) {
|
||||||
|
stateCell =
|
||||||
|
"<span class=\"badge badge-ok\" title=\"Контейнеры узлов запущены\">Запущен</span>";
|
||||||
|
} else {
|
||||||
|
stateCell =
|
||||||
|
"<span class=\"badge badge-stopped\" title=\"Зарегистрирован в kind, узлы остановлены\">Остановлен</span>";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stateCell =
|
||||||
|
"<span class=\"muted\" title=\"Нет в kind — только локальный каталог или кластер удалён из kind\">—</span>";
|
||||||
|
}
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
"<td><code class=\"cluster-name\">" +
|
"<td><code class=\"cluster-name\">" +
|
||||||
nameEsc +
|
nameEsc +
|
||||||
@@ -446,6 +693,9 @@
|
|||||||
"<td>" +
|
"<td>" +
|
||||||
escapeHtml(String(wn)) +
|
escapeHtml(String(wn)) +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
|
"<td>" +
|
||||||
|
stateCell +
|
||||||
|
"</td>" +
|
||||||
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
|
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
|
||||||
const td = tr.querySelector(".actions-toolbar");
|
const td = tr.querySelector(".actions-toolbar");
|
||||||
td.appendChild(
|
td.appendChild(
|
||||||
@@ -459,18 +709,7 @@
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
td.appendChild(
|
if (c.registered_in_kind && c.kind_nodes_running) {
|
||||||
iconActionButton(
|
|
||||||
ICONS.play,
|
|
||||||
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
|
|
||||||
"",
|
|
||||||
function () {
|
|
||||||
startCluster(c.name);
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (c.registered_in_kind) {
|
|
||||||
td.appendChild(
|
td.appendChild(
|
||||||
iconActionButton(
|
iconActionButton(
|
||||||
ICONS.stop,
|
ICONS.stop,
|
||||||
@@ -482,6 +721,18 @@
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
td.appendChild(
|
||||||
|
iconActionButton(
|
||||||
|
ICONS.play,
|
||||||
|
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
|
||||||
|
"",
|
||||||
|
function () {
|
||||||
|
startCluster(c.name);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
td.appendChild(
|
td.appendChild(
|
||||||
iconActionButton(
|
iconActionButton(
|
||||||
@@ -510,6 +761,16 @@
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
frag.appendChild(tr);
|
frag.appendChild(tr);
|
||||||
|
if (isHomePage()) {
|
||||||
|
const trExp = document.createElement("tr");
|
||||||
|
trExp.className = "cluster-resources-expand-row";
|
||||||
|
const tdExp = document.createElement("td");
|
||||||
|
tdExp.colSpan = 7;
|
||||||
|
tdExp.className = "cluster-resources-expand-cell";
|
||||||
|
tdExp.appendChild(buildClusterResourcesDetails(c.name));
|
||||||
|
trExp.appendChild(tdExp);
|
||||||
|
frag.appendChild(trExp);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(frag);
|
tbody.replaceChildren(frag);
|
||||||
bindActionTooltipHosts(document.getElementById("tbl-clusters"));
|
bindActionTooltipHosts(document.getElementById("tbl-clusters"));
|
||||||
@@ -968,11 +1229,19 @@
|
|||||||
pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
|
pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshLists() {
|
async function refreshLists() {
|
||||||
loadHealth();
|
try {
|
||||||
loadStats();
|
await loadHealth();
|
||||||
loadClusters();
|
if (isHomePage()) {
|
||||||
loadJobs();
|
await loadStats();
|
||||||
|
await loadClusters();
|
||||||
|
}
|
||||||
|
if (isCreatePage()) {
|
||||||
|
await loadJobs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* loadHealth/loadStats/loadClusters сами сообщают об ошибках в UI */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -1078,11 +1347,23 @@
|
|||||||
|
|
||||||
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
|
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
|
||||||
|
|
||||||
loadHealth();
|
(async function bootstrap() {
|
||||||
loadStats();
|
try {
|
||||||
loadVersions();
|
await loadHealth();
|
||||||
loadClusters();
|
if (isHomePage()) {
|
||||||
loadJobs();
|
await loadStats();
|
||||||
|
await loadClusters();
|
||||||
|
}
|
||||||
|
if (isCreatePage()) {
|
||||||
|
await loadJobs();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* см. сообщения в блоках */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (isCreatePage()) {
|
||||||
|
loadVersions();
|
||||||
|
}
|
||||||
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Переключение светлой/тёмной темы: data-theme на <html>, ключ localStorage.
|
||||||
|
* Начальная тема задаётся инлайн-скриптом в base.html (без мигания).
|
||||||
|
*
|
||||||
|
* Автор: Сергей Антропов
|
||||||
|
* Сайт: https://devops.org.ru
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var STORAGE_KEY = "kind_k8s_theme";
|
||||||
|
|
||||||
|
function getTheme() {
|
||||||
|
var t = document.documentElement.getAttribute("data-theme");
|
||||||
|
return t === "light" ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
var next = theme === "light" ? "light" : "dark";
|
||||||
|
document.documentElement.setAttribute("data-theme", next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch (e) {
|
||||||
|
/* приватный режим и т.п. */
|
||||||
|
}
|
||||||
|
syncToggleButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncToggleButton() {
|
||||||
|
var btn = document.getElementById("theme-toggle");
|
||||||
|
if (!btn) return;
|
||||||
|
var dark = getTheme() === "dark";
|
||||||
|
btn.setAttribute("aria-pressed", dark ? "true" : "false");
|
||||||
|
if (dark) {
|
||||||
|
btn.setAttribute("aria-label", "Включить светлую тему");
|
||||||
|
btn.setAttribute("title", "Светлая тема");
|
||||||
|
} else {
|
||||||
|
btn.setAttribute("aria-label", "Включить тёмную тему");
|
||||||
|
btn.setAttribute("title", "Тёмная тема");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var btn = document.getElementById("theme-toggle");
|
||||||
|
if (btn) btn.addEventListener("click", toggleTheme);
|
||||||
|
syncToggleButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* defer: скрипт в <head> выполняется после разбора документа — кнопка уже есть. */
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+546
-96
@@ -1,25 +1,28 @@
|
|||||||
/* Минимальные стили веб-интерфейса Kind Clusters Dashboard.
|
/* Минимальные стили веб-интерфейса Kind Clusters Dashboard.
|
||||||
Автор: Сергей Антропов — https://devops.org.ru */
|
Автор: Сергей Антропов — https://devops.org.ru */
|
||||||
|
|
||||||
:root {
|
/* Тема: тёмная по умолчанию; светлая — html[data-theme="light"]. Переключатель в шапке + localStorage. */
|
||||||
color-scheme: light dark;
|
html {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||||
|
}
|
||||||
|
html:not([data-theme="light"]) {
|
||||||
|
color-scheme: dark;
|
||||||
--bg: #0f1419;
|
--bg: #0f1419;
|
||||||
--fg: #e7ecf1;
|
--fg: #e7ecf1;
|
||||||
--muted: #8b98a5;
|
--muted: #8b98a5;
|
||||||
--card: #1a2332;
|
--card: #1a2332;
|
||||||
--accent: #3b82f6;
|
--accent: #3b82f6;
|
||||||
--border: #2f3d52;
|
--border: #2f3d52;
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
/* Светлая тема: основной текст и акценты в синей гамме (как тёмная), без «чистого» чёрного */
|
||||||
@media (prefers-color-scheme: light) {
|
html[data-theme="light"] {
|
||||||
:root {
|
color-scheme: light;
|
||||||
--bg: #f4f6f9;
|
--bg: #eef2fb;
|
||||||
--fg: #111827;
|
--fg: #172c46;
|
||||||
--muted: #6b7280;
|
--muted: #5a6f8f;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--border: #e5e7eb;
|
--border: #b8c9e8;
|
||||||
}
|
--accent: #3b82f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -70,13 +73,26 @@ body.modal-open {
|
|||||||
.nav-brand {
|
.nav-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.nav-logo {
|
.nav-brand-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.nav-brand-link:hover .nav-title {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 1.25rem;
|
}
|
||||||
line-height: 1;
|
.nav-brand-link:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.nav-logo-img {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.nav-title {
|
.nav-title {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -85,12 +101,163 @@ body.modal-open {
|
|||||||
.nav-tag {
|
.nav-tag {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
/* Шапка: один flex-ряд, все пилюли + API + тема с align-items: center */
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
.nav-links.top-nav-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Выпадающее меню «API»: Swagger, ReDoc, Health в отдельном окне */
|
||||||
|
.nav-dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.nav-dropdown__trigger {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nav-dropdown__trigger::after {
|
||||||
|
content: "▾";
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
font-size: 0.72em;
|
||||||
|
opacity: 0.85;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.nav-dropdown--open .nav-dropdown__trigger::after {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
.nav-dropdown__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.35rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 160;
|
||||||
|
min-width: 11rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .nav-dropdown__menu {
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
.nav-dropdown__menu[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.nav-dropdown__menu li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nav-dropdown__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.48rem 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--fg);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.nav-dropdown__link:hover,
|
||||||
|
.nav-dropdown__link:focus-visible {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.nav-dropdown__link.nav-pill--ext::after {
|
||||||
|
content: "↗";
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
font-size: 0.72em;
|
||||||
|
opacity: 0.72;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Переключатель светлая / тёмная тема (в конце шапки, высота как у пилюль) */
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.42rem 0.75rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .theme-toggle {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: rgba(59, 130, 246, 0.28);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.theme-toggle:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
.theme-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.theme-toggle__icon {
|
||||||
|
display: block;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .theme-toggle__icon--sun {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .theme-toggle__icon--moon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
html:not([data-theme="light"]) .theme-toggle__icon--sun {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
html:not([data-theme="light"]) .theme-toggle__icon--moon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Визуально кнопка темы сидела ниже пилюль (<a> vs <button>); поднимаем на 0.75rem */
|
||||||
|
.theme-toggle--nav-end {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
padding-left: 0.65rem;
|
||||||
|
padding-right: 0.65rem;
|
||||||
|
min-height: calc(0.8125rem * 1.35 + 0.84rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
transform: translateY(-0.3rem);
|
||||||
|
}
|
||||||
|
|
||||||
/* Пункты меню: компактные «пилюли» */
|
/* Пункты меню: компактные «пилюли» */
|
||||||
.nav-link.nav-pill {
|
.nav-link.nav-pill {
|
||||||
@@ -135,11 +302,15 @@ body.modal-open {
|
|||||||
background: rgba(59, 130, 246, 0.16);
|
background: rgba(59, 130, 246, 0.16);
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: light) {
|
html[data-theme="light"] .nav-link.nav-pill {
|
||||||
.nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) {
|
background: rgba(59, 130, 246, 0.1);
|
||||||
color: #1e40af;
|
border-color: rgba(59, 130, 246, 0.28);
|
||||||
background: rgba(59, 130, 246, 0.12);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
border-color: rgba(37, 99, 235, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Внешние окна: иконка «новое окно» */
|
/* Внешние окна: иконка «новое окно» */
|
||||||
@@ -168,6 +339,14 @@ body.modal-open {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Страница /documentation: основной блок и подвал на всю ширину окна */
|
||||||
|
.doc-layout-full .app-main,
|
||||||
|
.doc-layout-full .app-footer {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
max-width: 72rem;
|
max-width: 72rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -192,7 +371,7 @@ body.modal-open {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Верхний ряд: панель управления слева, статистика справа */
|
/* Верхний ряд: CTA «Создать кластер» и статистика по ~50% ширины */
|
||||||
.top-dashboard-row {
|
.top-dashboard-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -202,69 +381,173 @@ body.modal-open {
|
|||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.top-dashboard-row {
|
.top-dashboard-row {
|
||||||
grid-template-columns: 1fr minmax(17rem, 24rem);
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-dashboard-row .hero-panel {
|
/* Левая карточка — переход к форме создания */
|
||||||
|
.cta-hero-card {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: min(16rem, 42vh);
|
||||||
|
}
|
||||||
|
.cta-hero-inner {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.35rem 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 26rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.cta-hero-card .page-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: clamp(1.35rem, 2.5vw, 1.65rem);
|
||||||
|
}
|
||||||
|
.cta-hero-lead {
|
||||||
|
margin: 0 0 2.15rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 22rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.cta-hero-inner .btn-cta-large {
|
||||||
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-side-card {
|
.stats-side-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: min(85vh, 52rem);
|
gap: 0.85rem;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-side-card h2 {
|
.stats-side-card h2 {
|
||||||
margin-bottom: 0.5rem;
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пояснение: узлы kind и метрики идут через Docker или Podman (CONTAINER_CLI) */
|
||||||
|
.stats-engine-hint {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Одна карточка «Ошибка» на всю ширину блока статистики (health или /stats) */
|
||||||
|
.stats-failure-wrap {
|
||||||
|
width: 100%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.stats-failure-card {
|
||||||
.stats-dl--compact {
|
width: 100%;
|
||||||
flex: 0 0 auto;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
min-height: 4.75rem;
|
||||||
font-size: 0.88rem;
|
padding: 0.85rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Метрики узлов kind (docker stats) под сводкой */
|
/* Среда: Docker API · Kind · Kubectl · Каталогов — один ряд, 4 колонки */
|
||||||
.stats-resources {
|
.stats-env-cards {
|
||||||
margin-top: 0.75rem;
|
display: grid;
|
||||||
padding-top: 0.65rem;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
border-top: 1px solid var(--border);
|
gap: 0.65rem;
|
||||||
flex: 1 1 auto;
|
flex-shrink: 0;
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
}
|
||||||
.stats-resources-heading {
|
@media (max-width: 520px) {
|
||||||
margin: 0 0 0.45rem;
|
.stats-env-cards {
|
||||||
font-size: 0.8125rem;
|
grid-template-columns: 1fr;
|
||||||
font-weight: 650;
|
}
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
}
|
||||||
.stats-resources-msg {
|
.stats-env-placeholder {
|
||||||
|
grid-column: 1 / -1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.stats-cluster-res {
|
|
||||||
margin-bottom: 0.65rem;
|
|
||||||
}
|
|
||||||
.stats-cluster-res:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.stats-cluster-res__title {
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 650;
|
text-align: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.stats-cluster-res__note {
|
.stats-env-card {
|
||||||
margin: 0;
|
min-height: 4.5rem;
|
||||||
font-size: 0.78rem;
|
padding: 0.65rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
.stats-env-card--ok {
|
||||||
|
border-color: rgba(34, 197, 94, 0.45);
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
}
|
||||||
|
.stats-env-card--bad {
|
||||||
|
border-color: rgba(239, 68, 68, 0.45);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
.stats-env-card--bad .stats-tile__value {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .stats-env-card--bad .stats-tile__value {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.stats-env-card__detail {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.stats-env-card--metric {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .stats-env-card--metric {
|
||||||
|
background: rgba(59, 130, 246, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Метрики: по 4 карточки в ряд (4 метрики — один ряд) */
|
||||||
|
.stats-metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.stats-metrics-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-tile {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.72rem 0.45rem;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 4.1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .stats-tile {
|
||||||
|
background: rgba(59, 130, 246, 0.07);
|
||||||
|
}
|
||||||
|
.stats-tile__value {
|
||||||
|
font-size: 1.28rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.stats-tile__label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.045em;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Метрики узла (общие стили: донаты на главной и раскрытие под строкой кластера) */
|
||||||
.stats-node-res {
|
.stats-node-res {
|
||||||
margin-bottom: 0.45rem;
|
margin-bottom: 0.45rem;
|
||||||
padding: 0.35rem 0.45rem;
|
padding: 0.35rem 0.45rem;
|
||||||
@@ -290,10 +573,124 @@ body.modal-open {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: light) {
|
html[data-theme="light"] .stats-node-res {
|
||||||
.stats-node-res {
|
background: rgba(59, 130, 246, 0.07);
|
||||||
background: rgba(0, 0, 0, 0.04);
|
}
|
||||||
}
|
|
||||||
|
.btn-cta-large {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: min(22rem, 100%);
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0.95rem 2.25rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 650;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
a.btn-cta-large:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сводка-денаты по узлам */
|
||||||
|
.aggregate-donuts-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.donuts-card-title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
||||||
|
gap: 1rem 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.donut-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.donut-svg {
|
||||||
|
width: 6.5rem;
|
||||||
|
height: 6.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.donut-track {
|
||||||
|
stroke: var(--border);
|
||||||
|
}
|
||||||
|
.donut-fill {
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.donut-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.12rem;
|
||||||
|
max-width: 11rem;
|
||||||
|
}
|
||||||
|
.donut-meta__title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.donut-meta__val {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.donut-meta__sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.aggregate-donuts-placeholder {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Вторая строка таблицы: раскрытие ресурсов */
|
||||||
|
.cluster-resources-expand-row td {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.cluster-resources-expand-cell {
|
||||||
|
padding: 0 0.75rem 0.65rem;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .cluster-resources-expand-cell {
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
.cluster-nodes-resources {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.cluster-nodes-resources summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.cluster-nodes-resources__empty {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.cluster-row-node-res {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-create-hero {
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-cluster-card {
|
.create-cluster-card {
|
||||||
@@ -440,11 +837,6 @@ body.modal-open {
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.git-hint {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0 0 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-intro {
|
.page-intro {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -492,6 +884,9 @@ body.modal-open {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
html[data-theme="light"] .badge {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
.badge-ok {
|
.badge-ok {
|
||||||
border-color: #15803d;
|
border-color: #15803d;
|
||||||
color: #4ade80;
|
color: #4ade80;
|
||||||
@@ -508,6 +903,10 @@ body.modal-open {
|
|||||||
border-color: #6b7280;
|
border-color: #6b7280;
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
.badge-stopped {
|
||||||
|
border-color: #b45309;
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
.cluster-name {
|
.cluster-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -521,6 +920,9 @@ body.modal-open {
|
|||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: rgba(0, 0, 0, 0.12);
|
background: rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
html[data-theme="light"] .job-details {
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
.job-details summary {
|
.job-details summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -536,6 +938,27 @@ body.modal-open {
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.modal-head--cluster {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.modal-title-text--center {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding-right: 0;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.modal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.65rem 1rem;
|
||||||
|
}
|
||||||
.modal-dl-wrap {
|
.modal-dl-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -543,6 +966,9 @@ body.modal-open {
|
|||||||
gap: 0.5rem 0.75rem;
|
gap: 0.5rem 0.75rem;
|
||||||
margin: 0 0 0.65rem;
|
margin: 0 0 0.65rem;
|
||||||
}
|
}
|
||||||
|
.modal-toolbar .modal-dl-wrap {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.modal-dl-hint {
|
.modal-dl-hint {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
@@ -562,6 +988,7 @@ body.modal-open {
|
|||||||
.modal-spinner {
|
.modal-spinner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
@@ -689,6 +1116,9 @@ button:hover {
|
|||||||
max-height: 14rem;
|
max-height: 14rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
html[data-theme="light"] .mono {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -886,13 +1316,11 @@ button.btn-small {
|
|||||||
border-color: #ef4444;
|
border-color: #ef4444;
|
||||||
background: rgba(239, 68, 68, 0.14);
|
background: rgba(239, 68, 68, 0.14);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: light) {
|
html[data-theme="light"] .icon-btn {
|
||||||
.icon-btn {
|
background: rgba(59, 130, 246, 0.08);
|
||||||
background: rgba(0, 0, 0, 0.04);
|
}
|
||||||
}
|
html[data-theme="light"] .icon-btn--danger {
|
||||||
.icon-btn--danger {
|
color: #b91c1c;
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Модальное окно «Состояние кластера» */
|
/* Модальное окно «Состояние кластера» */
|
||||||
@@ -901,9 +1329,9 @@ button.btn-small {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem 1rem;
|
padding: 1.25rem 1rem;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -916,18 +1344,25 @@ button.btn-small {
|
|||||||
}
|
}
|
||||||
.modal-box--confirm {
|
.modal-box--confirm {
|
||||||
max-width: 24rem;
|
max-width: 24rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.confirm-modal-title {
|
.confirm-modal-title {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.confirm-modal-message {
|
.confirm-modal-message {
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.confirm-modal-actions {
|
.confirm-modal-actions {
|
||||||
margin-top: 1rem;
|
display: flex;
|
||||||
justify-content: flex-end;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.65rem 0.85rem;
|
||||||
|
margin-top: 1.15rem;
|
||||||
}
|
}
|
||||||
.confirm-modal-actions button {
|
.confirm-modal-actions button {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -938,8 +1373,12 @@ button.btn-small {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 2.5rem);
|
||||||
|
max-height: calc(100dvh - 2.5rem);
|
||||||
|
overflow-y: auto;
|
||||||
padding: 1rem 1.15rem;
|
padding: 1rem 1.15rem;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
.modal-box h3 {
|
.modal-box h3 {
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
@@ -960,11 +1399,9 @@ a.btn-danger {
|
|||||||
color: #f87171;
|
color: #f87171;
|
||||||
border-color: #b91c1c;
|
border-color: #b91c1c;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: light) {
|
html[data-theme="light"] button.btn-danger,
|
||||||
button.btn-danger,
|
html[data-theme="light"] a.btn-danger {
|
||||||
a.btn-danger {
|
color: #b91c1c;
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
button.btn-danger:hover {
|
button.btn-danger:hover {
|
||||||
filter: brightness(1.12);
|
filter: brightness(1.12);
|
||||||
@@ -976,6 +1413,12 @@ button.btn-danger:hover {
|
|||||||
margin: 0 auto 0.65rem;
|
margin: 0 auto 0.65rem;
|
||||||
padding: 0 0.15rem;
|
padding: 0 0.15rem;
|
||||||
}
|
}
|
||||||
|
.doc-layout-full .doc-breadcrumbs {
|
||||||
|
max-width: none;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
.doc-breadcrumbs__list {
|
.doc-breadcrumbs__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1017,6 +1460,12 @@ button.btn-danger:hover {
|
|||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
.doc-layout-full .readme-doc-shell {
|
||||||
|
max-width: none;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.readme-doc-loading {
|
.readme-doc-loading {
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -1135,11 +1584,12 @@ button.btn-danger:hover {
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin: 1.25rem 0;
|
margin: 1.25rem 0;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: light) {
|
html[data-theme="light"] .readme-section-card.markdown-body code {
|
||||||
.readme-section-card.markdown-body code {
|
background: rgba(59, 130, 246, 0.1);
|
||||||
background: rgba(0, 0, 0, 0.06);
|
}
|
||||||
}
|
html[data-theme="light"] .readme-section-card.markdown-body pre {
|
||||||
.readme-section-card.markdown-body pre {
|
background: rgba(59, 130, 246, 0.06);
|
||||||
background: rgba(0, 0, 0, 0.04);
|
}
|
||||||
}
|
html[data-theme="light"] .readme-section-card.markdown-body th {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-15
@@ -6,29 +6,127 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{% block page_title %}{{ app_title }}{% endblock %} — kind</title>
|
<title>{% block page_title %}{{ app_title }}{% endblock %} — kind</title>
|
||||||
|
{# Тема до первой отрисовки: localStorage kind_k8s_theme или prefers-color-scheme #}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var k = "kind_k8s_theme";
|
||||||
|
var v = localStorage.getItem(k);
|
||||||
|
var t =
|
||||||
|
v === "light" || v === "dark"
|
||||||
|
? v
|
||||||
|
: window.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
? "light"
|
||||||
|
: "dark";
|
||||||
|
document.documentElement.setAttribute("data-theme", t);
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<script src="/static/js/theme.js" defer></script>
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="app-body"
|
class="app-body{% block body_extra_class %}{% endblock %}"
|
||||||
data-api-base="/api/v1"
|
data-api-base="/api/v1"
|
||||||
data-app-title="{{ app_title | e }}"
|
data-app-title="{{ app_title | e }}"
|
||||||
|
{% block body_attrs %}{% endblock %}
|
||||||
>
|
>
|
||||||
<a class="skip-link" href="#main-content">К основному содержимому</a>
|
<a class="skip-link" href="#main-content">К основному содержимому</a>
|
||||||
|
|
||||||
<header class="top-nav" role="banner">
|
<header class="top-nav" role="banner">
|
||||||
<div class="top-nav-inner">
|
<div class="top-nav-inner">
|
||||||
<div class="nav-brand">
|
{# Логотип Kubernetes (SVG из набора Simple Icons, CC0) + название — одна ссылка на главную. #}
|
||||||
<span class="nav-logo" aria-hidden="true">◈</span>
|
<a href="/" class="nav-brand nav-brand-link">
|
||||||
|
<img
|
||||||
|
src="/static/icons/kubernetes.svg"
|
||||||
|
alt=""
|
||||||
|
class="nav-logo-img"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
<span class="nav-title">{{ app_title }}</span>
|
<span class="nav-title">{{ app_title }}</span>
|
||||||
</div>
|
</a>
|
||||||
<nav class="nav-links" aria-label="Разделы">
|
{# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #}
|
||||||
{# Панель подсвечивается только на дашборде; на странице документации — обычная пилюля, как Swagger/ReDoc. #}
|
<nav class="nav-links top-nav-actions" aria-label="Разделы и оформление">
|
||||||
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
|
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
|
||||||
<a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
|
<a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
|
||||||
<a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a>
|
<div class="nav-dropdown">
|
||||||
<a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
|
<button
|
||||||
<a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
|
type="button"
|
||||||
|
class="nav-link nav-pill nav-dropdown__trigger"
|
||||||
|
id="nav-api-trigger"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-controls="nav-api-menu"
|
||||||
|
>
|
||||||
|
API
|
||||||
|
</button>
|
||||||
|
<ul class="nav-dropdown__menu" id="nav-api-menu" role="menu" hidden>
|
||||||
|
<li role="none">
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href="/docs"
|
||||||
|
class="nav-dropdown__link nav-pill--ext"
|
||||||
|
data-open-window="kind_swagger"
|
||||||
|
>Swagger</a>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href="/redoc"
|
||||||
|
class="nav-dropdown__link nav-pill--ext"
|
||||||
|
data-open-window="kind_redoc"
|
||||||
|
>ReDoc</a>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href="/api/v1/health"
|
||||||
|
class="nav-dropdown__link nav-pill--ext"
|
||||||
|
data-open-window="kind_health"
|
||||||
|
>Health</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="theme-toggle" class="theme-toggle theme-toggle--nav-end">
|
||||||
|
<svg
|
||||||
|
class="theme-toggle__icon theme-toggle__icon--sun"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M12 2v2M12 20v2M2 12h2M20 12h2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M19.07 4.93l-1.41 1.41M6.34 17.66l-1.41 1.41"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="theme-toggle__icon theme-toggle__icon--moon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -53,16 +151,63 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{# Отдельное окно браузера для документации и health (не вкладка). #}
|
{# Отдельное окно для Swagger/ReDoc/Health; выпадающее меню API. #}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var opts = "noopener,noreferrer,width=1240,height=840,scrollbars=yes,resizable=yes";
|
var opts = "noopener,noreferrer,width=1240,height=840,scrollbars=yes,resizable=yes";
|
||||||
document.querySelectorAll(".nav-link[data-open-window]").forEach(function (a) {
|
function bindOpenWindow(scope) {
|
||||||
a.addEventListener("click", function (e) {
|
(scope || document).querySelectorAll("[data-open-window]").forEach(function (a) {
|
||||||
e.preventDefault();
|
if (a.tagName !== "A") return;
|
||||||
var name = a.getAttribute("data-open-window") || "kind_ext";
|
a.addEventListener("click", function (e) {
|
||||||
window.open(a.getAttribute("href"), name, opts);
|
e.preventDefault();
|
||||||
|
var name = a.getAttribute("data-open-window") || "kind_ext";
|
||||||
|
window.open(a.getAttribute("href"), name, opts);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
bindOpenWindow();
|
||||||
|
|
||||||
|
var dd = document.querySelector(".nav-dropdown");
|
||||||
|
if (!dd) return;
|
||||||
|
var btn = document.getElementById("nav-api-trigger");
|
||||||
|
var menu = document.getElementById("nav-api-menu");
|
||||||
|
if (!btn || !menu) return;
|
||||||
|
|
||||||
|
function closeApiMenu() {
|
||||||
|
dd.classList.remove("nav-dropdown--open");
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
menu.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
function openApiMenu() {
|
||||||
|
dd.classList.add("nav-dropdown--open");
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
menu.removeAttribute("hidden");
|
||||||
|
}
|
||||||
|
function toggleApiMenu() {
|
||||||
|
if (menu.hasAttribute("hidden")) openApiMenu();
|
||||||
|
else closeApiMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleApiMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.querySelectorAll("a[role='menuitem']").forEach(function (a) {
|
||||||
|
a.addEventListener("click", function () {
|
||||||
|
closeApiMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", function () {
|
||||||
|
closeApiMenu();
|
||||||
|
});
|
||||||
|
dd.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") closeApiMenu();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{# Создание кластера, журнал задания и последние задания.
|
||||||
|
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block body_attrs %}data-dashboard-mode="create"{% 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 %}
|
||||||
|
<section class="card hero-panel cluster-create-hero" aria-labelledby="create-hero-title">
|
||||||
|
<h1 class="page-title" id="create-hero-title">Создание кластера</h1>
|
||||||
|
<p class="muted page-lead">
|
||||||
|
Укажите имя, версию образа kindest/node и число worker-нод. Операция выполняется в фоне; ход смотрите в журнале ниже.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card create-cluster-card">
|
||||||
|
<h2>Новый кластер</h2>
|
||||||
|
<form id="form-create" novalidate>
|
||||||
|
<div class="create-form-grid">
|
||||||
|
<div class="create-form-col">
|
||||||
|
<label for="fld-name">Имя (a-z0-9-)</label>
|
||||||
|
<input
|
||||||
|
id="fld-name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
|
||||||
|
maxlength="63"
|
||||||
|
placeholder="dev"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label for="fld-workers">Worker-ноды (0–20)</label>
|
||||||
|
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="create-form-col">
|
||||||
|
<label for="version-select">Тег образа kindest/node (подсказка) или введите версию вручную ниже</label>
|
||||||
|
<div class="row">
|
||||||
|
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
|
||||||
|
<option value="">— загрузить список —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
|
||||||
|
<input
|
||||||
|
name="kubernetes_version"
|
||||||
|
id="kubernetes_version"
|
||||||
|
required
|
||||||
|
placeholder="1.29.4 или v1.29.4"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="create-form-span">
|
||||||
|
<div class="row create-actions">
|
||||||
|
<button type="submit">Создать кластер</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="create-progress-wrap" class="create-progress-wrap hidden" aria-hidden="true">
|
||||||
|
<p class="create-progress-hint muted" id="create-progress-hint">
|
||||||
|
Идёт операция — шаг <code>kind create</code> может занять несколько минут при первом pull образов.
|
||||||
|
Ниже выводится журнал (скачивание образов и подъём нод).
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="progress-track"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow="0"
|
||||||
|
aria-labelledby="create-progress-label"
|
||||||
|
id="create-progress-track"
|
||||||
|
>
|
||||||
|
<div class="progress-bar" id="create-progress-bar" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-label" id="create-progress-label">—</p>
|
||||||
|
<div class="job-log-panel-wrap">
|
||||||
|
<label for="job-log-panel" class="job-log-label muted">Журнал задания</label>
|
||||||
|
<pre
|
||||||
|
id="job-log-panel"
|
||||||
|
class="mono job-log-panel"
|
||||||
|
role="log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-relevant="additions"
|
||||||
|
></pre>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="create-msg" class="msg" aria-live="polite"></p>
|
||||||
|
<details id="job-details" class="job-details hidden">
|
||||||
|
<summary>Технические детали (JSON)</summary>
|
||||||
|
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
|
||||||
|
</details>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card jobs-card">
|
||||||
|
<div class="row spread jobs-card-toolbar">
|
||||||
|
<h2 id="jobs-heading" class="jobs-card-title">Последние задания</h2>
|
||||||
|
<button type="button" id="btn-jobs-clear" class="btn-secondary">Очистить завершённые</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="tbl-jobs" aria-label="Последние задания создания">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата, время <span class="th-hint">(UTC)</span></th>
|
||||||
|
<th>Кластер</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Сообщение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p id="jobs-msg" class="msg muted"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "partials/dashboard_modals.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="/static/js/dashboard.js" defer></script>
|
||||||
|
{% endblock %}
|
||||||
+20
-181
@@ -1,8 +1,9 @@
|
|||||||
{# Главная панель: кластеры, задания, создание.
|
{# Главная: создание (CTA), статистика, донаты, таблица кластеров.
|
||||||
Расширяет base.html; логика в /static/js/dashboard.js.
|
|
||||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block body_attrs %}data-dashboard-mode="home"{% endblock %}
|
||||||
|
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<p class="footer-copyright">
|
<p class="footer-copyright">
|
||||||
@@ -14,119 +15,34 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="top-dashboard-row">
|
<div class="top-dashboard-row">
|
||||||
<section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
|
<section class="card cta-hero-card" aria-labelledby="cta-hero-title">
|
||||||
<div class="hero-panel-main">
|
<div class="cta-hero-inner">
|
||||||
<h1 class="page-title" id="hero-title">Панель управления</h1>
|
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
|
||||||
<p class="muted page-lead">
|
<p class="muted page-lead cta-hero-lead">
|
||||||
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
|
Кластеры kind, kubeconfig и kubectl — ниже на странице. Данные обновляются автоматически.
|
||||||
Данные обновляются автоматически.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a>
|
||||||
<div
|
|
||||||
id="status-banner"
|
|
||||||
class="hero-panel-status muted"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
Проверка среды…
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="card stats-side-card" aria-labelledby="stats-heading">
|
<aside class="card stats-side-card" aria-labelledby="stats-heading">
|
||||||
<h2 id="stats-heading">Статистика</h2>
|
<h2 id="stats-heading">Статистика</h2>
|
||||||
<dl id="stats-dl" class="stats-dl stats-dl--compact" aria-label="Сводка по кластерам и заданиям"></dl>
|
<p id="stats-engine-hint" class="stats-engine-hint muted hidden" aria-live="polite"></p>
|
||||||
<div id="stats-resources" class="stats-resources" aria-live="polite"></div>
|
<div id="stats-failure-wrap" class="stats-failure-wrap hidden" role="alert" aria-live="polite"></div>
|
||||||
<p id="stats-err" class="msg hidden" role="alert"></p>
|
<div id="stats-env" class="stats-env-cards" role="status" aria-live="polite">
|
||||||
|
<p class="stats-env-placeholder muted">Проверка среды…</p>
|
||||||
|
</div>
|
||||||
|
<div id="stats-grid" class="stats-metrics-grid" aria-label="Сводка по кластерам и заданиям"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="card create-cluster-card">
|
<section id="aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="donuts-heading">
|
||||||
<h2>Создать кластер</h2>
|
<h2 id="donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
|
||||||
<form id="form-create" novalidate>
|
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
|
||||||
<div class="create-form-grid">
|
|
||||||
<div class="create-form-col">
|
|
||||||
<label for="fld-name">Имя (a-z0-9-)</label>
|
|
||||||
<input
|
|
||||||
id="fld-name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
|
|
||||||
maxlength="63"
|
|
||||||
placeholder="dev"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label for="fld-workers">Worker-ноды (0–20)</label>
|
|
||||||
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="create-form-col">
|
|
||||||
<label for="version-select">Тег образа kindest/node (подсказка) или введите версию вручную ниже</label>
|
|
||||||
<div class="row">
|
|
||||||
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
|
|
||||||
<option value="">— загрузить список —</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
|
|
||||||
<input
|
|
||||||
name="kubernetes_version"
|
|
||||||
id="kubernetes_version"
|
|
||||||
required
|
|
||||||
placeholder="1.29.4 или v1.29.4"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="create-form-span">
|
|
||||||
<div class="row create-actions">
|
|
||||||
<button type="submit">Создать кластер</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="create-progress-wrap" class="create-progress-wrap hidden" aria-hidden="true">
|
|
||||||
<p class="create-progress-hint muted" id="create-progress-hint">
|
|
||||||
Идёт операция — шаг <code>kind create</code> может занять несколько минут при первом pull образов.
|
|
||||||
Ниже выводится журнал (скачивание образов и подъём нод).
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
class="progress-track"
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"
|
|
||||||
aria-valuenow="0"
|
|
||||||
aria-labelledby="create-progress-label"
|
|
||||||
id="create-progress-track"
|
|
||||||
>
|
|
||||||
<div class="progress-bar" id="create-progress-bar" style="width:0%"></div>
|
|
||||||
</div>
|
|
||||||
<p class="progress-label" id="create-progress-label">—</p>
|
|
||||||
<div class="job-log-panel-wrap">
|
|
||||||
<label for="job-log-panel" class="job-log-label muted">Журнал задания</label>
|
|
||||||
<pre
|
|
||||||
id="job-log-panel"
|
|
||||||
class="mono job-log-panel"
|
|
||||||
role="log"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-relevant="additions"
|
|
||||||
></pre>
|
|
||||||
</div>
|
|
||||||
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="create-msg" class="msg" aria-live="polite"></p>
|
|
||||||
<details id="job-details" class="job-details hidden">
|
|
||||||
<summary>Технические детали (JSON)</summary>
|
|
||||||
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
|
|
||||||
</details>
|
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card clusters-card">
|
<section class="card clusters-card">
|
||||||
<h2>Кластеры</h2>
|
<h2>Кластеры</h2>
|
||||||
<p class="muted git-hint">
|
|
||||||
Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
|
|
||||||
</p>
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="tbl-clusters" aria-label="Список кластеров">
|
<table id="tbl-clusters" aria-label="Список кластеров">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -136,6 +52,7 @@
|
|||||||
<th>kubeconfig</th>
|
<th>kubeconfig</th>
|
||||||
<th>Версия</th>
|
<th>Версия</th>
|
||||||
<th>Workers</th>
|
<th>Workers</th>
|
||||||
|
<th>Состояние</th>
|
||||||
<th class="col-actions">Действия</th>
|
<th class="col-actions">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -145,85 +62,7 @@
|
|||||||
<p id="list-msg" class="msg muted"></p>
|
<p id="list-msg" class="msg muted"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card jobs-card">
|
{% include "partials/dashboard_modals.html" %}
|
||||||
<div class="row spread jobs-card-toolbar">
|
|
||||||
<h2 id="jobs-heading" class="jobs-card-title">Последние задания</h2>
|
|
||||||
<button type="button" id="btn-jobs-clear" class="btn-secondary">Очистить завершённые</button>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table id="tbl-jobs" aria-label="Последние задания создания">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Дата, время <span class="th-hint">(UTC)</span></th>
|
|
||||||
<th>Кластер</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
<th>Сообщение</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p id="jobs-msg" class="msg muted"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="modal-overlay"
|
|
||||||
class="modal-overlay hidden"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="modal-title"
|
|
||||||
>
|
|
||||||
<div class="modal-box">
|
|
||||||
<div class="modal-head row spread">
|
|
||||||
<h3 id="modal-title" class="modal-title-text">Состояние кластера</h3>
|
|
||||||
<button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
<div id="modal-dl-wrap" class="modal-dl-wrap hidden">
|
|
||||||
<span
|
|
||||||
class="icon-tooltip-host"
|
|
||||||
data-tooltip="Скачать kubeconfig для использования с kubectl на хосте"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="icon-btn"
|
|
||||||
id="modal-btn-kubeconfig"
|
|
||||||
aria-label="Скачать kubeconfig для kubectl на хосте"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<span class="muted modal-dl-hint">kubeconfig</span>
|
|
||||||
</div>
|
|
||||||
<div id="modal-spinner" class="modal-spinner hidden" aria-hidden="true">
|
|
||||||
<span class="spinner" aria-label="Загрузка"></span>
|
|
||||||
<span class="muted">Запрос kubectl…</span>
|
|
||||||
</div>
|
|
||||||
<p id="modal-sub" class="muted modal-sub"></p>
|
|
||||||
<h4 class="modal-section-title muted">Узлы</h4>
|
|
||||||
<pre id="modal-nodes" class="mono" role="region" aria-label="Вывод kubectl get nodes"></pre>
|
|
||||||
<h4 class="modal-section-title muted">Поды (все namespace)</h4>
|
|
||||||
<pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Модальное подтверждение вместо window.confirm (удаление, остановка узлов). #}
|
|
||||||
<div
|
|
||||||
id="confirm-modal-overlay"
|
|
||||||
class="modal-overlay confirm-modal-overlay hidden"
|
|
||||||
role="alertdialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="confirm-modal-title"
|
|
||||||
aria-describedby="confirm-modal-message"
|
|
||||||
>
|
|
||||||
<div class="modal-box modal-box--confirm">
|
|
||||||
<h3 id="confirm-modal-title" class="modal-title-text confirm-modal-title">Подтвердите действие</h3>
|
|
||||||
<p id="confirm-modal-message" class="modal-sub confirm-modal-message"></p>
|
|
||||||
<div class="confirm-modal-actions row spread">
|
|
||||||
<button type="button" class="btn-secondary" id="confirm-modal-cancel">Отмена</button>
|
|
||||||
<button type="button" id="confirm-modal-ok">Подтвердить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{# Полная ширина контента и подвала — см. .doc-layout-full в style.css #}
|
||||||
|
{% block body_extra_class %} doc-layout-full{% endblock %}
|
||||||
|
|
||||||
{% block page_title %}Документация{% endblock %}
|
{% block page_title %}Документация{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{# Модалки kubectl и подтверждения — общие для главной и страницы создания кластера.
|
||||||
|
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||||
|
<div
|
||||||
|
id="modal-overlay"
|
||||||
|
class="modal-overlay hidden"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head modal-head--cluster">
|
||||||
|
<h3 id="modal-title" class="modal-title-text modal-title-text--center">Состояние кластера</h3>
|
||||||
|
<div class="modal-toolbar" role="toolbar" aria-label="Действия в окне">
|
||||||
|
<div id="modal-dl-wrap" class="modal-dl-wrap hidden">
|
||||||
|
<span
|
||||||
|
class="icon-tooltip-host"
|
||||||
|
data-tooltip="Скачать kubeconfig для использования с kubectl на хосте"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
id="modal-btn-kubeconfig"
|
||||||
|
aria-label="Скачать kubeconfig для kubectl на хосте"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span class="muted modal-dl-hint">kubeconfig</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="modal-spinner" class="modal-spinner hidden" aria-hidden="true">
|
||||||
|
<span class="spinner" aria-label="Загрузка"></span>
|
||||||
|
<span class="muted">Запрос kubectl…</span>
|
||||||
|
</div>
|
||||||
|
<p id="modal-sub" class="muted modal-sub"></p>
|
||||||
|
<h4 class="modal-section-title muted">Узлы</h4>
|
||||||
|
<pre id="modal-nodes" class="mono" role="region" aria-label="Вывод kubectl get nodes"></pre>
|
||||||
|
<h4 class="modal-section-title muted">Поды (все namespace)</h4>
|
||||||
|
<pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="confirm-modal-overlay"
|
||||||
|
class="modal-overlay confirm-modal-overlay hidden"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-modal-title"
|
||||||
|
aria-describedby="confirm-modal-message"
|
||||||
|
>
|
||||||
|
<div class="modal-box modal-box--confirm">
|
||||||
|
<h3 id="confirm-modal-title" class="modal-title-text confirm-modal-title">Подтвердите действие</h3>
|
||||||
|
<p id="confirm-modal-message" class="modal-sub confirm-modal-message"></p>
|
||||||
|
<div class="confirm-modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="confirm-modal-cancel">Отмена</button>
|
||||||
|
<button type="button" id="confirm-modal-ok">Подтвердить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
KIND_K8S_DOCKER_PULL_PLAIN: ${KIND_K8S_DOCKER_PULL_PLAIN:-}
|
KIND_K8S_DOCKER_PULL_PLAIN: ${KIND_K8S_DOCKER_PULL_PLAIN:-}
|
||||||
# Сколько строк журнала отдавать в GET /api/v1/jobs/{id} (по умолчанию 5000 в коде).
|
# Сколько строк журнала отдавать в GET /api/v1/jobs/{id} (по умолчанию 5000 в коде).
|
||||||
KIND_K8S_JOB_API_LOG_MAX_LINES: ${KIND_K8S_JOB_API_LOG_MAX_LINES:-}
|
KIND_K8S_JOB_API_LOG_MAX_LINES: ${KIND_K8S_JOB_API_LOG_MAX_LINES:-}
|
||||||
|
KIND_K8S_JOBS_JSON: ${KIND_K8S_JOBS_JSON:-}
|
||||||
KIND_K8S_README_PATH: ${KIND_K8S_README_PATH:-}
|
KIND_K8S_README_PATH: ${KIND_K8S_README_PATH:-}
|
||||||
KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-}
|
KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-}
|
||||||
KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-}
|
KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ _SETUP_DEFAULTS: dict[str, str] = {
|
|||||||
"KIND_K8S_UVICORN_RELOAD": "1",
|
"KIND_K8S_UVICORN_RELOAD": "1",
|
||||||
"KIND_K8S_WEB_HOST": "0.0.0.0",
|
"KIND_K8S_WEB_HOST": "0.0.0.0",
|
||||||
"KIND_K8S_WORKDIR": "",
|
"KIND_K8S_WORKDIR": "",
|
||||||
|
"KIND_K8S_JOBS_JSON": "",
|
||||||
"COMPOSE_BUILD_FLAGS": "",
|
"COMPOSE_BUILD_FLAGS": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +117,11 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
|
|||||||
),
|
),
|
||||||
_EnvPrompt(None, "KIND_K8S_WAIT_NODES_TIMEOUT_SEC", "Таймаут kubectl wait (секунды)."),
|
_EnvPrompt(None, "KIND_K8S_WAIT_NODES_TIMEOUT_SEC", "Таймаут kubectl wait (секунды)."),
|
||||||
_EnvPrompt(None, "KIND_K8S_APP_TITLE", "Заголовок OpenAPI и веб-интерфейса."),
|
_EnvPrompt(None, "KIND_K8S_APP_TITLE", "Заголовок OpenAPI и веб-интерфейса."),
|
||||||
|
_EnvPrompt(
|
||||||
|
None,
|
||||||
|
"KIND_K8S_JOBS_JSON",
|
||||||
|
"Путь к JSON с историей заданий (создание/старт/стоп кластера); пусто — файл kind_k8s_jobs.json в каталоге clusters/ на томе данных.",
|
||||||
|
),
|
||||||
_EnvPrompt(
|
_EnvPrompt(
|
||||||
None,
|
None,
|
||||||
"KIND_K8S_README_PATH",
|
"KIND_K8S_README_PATH",
|
||||||
|
|||||||
Reference in New Issue
Block a user