Веб-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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user