Веб-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
+57 -13
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)
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
return
raise
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
_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