Веб-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
|
||||
clusters/*/
|
||||
!clusters/.gitkeep
|
||||
# История фоновых заданий (восстанавливается после перезапуска контейнера)
|
||||
clusters/kind_k8s_jobs.json
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление.
|
||||
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
|
||||
- **Автообновление** таблиц и плашки среды каждые ~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**.
|
||||
|
||||
**Шапка:** навигация в виде **пилюль** (стили `.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_JOB_LOG_MAX_LINES`** | приложение | Сколько строк журнала хранить в памяти на задание (старые вытесняются); по умолчанию **2500** |
|
||||
| **`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_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_WORKDIR`** | локальный запуск | Корень данных на машине разработчика без compose |
|
||||
| **`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 |
|
||||
| `requirements.txt` | pip-зависимости веб-приложения |
|
||||
| `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/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
|
||||
| `app/models/schemas.py` | Pydantic-схемы запросов/ответов API |
|
||||
|
||||
@@ -17,6 +17,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from core.cluster_lifecycle import (
|
||||
KindClusterError,
|
||||
cluster_summary_for_api,
|
||||
configured_container_cli,
|
||||
create_cluster_non_interactive,
|
||||
delete_kind_cluster_and_data,
|
||||
kubectl_nodes_wide,
|
||||
@@ -27,7 +28,11 @@ from core.cluster_lifecycle import (
|
||||
stop_kind_cluster_containers,
|
||||
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 (
|
||||
JobRecord,
|
||||
end_job_tracking,
|
||||
@@ -39,6 +44,7 @@ from core.job_store import (
|
||||
from core.kind_guard import kind_cluster_lock
|
||||
from kind_k8s_paths import clusters_dir
|
||||
from models.schemas import (
|
||||
AggregateResourcesSummary,
|
||||
ClusterCreateAccepted,
|
||||
ClusterCreateRequest,
|
||||
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("."))
|
||||
|
||||
total_workers = 0
|
||||
counted = False
|
||||
for name in subdirs:
|
||||
meta = read_meta_json(name)
|
||||
if not meta:
|
||||
@@ -110,7 +115,6 @@ def _stats_sync() -> StatsResponse:
|
||||
continue
|
||||
try:
|
||||
total_workers += int(w)
|
||||
counted = True
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
@@ -118,15 +122,22 @@ def _stats_sync() -> StatsResponse:
|
||||
failed = sum(1 for j in jobs if j.status == "failed")
|
||||
|
||||
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(
|
||||
container_cli=configured_container_cli(),
|
||||
kind_clusters_count=len(kind_names),
|
||||
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_recent_failed=failed,
|
||||
cluster_resources=res_blocks,
|
||||
cluster_resources_error=res_err,
|
||||
aggregate_cluster_resources=agg,
|
||||
)
|
||||
|
||||
|
||||
@@ -163,6 +174,7 @@ async def list_clusters() -> list[ClusterSummary]:
|
||||
if cdir.is_dir():
|
||||
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)
|
||||
running_map = running_kind_clusters_mask(all_names)
|
||||
out: list[ClusterSummary] = []
|
||||
for name in all_names:
|
||||
summary = cluster_summary_for_api(name)
|
||||
@@ -170,6 +182,7 @@ async def list_clusters() -> list[ClusterSummary]:
|
||||
ClusterSummary(
|
||||
name=str(summary["name"]),
|
||||
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"]),
|
||||
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:
|
||||
"""
|
||||
``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()
|
||||
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:
|
||||
"""
|
||||
Скачать образ узлов через ``docker pull`` / ``podman pull`` с выводом в журнал задания.
|
||||
@@ -316,7 +351,12 @@ def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], jo
|
||||
"Скачивание образа недоступно: не найдена программа для работы с контейнерами (docker/podman).",
|
||||
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:
|
||||
_run_checked_stream(
|
||||
[cli, "pull", "--progress=plain", image],
|
||||
@@ -326,16 +366,12 @@ def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], jo
|
||||
)
|
||||
return
|
||||
except KindClusterError as e:
|
||||
# У docker CLI код 125 — «неверная команда/флаг»; тогда пробуем pull без plain.
|
||||
err_low = str(e).lower()
|
||||
if e.exit_code == 125 or any(
|
||||
x in err_low
|
||||
for x in ("progress", "unknown flag", "bad flag", "invalid option", "unknown shorthand")
|
||||
):
|
||||
logger.warning("Повтор docker pull без --progress=plain: %s", e)
|
||||
if e.exit_code == 130:
|
||||
raise
|
||||
# На всякий случай, если справка устарела или другой код ошибки.
|
||||
logger.warning("Повтор docker pull без --progress=plain (код %s): %s", e.exit_code, 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)
|
||||
|
||||
|
||||
@@ -800,6 +836,14 @@ def _container_cli_bin() -> str:
|
||||
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]:
|
||||
"""
|
||||
Проверить доступ к движку контейнеров (``docker info`` / ``podman info``).
|
||||
|
||||
@@ -13,8 +13,9 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
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")
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
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]:
|
||||
"""Все имена контейнеров (``ps -a``). При ошибке — ([], сообщение)."""
|
||||
p = subprocess.run(
|
||||
@@ -67,6 +87,164 @@ def _parse_pids(raw: str) -> int | 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]:
|
||||
"""Вызов ``<cli> stats --no-stream`` для списка имён (только запущенные)."""
|
||||
if not names:
|
||||
|
||||
+158
-6
@@ -1,6 +1,8 @@
|
||||
"""Хранилище фоновых заданий (создание кластера) в памяти процесса.
|
||||
"""Хранилище фоновых заданий (создание/старт/стоп кластера).
|
||||
|
||||
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
|
||||
Снимок заданий сохраняется в JSON в каталоге ``clusters/`` (том на хосте), чтобы после
|
||||
перезапуска контейнера история не терялась. Записи ``queued``/``running`` при старте
|
||||
помечаются как ``failed`` — рабочий процесс уже не существует.
|
||||
|
||||
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
|
||||
|
||||
@@ -11,6 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
@@ -20,13 +23,19 @@ import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
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")
|
||||
|
||||
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
|
||||
# Лимит записей в памяти и в файле (dev-инструмент; старые задания вытесняются)
|
||||
_MAX_JOBS = 200
|
||||
|
||||
# Версия формата JSON на диске
|
||||
_JOBS_FILE_VERSION = 1
|
||||
|
||||
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||
|
||||
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
|
||||
@@ -40,6 +49,19 @@ _process_lock = threading.Lock()
|
||||
_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:
|
||||
# Буфер в памяти: старые строки вытесняются; по умолчанию 2500 — виден почти весь типичный лог.
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""Потокобезопасное (asyncio) хранилище заданий."""
|
||||
"""Потокобезопасное (asyncio) хранилище заданий с персистентностью в JSON."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._jobs: dict[str, JobRecord] = {}
|
||||
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:
|
||||
"""Зарегистрировать задание в статусе ``queued``."""
|
||||
@@ -203,6 +348,7 @@ class JobStore:
|
||||
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
|
||||
begin_job_tracking(jid)
|
||||
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
|
||||
await self._persist_async()
|
||||
return rec
|
||||
|
||||
async def set_running(
|
||||
@@ -217,6 +363,7 @@ class JobStore:
|
||||
self._jobs[job_id].status = "running"
|
||||
self._jobs[job_id].message = None
|
||||
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:
|
||||
logs = take_logs_finalize_sync(job_id)
|
||||
@@ -227,6 +374,7 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
self._jobs[job_id].log_lines = logs
|
||||
set_progress_sync(job_id, "Готово", 100)
|
||||
await self._persist_async()
|
||||
|
||||
async def set_failed(self, job_id: str, message: str) -> None:
|
||||
logs = take_logs_finalize_sync(job_id)
|
||||
@@ -236,6 +384,7 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
self._jobs[job_id].log_lines = logs
|
||||
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
|
||||
await self._persist_async()
|
||||
|
||||
async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None:
|
||||
logs = take_logs_finalize_sync(job_id)
|
||||
@@ -245,6 +394,7 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
self._jobs[job_id].log_lines = logs
|
||||
logger.info("Задание %s отменено: %s", job_id, message)
|
||||
await self._persist_async()
|
||||
|
||||
async def get(self, job_id: str) -> JobRecord | None:
|
||||
async with self._lock:
|
||||
@@ -276,7 +426,9 @@ class JobStore:
|
||||
del self._jobs[jid]
|
||||
for jid in to_remove:
|
||||
end_job_tracking(jid)
|
||||
return len(to_remove)
|
||||
n = len(to_remove)
|
||||
await self._persist_async()
|
||||
return n
|
||||
|
||||
|
||||
# Синглтон на процесс uvicorn
|
||||
|
||||
+25
-5
@@ -56,11 +56,12 @@
|
||||
|
||||
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
|
||||
- В памяти держится не более **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).
|
||||
- В ответе задания поля **`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**).
|
||||
- Буфер строк в памяти на задание: `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` (остановка узлов).
|
||||
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
|
||||
|
||||
@@ -163,12 +164,13 @@ Accept: text/markdown
|
||||
|
||||
## 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:**
|
||||
|
||||
```json
|
||||
{
|
||||
"container_cli": "docker",
|
||||
"kind_clusters_count": 1,
|
||||
"local_cluster_dirs_count": 1,
|
||||
"total_workers_from_meta": 2,
|
||||
@@ -200,15 +202,30 @@ Accept: text/markdown
|
||||
"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_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку).
|
||||
- `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",
|
||||
"registered_in_kind": true,
|
||||
"kind_nodes_running": true,
|
||||
"has_local_kubeconfig": true,
|
||||
"meta": {
|
||||
"cluster_name": "dev",
|
||||
@@ -235,6 +253,8 @@ Accept: text/markdown
|
||||
]
|
||||
```
|
||||
|
||||
- `kind_nodes_running` — в списке процессов контейнерного движка есть узлы kind с префиксом имени (`имя-control-plane`, `имя-worker`…); для UI: при `true` показывается действие «Стоп», иначе при необходимости подъёма — «Старт».
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/jobs
|
||||
|
||||
+16
-1
@@ -61,7 +61,7 @@ templates = Jinja2Templates(directory=str(_templates_dir))
|
||||
|
||||
@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс")
|
||||
async def dashboard(request: Request) -> HTMLResponse:
|
||||
"""Главная страница с формой создания и таблицей кластеров."""
|
||||
"""Главная страница: обзор, сводка по узлам, таблица кластеров."""
|
||||
if not _templates_dir.is_dir():
|
||||
return HTMLResponse(
|
||||
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)
|
||||
async def ui_redirect() -> RedirectResponse:
|
||||
"""Удобный алиас на корень UI."""
|
||||
|
||||
+32
-1
@@ -54,6 +54,10 @@ class ClusterSummary(BaseModel):
|
||||
|
||||
name: str
|
||||
registered_in_kind: bool
|
||||
kind_nodes_running: bool = Field(
|
||||
default=False,
|
||||
description="Запущены контейнеры узлов kind (имя-control-plane, имя-worker…; docker/podman ps)",
|
||||
)
|
||||
has_local_kubeconfig: bool
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -78,12 +82,35 @@ class KindClusterResources(BaseModel):
|
||||
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):
|
||||
"""Краткая статистика для дашборда."""
|
||||
|
||||
container_cli: str = Field(
|
||||
description="CLI движка контейнеров (docker или podman): узлы kind и метрики собираются через него",
|
||||
)
|
||||
kind_clusters_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_recent_failed: int
|
||||
cluster_resources: list[KindClusterResources] = Field(
|
||||
@@ -94,6 +121,10 @@ class StatsResponse(BaseModel):
|
||||
default=None,
|
||||
description="Глобальная ошибка сбора метрик (например CLI не найден)",
|
||||
)
|
||||
aggregate_cluster_resources: AggregateResourcesSummary = Field(
|
||||
default_factory=AggregateResourcesSummary,
|
||||
description="Агрегаты по узлам для донатов на главной странице",
|
||||
)
|
||||
|
||||
|
||||
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 |
+384
-103
@@ -28,6 +28,20 @@
|
||||
var currentPollJobId = null;
|
||||
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
|
||||
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) {
|
||||
if (!data) return fallback;
|
||||
@@ -240,85 +254,286 @@
|
||||
}, 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;
|
||||
var cls = "hero-panel-status muted ";
|
||||
if (ok) cls += "ok";
|
||||
else if (degraded) cls += "degraded";
|
||||
else cls += "err";
|
||||
el.className = cls;
|
||||
const name = titleCaseContainerCli(cliRaw);
|
||||
el.textContent =
|
||||
"Узлы kind (контейнеры control-plane и worker) и метрики ниже собираются через " + name + ".";
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
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() {
|
||||
const el = document.getElementById("status-banner");
|
||||
const el = document.getElementById("stats-env");
|
||||
if (!el) return;
|
||||
try {
|
||||
const h = await api("/health");
|
||||
const ok =
|
||||
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
|
||||
setStatusBannerClass(ok, !ok);
|
||||
var lines = "<strong>Среда:</strong> ";
|
||||
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;
|
||||
cachedHealthPayload = h;
|
||||
revealStatsCardBody();
|
||||
paintStatsEnvRow();
|
||||
updateStatsEngineHint(h.container_cli);
|
||||
} catch (e) {
|
||||
setStatusBannerClass(false, false);
|
||||
el.textContent = "Не удалось запросить health: " + e.message;
|
||||
cachedHealthPayload = null;
|
||||
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) {
|
||||
const wrap = document.getElementById("stats-resources");
|
||||
if (!wrap) return;
|
||||
wrap.replaceChildren();
|
||||
if (s.cluster_resources_error) {
|
||||
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
|
||||
const root = document.getElementById("aggregate-donuts");
|
||||
if (!root) return;
|
||||
|
||||
root.replaceChildren();
|
||||
if (resourcesError) {
|
||||
const p = document.createElement("p");
|
||||
p.className = "muted stats-resources-msg";
|
||||
p.textContent = "Ресурсы узлов: " + s.cluster_resources_error;
|
||||
wrap.appendChild(p);
|
||||
p.className = "muted aggregate-donuts-placeholder";
|
||||
p.textContent = "Метрики узлов недоступны: " + resourcesError;
|
||||
root.appendChild(p);
|
||||
return;
|
||||
}
|
||||
const list = s.cluster_resources || [];
|
||||
if (!list.length) {
|
||||
if (!agg || !agg.nodes_count) {
|
||||
const p2 = document.createElement("p");
|
||||
p2.className = "muted stats-resources-msg";
|
||||
p2.textContent = "Нет кластеров в kind — метрики узлов пусты.";
|
||||
wrap.appendChild(p2);
|
||||
p2.className = "muted aggregate-donuts-placeholder";
|
||||
p2.textContent = "Нет запущенных узлов — диаграммы появятся после старта кластера.";
|
||||
root.appendChild(p2);
|
||||
return;
|
||||
}
|
||||
const h3 = document.createElement("h3");
|
||||
h3.className = "stats-resources-heading";
|
||||
h3.textContent = "Ресурсы узлов (CPU, RAM, I/O)";
|
||||
wrap.appendChild(h3);
|
||||
list.forEach(function (cr) {
|
||||
const sec = document.createElement("section");
|
||||
sec.className = "stats-cluster-res";
|
||||
const h4 = document.createElement("h4");
|
||||
h4.className = "stats-cluster-res__title";
|
||||
h4.textContent = cr.cluster_name;
|
||||
sec.appendChild(h4);
|
||||
const nodes = cr.nodes || [];
|
||||
if (!nodes.length) {
|
||||
const pn = document.createElement("p");
|
||||
pn.className = "muted stats-cluster-res__note";
|
||||
pn.textContent = cr.note || "Нет запущенных контейнеров узлов.";
|
||||
sec.appendChild(pn);
|
||||
} else {
|
||||
nodes.forEach(function (n) {
|
||||
|
||||
function donutItem(title, ringPct, centerLabel, subLine) {
|
||||
const p = Math.max(0, Math.min(100, Number(ringPct) || 0));
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "donut-item";
|
||||
wrap.setAttribute("role", "group");
|
||||
wrap.setAttribute("aria-label", title);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 110 110");
|
||||
svg.setAttribute("class", "donut-svg");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
const track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
track.setAttribute("class", "donut-track");
|
||||
track.setAttribute("cx", "55");
|
||||
track.setAttribute("cy", "55");
|
||||
track.setAttribute("r", "40");
|
||||
track.setAttribute("fill", "none");
|
||||
track.setAttribute("stroke-width", "9");
|
||||
track.setAttribute("pathLength", "100");
|
||||
const fill = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
fill.setAttribute("class", "donut-fill");
|
||||
fill.setAttribute("cx", "55");
|
||||
fill.setAttribute("cy", "55");
|
||||
fill.setAttribute("r", "40");
|
||||
fill.setAttribute("fill", "none");
|
||||
fill.setAttribute("stroke-width", "9");
|
||||
fill.setAttribute("pathLength", "100");
|
||||
fill.setAttribute("stroke-dasharray", p + " " + (100 - p));
|
||||
fill.setAttribute("transform", "rotate(-90 55 55)");
|
||||
svg.appendChild(track);
|
||||
svg.appendChild(fill);
|
||||
wrap.appendChild(svg);
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "donut-meta";
|
||||
const tEl = document.createElement("span");
|
||||
tEl.className = "donut-meta__title";
|
||||
tEl.textContent = title;
|
||||
meta.appendChild(tEl);
|
||||
const vEl = document.createElement("span");
|
||||
vEl.className = "donut-meta__val";
|
||||
vEl.textContent = centerLabel || "—";
|
||||
meta.appendChild(vEl);
|
||||
if (subLine) {
|
||||
const sEl = document.createElement("span");
|
||||
sEl.className = "donut-meta__sub muted";
|
||||
sEl.textContent = subLine;
|
||||
meta.appendChild(sEl);
|
||||
}
|
||||
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";
|
||||
row.className = "stats-node-res cluster-row-node-res";
|
||||
const code = document.createElement("code");
|
||||
code.textContent = n.container_name;
|
||||
row.appendChild(code);
|
||||
@@ -340,43 +555,62 @@
|
||||
addMetric("Диск", n.block_io);
|
||||
addMetric("PID", n.pids != null ? String(n.pids) : null);
|
||||
row.appendChild(ul);
|
||||
sec.appendChild(row);
|
||||
});
|
||||
}
|
||||
wrap.appendChild(sec);
|
||||
details.appendChild(row);
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const dl = document.getElementById("stats-dl");
|
||||
const errEl = document.getElementById("stats-err");
|
||||
if (!dl) return;
|
||||
const grid = document.getElementById("stats-grid");
|
||||
if (!grid) return;
|
||||
try {
|
||||
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 = [
|
||||
["Кластеров в kind", s.kind_clusters_count],
|
||||
["Локальных каталогов", s.local_cluster_dirs_count],
|
||||
["Сумма workers (meta)", s.total_workers_from_meta != null ? s.total_workers_from_meta : "—"],
|
||||
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
|
||||
["Заданий в памяти", s.jobs_total],
|
||||
["Заданий с ошибкой", s.jobs_recent_failed],
|
||||
];
|
||||
const frag = document.createDocumentFragment();
|
||||
grid.replaceChildren();
|
||||
rows.forEach(function (kv) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = kv[0];
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = String(kv[1]);
|
||||
frag.appendChild(dt);
|
||||
frag.appendChild(dd);
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile";
|
||||
const valEl = document.createElement("span");
|
||||
valEl.className = "stats-tile__value";
|
||||
valEl.textContent = String(kv[1]);
|
||||
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);
|
||||
renderStatsResources(s);
|
||||
cachedClusterResourcesByName = {};
|
||||
(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) {
|
||||
errEl.textContent = "Статистика: " + e.message;
|
||||
errEl.classList.remove("hidden");
|
||||
const wrap = document.getElementById("stats-resources");
|
||||
if (wrap) wrap.replaceChildren();
|
||||
coverStatsCardWithError("Статистика", e.message || String(e));
|
||||
grid.replaceChildren();
|
||||
cachedClusterResourcesByName = {};
|
||||
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 wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
|
||||
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 =
|
||||
"<td><code class=\"cluster-name\">" +
|
||||
nameEsc +
|
||||
@@ -446,6 +693,9 @@
|
||||
"<td>" +
|
||||
escapeHtml(String(wn)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
stateCell +
|
||||
"</td>" +
|
||||
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
|
||||
const td = tr.querySelector(".actions-toolbar");
|
||||
td.appendChild(
|
||||
@@ -459,18 +709,7 @@
|
||||
false,
|
||||
),
|
||||
);
|
||||
td.appendChild(
|
||||
iconActionButton(
|
||||
ICONS.play,
|
||||
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
|
||||
"",
|
||||
function () {
|
||||
startCluster(c.name);
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
if (c.registered_in_kind) {
|
||||
if (c.registered_in_kind && c.kind_nodes_running) {
|
||||
td.appendChild(
|
||||
iconActionButton(
|
||||
ICONS.stop,
|
||||
@@ -482,6 +721,18 @@
|
||||
false,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
td.appendChild(
|
||||
iconActionButton(
|
||||
ICONS.play,
|
||||
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
|
||||
"",
|
||||
function () {
|
||||
startCluster(c.name);
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
td.appendChild(
|
||||
iconActionButton(
|
||||
@@ -510,6 +761,16 @@
|
||||
),
|
||||
);
|
||||
frag.appendChild(tr);
|
||||
if (isHomePage()) {
|
||||
const trExp = document.createElement("tr");
|
||||
trExp.className = "cluster-resources-expand-row";
|
||||
const tdExp = document.createElement("td");
|
||||
tdExp.colSpan = 7;
|
||||
tdExp.className = "cluster-resources-expand-cell";
|
||||
tdExp.appendChild(buildClusterResourcesDetails(c.name));
|
||||
trExp.appendChild(tdExp);
|
||||
frag.appendChild(trExp);
|
||||
}
|
||||
});
|
||||
tbody.replaceChildren(frag);
|
||||
bindActionTooltipHosts(document.getElementById("tbl-clusters"));
|
||||
@@ -968,11 +1229,19 @@
|
||||
pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
|
||||
}
|
||||
|
||||
function refreshLists() {
|
||||
loadHealth();
|
||||
loadStats();
|
||||
loadClusters();
|
||||
loadJobs();
|
||||
async function refreshLists() {
|
||||
try {
|
||||
await loadHealth();
|
||||
if (isHomePage()) {
|
||||
await loadStats();
|
||||
await loadClusters();
|
||||
}
|
||||
if (isCreatePage()) {
|
||||
await loadJobs();
|
||||
}
|
||||
} catch (e) {
|
||||
/* loadHealth/loadStats/loadClusters сами сообщают об ошибках в UI */
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
@@ -1078,11 +1347,23 @@
|
||||
|
||||
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
|
||||
|
||||
loadHealth();
|
||||
loadStats();
|
||||
(async function bootstrap() {
|
||||
try {
|
||||
await loadHealth();
|
||||
if (isHomePage()) {
|
||||
await loadStats();
|
||||
await loadClusters();
|
||||
}
|
||||
if (isCreatePage()) {
|
||||
await loadJobs();
|
||||
}
|
||||
} catch (e) {
|
||||
/* см. сообщения в блоках */
|
||||
}
|
||||
})();
|
||||
if (isCreatePage()) {
|
||||
loadVersions();
|
||||
loadClusters();
|
||||
loadJobs();
|
||||
}
|
||||
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();
|
||||
}
|
||||
})();
|
||||
+543
-93
@@ -1,25 +1,28 @@
|
||||
/* Минимальные стили веб-интерфейса Kind Clusters Dashboard.
|
||||
Автор: Сергей Антропов — https://devops.org.ru */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
/* Тема: тёмная по умолчанию; светлая — html[data-theme="light"]. Переключатель в шапке + localStorage. */
|
||||
html {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
}
|
||||
html:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1419;
|
||||
--fg: #e7ecf1;
|
||||
--muted: #8b98a5;
|
||||
--card: #1a2332;
|
||||
--accent: #3b82f6;
|
||||
--border: #2f3d52;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--fg: #111827;
|
||||
--muted: #6b7280;
|
||||
/* Светлая тема: основной текст и акценты в синей гамме (как тёмная), без «чистого» чёрного */
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #eef2fb;
|
||||
--fg: #172c46;
|
||||
--muted: #5a6f8f;
|
||||
--card: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
--border: #b8c9e8;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -70,13 +73,26 @@ body.modal-open {
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-logo {
|
||||
.nav-brand-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.nav-brand-link:hover .nav-title {
|
||||
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 {
|
||||
font-weight: 700;
|
||||
@@ -85,12 +101,163 @@ body.modal-open {
|
||||
.nav-tag {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
/* Шапка: один flex-ряд, все пилюли + API + тема с align-items: center */
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -135,11 +302,15 @@ body.modal-open {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
color: #93c5fd;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) {
|
||||
color: #1e40af;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
html[data-theme="light"] .nav-link.nav-pill {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
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;
|
||||
}
|
||||
|
||||
/* Страница /documentation: основной блок и подвал на всю ширину окна */
|
||||
.doc-layout-full .app-main,
|
||||
.doc-layout-full .app-footer {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
@@ -192,7 +371,7 @@ body.modal-open {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Верхний ряд: панель управления слева, статистика справа */
|
||||
/* Верхний ряд: CTA «Создать кластер» и статистика по ~50% ширины */
|
||||
.top-dashboard-row {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -202,69 +381,173 @@ body.modal-open {
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.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;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(85vh, 52rem);
|
||||
overflow: hidden;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.stats-dl--compact {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
.stats-failure-card {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 4.75rem;
|
||||
padding: 0.85rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Метрики узлов kind (docker stats) под сводкой */
|
||||
.stats-resources {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--border);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
font-size: 0.78rem;
|
||||
/* Среда: Docker API · Kind · Kubectl · Каталогов — один ряд, 4 колонки */
|
||||
.stats-env-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stats-resources-heading {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
@media (max-width: 520px) {
|
||||
.stats-env-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.stats-resources-msg {
|
||||
.stats-env-placeholder {
|
||||
grid-column: 1 / -1;
|
||||
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-weight: 650;
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.stats-cluster-res__note {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
.stats-env-card {
|
||||
min-height: 4.5rem;
|
||||
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 {
|
||||
margin-bottom: 0.45rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
@@ -290,10 +573,124 @@ body.modal-open {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.stats-node-res {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
html[data-theme="light"] .stats-node-res {
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -440,11 +837,6 @@ body.modal-open {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.git-hint {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -492,6 +884,9 @@ body.modal-open {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
html[data-theme="light"] .badge {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.badge-ok {
|
||||
border-color: #15803d;
|
||||
color: #4ade80;
|
||||
@@ -508,6 +903,10 @@ body.modal-open {
|
||||
border-color: #6b7280;
|
||||
color: #d1d5db;
|
||||
}
|
||||
.badge-stopped {
|
||||
border-color: #b45309;
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.cluster-name {
|
||||
font-weight: 600;
|
||||
@@ -521,6 +920,9 @@ body.modal-open {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
html[data-theme="light"] .job-details {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
.job-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
@@ -536,6 +938,27 @@ body.modal-open {
|
||||
margin-bottom: 0.5rem;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -543,6 +966,9 @@ body.modal-open {
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
.modal-toolbar .modal-dl-wrap {
|
||||
margin: 0;
|
||||
}
|
||||
.modal-dl-hint {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -562,6 +988,7 @@ body.modal-open {
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
@@ -689,6 +1116,9 @@ button:hover {
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
}
|
||||
html[data-theme="light"] .mono {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
@@ -886,13 +1316,11 @@ button.btn-small {
|
||||
border-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.icon-btn {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.icon-btn--danger {
|
||||
html[data-theme="light"] .icon-btn {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
html[data-theme="light"] .icon-btn--danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Модальное окно «Состояние кластера» */
|
||||
@@ -901,9 +1329,9 @@ button.btn-small {
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
padding: 1.25rem 1rem;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -916,18 +1344,25 @@ button.btn-small {
|
||||
}
|
||||
.modal-box--confirm {
|
||||
max-width: 24rem;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-modal-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-modal-message {
|
||||
margin: 0 0 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-modal-actions {
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem 0.85rem;
|
||||
margin-top: 1.15rem;
|
||||
}
|
||||
.confirm-modal-actions button {
|
||||
margin-top: 0;
|
||||
@@ -938,8 +1373,12 @@ button.btn-small {
|
||||
border-radius: 10px;
|
||||
max-width: 52rem;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
max-height: calc(100dvh - 2.5rem);
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.15rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
margin: auto;
|
||||
}
|
||||
.modal-box h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
@@ -960,11 +1399,9 @@ a.btn-danger {
|
||||
color: #f87171;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
button.btn-danger,
|
||||
a.btn-danger {
|
||||
html[data-theme="light"] button.btn-danger,
|
||||
html[data-theme="light"] a.btn-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
button.btn-danger:hover {
|
||||
filter: brightness(1.12);
|
||||
@@ -976,6 +1413,12 @@ button.btn-danger:hover {
|
||||
margin: 0 auto 0.65rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.doc-layout-full .doc-breadcrumbs {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.doc-breadcrumbs__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1017,6 +1460,12 @@ button.btn-danger:hover {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.doc-layout-full .readme-doc-shell {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.readme-doc-loading {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -1135,11 +1584,12 @@ button.btn-danger:hover {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.readme-section-card.markdown-body code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.readme-section-card.markdown-body pre {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
html[data-theme="light"] .readme-section-card.markdown-body code {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
html[data-theme="light"] .readme-section-card.markdown-body pre {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
html[data-theme="light"] .readme-section-card.markdown-body th {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
+156
-11
@@ -6,29 +6,127 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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" />
|
||||
<script src="/static/js/theme.js" defer></script>
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body
|
||||
class="app-body"
|
||||
class="app-body{% block body_extra_class %}{% endblock %}"
|
||||
data-api-base="/api/v1"
|
||||
data-app-title="{{ app_title | e }}"
|
||||
{% block body_attrs %}{% endblock %}
|
||||
>
|
||||
<a class="skip-link" href="#main-content">К основному содержимому</a>
|
||||
|
||||
<header class="top-nav" role="banner">
|
||||
<div class="top-nav-inner">
|
||||
<div class="nav-brand">
|
||||
<span class="nav-logo" aria-hidden="true">◈</span>
|
||||
{# Логотип Kubernetes (SVG из набора Simple Icons, CC0) + название — одна ссылка на главную. #}
|
||||
<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>
|
||||
</div>
|
||||
<nav class="nav-links" aria-label="Разделы">
|
||||
{# Панель подсвечивается только на дашборде; на странице документации — обычная пилюля, как Swagger/ReDoc. #}
|
||||
</a>
|
||||
{# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #}
|
||||
<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="/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>
|
||||
<a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
|
||||
<a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
|
||||
<div class="nav-dropdown">
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
@@ -53,17 +151,64 @@
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
{# Отдельное окно браузера для документации и health (не вкладка). #}
|
||||
{# Отдельное окно для Swagger/ReDoc/Health; выпадающее меню API. #}
|
||||
<script>
|
||||
(function () {
|
||||
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) {
|
||||
(scope || document).querySelectorAll("[data-open-window]").forEach(function (a) {
|
||||
if (a.tagName !== "A") return;
|
||||
a.addEventListener("click", function (e) {
|
||||
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>
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -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 @@
|
||||
{# Главная панель: кластеры, задания, создание.
|
||||
Расширяет base.html; логика в /static/js/dashboard.js.
|
||||
{# Главная: создание (CTA), статистика, донаты, таблица кластеров.
|
||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block body_attrs %}data-dashboard-mode="home"{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<div class="footer-inner">
|
||||
<p class="footer-copyright">
|
||||
@@ -14,119 +15,34 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="top-dashboard-row">
|
||||
<section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
|
||||
<div class="hero-panel-main">
|
||||
<h1 class="page-title" id="hero-title">Панель управления</h1>
|
||||
<p class="muted page-lead">
|
||||
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
|
||||
Данные обновляются автоматически.
|
||||
<section class="card cta-hero-card" aria-labelledby="cta-hero-title">
|
||||
<div class="cta-hero-inner">
|
||||
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
|
||||
<p class="muted page-lead cta-hero-lead">
|
||||
Кластеры kind, kubeconfig и kubectl — ниже на странице. Данные обновляются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="status-banner"
|
||||
class="hero-panel-status muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Проверка среды…
|
||||
<a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="card stats-side-card" aria-labelledby="stats-heading">
|
||||
<h2 id="stats-heading">Статистика</h2>
|
||||
<dl id="stats-dl" class="stats-dl stats-dl--compact" aria-label="Сводка по кластерам и заданиям"></dl>
|
||||
<div id="stats-resources" class="stats-resources" aria-live="polite"></div>
|
||||
<p id="stats-err" class="msg hidden" role="alert"></p>
|
||||
<p id="stats-engine-hint" class="stats-engine-hint muted hidden" aria-live="polite"></p>
|
||||
<div id="stats-failure-wrap" class="stats-failure-wrap hidden" role="alert" aria-live="polite"></div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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 id="aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="donuts-heading">
|
||||
<h2 id="donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
|
||||
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="card clusters-card">
|
||||
<h2>Кластеры</h2>
|
||||
<p class="muted git-hint">
|
||||
Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl-clusters" aria-label="Список кластеров">
|
||||
<thead>
|
||||
@@ -136,6 +52,7 @@
|
||||
<th>kubeconfig</th>
|
||||
<th>Версия</th>
|
||||
<th>Workers</th>
|
||||
<th>Состояние</th>
|
||||
<th class="col-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -145,85 +62,7 @@
|
||||
<p id="list-msg" class="msg muted"></p>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
{% include "partials/dashboard_modals.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{# Полная ширина контента и подвала — см. .doc-layout-full в style.css #}
|
||||
{% block body_extra_class %} doc-layout-full{% endblock %}
|
||||
|
||||
{% block page_title %}Документация{% endblock %}
|
||||
|
||||
{% 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:-}
|
||||
# Сколько строк журнала отдавать в GET /api/v1/jobs/{id} (по умолчанию 5000 в коде).
|
||||
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_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-}
|
||||
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_WEB_HOST": "0.0.0.0",
|
||||
"KIND_K8S_WORKDIR": "",
|
||||
"KIND_K8S_JOBS_JSON": "",
|
||||
"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_APP_TITLE", "Заголовок OpenAPI и веб-интерфейса."),
|
||||
_EnvPrompt(
|
||||
None,
|
||||
"KIND_K8S_JOBS_JSON",
|
||||
"Путь к JSON с историей заданий (создание/старт/стоп кластера); пусто — файл kind_k8s_jobs.json в каталоге clusters/ на томе данных.",
|
||||
),
|
||||
_EnvPrompt(
|
||||
None,
|
||||
"KIND_K8S_README_PATH",
|
||||
|
||||
Reference in New Issue
Block a user