From 88c042354cb250edc9943de548515a1d43c568b7 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 07:58:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-UI:=20=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B,=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20API,=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Шапка: логотип Kubernetes, ссылка на главную, выпадающее меню API (Swagger/ReDoc/Health), переключатель светлой/тёмной темы (localStorage). - Светлая тема в синей гамме; выравнивание кнопки темы в ряду с пилюлями. - Дашборд: единая карточка ошибки health/stats, подсказка Docker/Podman, поле container_cli в GET /stats, total_workers_from_meta всегда число (0 без meta). - Правки кластеров, job_store, compose, документация и частичные шаблоны. --- .gitignore | 2 + README.md | 7 +- app/api/v1/endpoints/clusters.py | 21 +- app/core/cluster_lifecycle.py | 70 +- app/core/container_resource_stats.py | 180 +++++- app/core/job_store.py | 164 ++++- app/docs/api_routes.md | 30 +- app/main.py | 17 +- app/models/schemas.py | 33 +- app/static/icons/kubernetes.svg | 1 + app/static/js/dashboard.js | 531 +++++++++++---- app/static/js/theme.js | 59 ++ app/static/style.css | 642 ++++++++++++++++--- app/templates/base.html | 175 ++++- app/templates/cluster_create.html | 132 ++++ app/templates/dashboard.html | 201 +----- app/templates/documentation.html | 3 + app/templates/partials/dashboard_modals.html | 61 ++ docker-compose.yml | 1 + scripts/setup_env_interactive.py | 6 + 20 files changed, 1885 insertions(+), 451 deletions(-) create mode 100644 app/static/icons/kubernetes.svg create mode 100644 app/static/js/theme.js create mode 100644 app/templates/cluster_create.html create mode 100644 app/templates/partials/dashboard_modals.html diff --git a/.gitignore b/.gitignore index 99843d2..b8d9444 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ __pycache__/ *$py.class clusters/*/ !clusters/.gitkeep +# История фоновых заданий (восстанавливается после перезапуска контейнера) +clusters/kind_k8s_jobs.json diff --git a/README.md b/README.md index 0373e49..85f6b98 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 87deab5..c7ecf62 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -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 {}, ) diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index ccc2e15..fff0eed 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -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``). diff --git a/app/core/container_resource_stats.py b/app/core/container_resource_stats.py index 86fe08f..b3d7b5a 100644 --- a/app/core/container_resource_stats.py +++ b/app/core/container_resource_stats.py @@ -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]: + """ + Один вызов `` 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]: """Вызов `` stats --no-stream`` для списка имён (только запущенные).""" if not names: diff --git a/app/core/job_store.py b/app/core/job_store.py index c721710..7db1138 100644 --- a/app/core/job_store.py +++ b/app/core/job_store.py @@ -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`` (абсолютный или относительный путь). + По умолчанию: ``/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 diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 6c9eb80..ee02e20 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -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 diff --git a/app/main.py b/app/main.py index a06327b..d81fdb6 100644 --- a/app/main.py +++ b/app/main.py @@ -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="

Шаблоны не найдены. Ожидается каталог app/templates/

", @@ -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="

Шаблоны не найдены. Ожидается каталог app/templates/

", + 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.""" diff --git a/app/models/schemas.py b/app/models/schemas.py index 08f0177..50598a4 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -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): diff --git a/app/static/icons/kubernetes.svg b/app/static/icons/kubernetes.svg new file mode 100644 index 0000000..b9f95b5 --- /dev/null +++ b/app/static/icons/kubernetes.svg @@ -0,0 +1 @@ + diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 2e64394..c97232d 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -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,143 +254,363 @@ }, 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 = "Среда: "; - 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 += - "
" + - escapeHtml(String(h.container_engine_detail).slice(0, 400)) + - ""; - } - 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) { - const row = document.createElement("div"); - row.className = "stats-node-res"; - const code = document.createElement("code"); - code.textContent = n.container_name; - row.appendChild(code); - const ul = document.createElement("ul"); - ul.className = "stats-node-res__metrics"; - function addMetric(label, val) { - if (val == null || val === "") return; - const li = document.createElement("li"); - const strong = document.createElement("strong"); - strong.textContent = label + ": "; - li.appendChild(strong); - li.appendChild(document.createTextNode(String(val))); - ul.appendChild(li); - } - addMetric("CPU", n.cpu_percent); - addMetric("Память", n.memory_usage); - addMetric("% RAM", n.memory_percent); - addMetric("Сеть", n.net_io); - addMetric("Диск", n.block_io); - addMetric("PID", n.pids != null ? String(n.pids) : null); - row.appendChild(ul); - sec.appendChild(row); - }); + + 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(sec); + wrap.appendChild(meta); + return wrap; + } + + const ramEngine = titleCaseContainerCli(containerCliRaw); + const grid = document.createElement("div"); + grid.className = "donut-grid"; + grid.appendChild( + donutItem("CPU (ср.)", agg.cpu_ring, String(agg.cpu_label || "—"), "узлов: " + agg.nodes_count), + ); + grid.appendChild( + donutItem("% RAM (" + ramEngine + ")", agg.memory_percent_ring, String(agg.memory_percent_label || "—"), ""), + ); + grid.appendChild( + donutItem("RAM used/limit", agg.memory_used_ratio_ring, String(agg.memory_used_ratio_label || "—"), ""), + ); + grid.appendChild( + donutItem("Сеть Σ", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"), + ); + grid.appendChild( + donutItem("Диск Σ", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"), + ); + root.appendChild(grid); + } + + /** + * Раскрывающийся блок метрик узлов под строкой таблицы кластеров. + * @param {string} clusterName + */ + function buildClusterResourcesDetails(clusterName) { + const cr = cachedClusterResourcesByName[clusterName]; + const details = document.createElement("details"); + details.className = "cluster-nodes-resources"; + const summ = document.createElement("summary"); + summ.textContent = "Ресурсы узлов (CPU, RAM, сеть, диск)"; + details.appendChild(summ); + if (!cr || !(cr.nodes && cr.nodes.length)) { + const p = document.createElement("p"); + p.className = "muted cluster-nodes-resources__empty"; + p.textContent = cr && cr.note ? cr.note : "Нет данных: узлы остановлены или метрики недоступны."; + details.appendChild(p); + return details; + } + cr.nodes.forEach(function (n) { + const row = document.createElement("div"); + row.className = "stats-node-res cluster-row-node-res"; + const code = document.createElement("code"); + code.textContent = n.container_name; + row.appendChild(code); + const ul = document.createElement("ul"); + ul.className = "stats-node-res__metrics"; + function addMetric(label, val) { + if (val == null || val === "") return; + const li = document.createElement("li"); + const strong = document.createElement("strong"); + strong.textContent = label + ": "; + li.appendChild(strong); + li.appendChild(document.createTextNode(String(val))); + ul.appendChild(li); + } + addMetric("CPU", n.cpu_percent); + addMetric("Память", n.memory_usage); + addMetric("% RAM", n.memory_percent); + addMetric("Сеть", n.net_io); + addMetric("Диск", n.block_io); + addMetric("PID", n.pids != null ? String(n.pids) : null); + row.appendChild(ul); + details.appendChild(row); }); + return details; } async function loadStats() { - 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 = + "Запущен"; + } else { + stateCell = + "Остановлен"; + } + } else { + stateCell = + ""; + } tr.innerHTML = "" + nameEsc + @@ -446,6 +693,9 @@ "" + escapeHtml(String(wn)) + "" + + "" + + stateCell + + "" + ""; 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(); - loadVersions(); - loadClusters(); - loadJobs(); + (async function bootstrap() { + try { + await loadHealth(); + if (isHomePage()) { + await loadStats(); + await loadClusters(); + } + if (isCreatePage()) { + await loadJobs(); + } + } catch (e) { + /* см. сообщения в блоках */ + } + })(); + if (isCreatePage()) { + loadVersions(); + } bindActionTooltipHosts(document.getElementById("modal-overlay")); } diff --git a/app/static/js/theme.js b/app/static/js/theme.js new file mode 100644 index 0000000..f98be2e --- /dev/null +++ b/app/static/js/theme.js @@ -0,0 +1,59 @@ +/** + * Переключение светлой/тёмной темы: data-theme на , ключ 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: скрипт в выполняется после разбора документа — кнопка уже есть. */ + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/app/static/style.css b/app/static/style.css index 202eacb..bd5a710 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; - --card: #ffffff; - --border: #e5e7eb; - } +/* Светлая тема: основной текст и акценты в синей гамме (как тёмная), без «чистого» чёрного */ +html[data-theme="light"] { + color-scheme: light; + --bg: #eef2fb; + --fg: #172c46; + --muted: #5a6f8f; + --card: #ffffff; + --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; +} +/* Визуально кнопка темы сидела ниже пилюль ( vs + + + @@ -53,16 +151,63 @@ {% endblock %} - {# Отдельное окно браузера для документации и health (не вкладка). #} + {# Отдельное окно для Swagger/ReDoc/Health; выпадающее меню API. #} diff --git a/app/templates/cluster_create.html b/app/templates/cluster_create.html new file mode 100644 index 0000000..bd88649 --- /dev/null +++ b/app/templates/cluster_create.html @@ -0,0 +1,132 @@ +{# Создание кластера, журнал задания и последние задания. + Автор: Сергей Антропов — https://devops.org.ru #} +{% extends "base.html" %} + +{% block body_attrs %}data-dashboard-mode="create"{% endblock %} + +{% block footer %} + +{% endblock %} + +{% block content %} +
+

Создание кластера

+

+ Укажите имя, версию образа kindest/node и число worker-нод. Операция выполняется в фоне; ход смотрите в журнале ниже. +

+
+ +
+

Новый кластер

+
+
+
+ + + + + +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+
+
+ + + +

+ +
+
+ +
+
+

Последние задания

+ +
+
+ + + + + + + + + + +
Дата, время (UTC)КластерСтатусСообщение
+
+

+
+ +{% include "partials/dashboard_modals.html" %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 3963112..7f27927 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -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 %}