Веб-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:
Sergey Antropoff
2026-04-04 07:58:19 +03:00
parent aa8003061e
commit 6f3daa33ec
20 changed files with 1885 additions and 451 deletions
+2
View File
@@ -9,3 +9,5 @@ __pycache__/
*$py.class
clusters/*/
!clusters/.gitkeep
# История фоновых заданий (восстанавливается после перезапуска контейнера)
clusters/kind_k8s_jobs.json
+4 -3
View File
@@ -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 -4
View File
@@ -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 {},
)
+55 -11
View File
@@ -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``).
+179 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+1
View File
@@ -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
View File
@@ -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"));
}
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}
+132
View File
@@ -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-ноды (020)</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
View File
@@ -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-ноды (020)</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 %}
+3
View File
@@ -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>
+1
View File
@@ -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:-}
+6
View File
@@ -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",