From a5d75aa56bbd103e0c74967ea4b7575348b73b7b Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 13:42:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81:=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20/clusters,=20=D0=BD=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D0=BA=D1=80?= =?UTF-8?q?=D0=BE=D1=88=D0=BA=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env. --- .gitignore | 2 + Makefile | 102 ++- README.md | 18 +- app/api/v1/endpoints/cluster_addons.py | 233 +++++-- app/api/v1/endpoints/clusters.py | 2 + app/api/v1/endpoints/helm_charts.py | 119 +++- app/api/v1/endpoints/journal.py | 89 ++- app/core/cluster_lifecycle.py | 18 +- app/core/container_resource_stats.py | 4 +- app/core/helm_addons.py | 317 +++++++-- app/core/job_journal.py | 55 +- app/core/provision_log.py | 189 ++++++ app/create_cluster.py | 4 +- app/docs/api_routes.md | 112 +++- app/kind_k8s_paths.py | 19 + app/kubeconfig_patch.py | 6 +- app/main.py | 21 +- app/models/schemas.py | 135 +++- app/static/js/cluster_addons.js | 540 ++++++++++++++-- app/static/js/dashboard.js | 637 ++++++++++++------- app/static/js/journal.js | 322 ++++++---- app/static/js/theme.js | 49 ++ app/static/style.css | 343 ++++++++-- app/templates/base.html | 1 + app/templates/cluster_addons.html | 187 ++++-- app/templates/cluster_create.html | 16 +- app/templates/cluster_detail.html | 156 ++++- app/templates/cluster_edit.html | 2 +- app/templates/clusters.html | 81 +++ app/templates/dashboard.html | 35 +- app/templates/journal.html | 94 ++- app/templates/partials/dashboard_modals.html | 2 +- docker-compose.podman.yml | 19 + docker-compose.yml | 20 +- env.example | 8 + scripts/detect_podman_socket.py | 185 ++++++ scripts/probe_container_sockets.py | 314 +++++++++ scripts/setup_env_interactive.py | 268 +++++++- 38 files changed, 3990 insertions(+), 734 deletions(-) create mode 100644 app/templates/clusters.html create mode 100644 docker-compose.podman.yml create mode 100644 scripts/detect_podman_socket.py create mode 100644 scripts/probe_container_sockets.py diff --git a/.gitignore b/.gitignore index b8d9444..fb31ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # Содержимое clusters/<имя>/ в репозиторий не попадает (только clusters/.gitkeep). .DS_Store .env +# Генерируется «make podman …» — перекрывает CONTAINER_SOCKET из .env (устаревший путь в /var/folders). +.env.podman.override # Кэш и байткод Python (не коммитить) __pycache__/ diff --git a/Makefile b/Makefile index 62cb812..5c28d13 100644 --- a/Makefile +++ b/Makefile @@ -10,17 +10,50 @@ # # Автор: Сергей Антропов — https://devops.org.ru -ifneq (,$(filter podman,$(MAKECMDGOALS))) - COMPOSE := podman compose -else ifneq (,$(filter docker,$(MAKECMDGOALS))) - COMPOSE := docker compose -endif - -.PHONY: help docker podman _require_runtime up down restart logs ps setup clusters-dir check-docker build rebuild kubectl - KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py PYTHON ?= python3 + +ifneq (,$(filter podman,$(MAKECMDGOALS))) + COMPOSE := podman compose + # Сокет Podman на хосте (перекрывает CONTAINER_SOCKET из .env, если там путь Docker). + PODMAN_HOST_SOCK := $(shell $(PYTHON) "$(KIND_K8S_DIR)/scripts/detect_podman_socket.py") + # docker-compose из podman compose часто подставляет том из .env, игнорируя префикс VAR= в командной строке. + # Файл .env.podman.override (см. цель _podman_env_override) задаёт CONTAINER_SOCKET после .env. + PODMAN_COMPOSE_ENV_FILE := --env-file .env.podman.override + # user: в контейнере для доступа к смонтированному podman.sock: + # «podman compose» на macOS часто дергает docker-compose к API Podman — keep-id не срабатывает, uid с сокета даёт EACCES. + # По умолчанию 0:0 (как у типичного docker.sock); переопределение: KIND_K8S_PODMAN_CONTAINER_UIDGID=501:20 + # или KIND_K8S_PODMAN_USE_SOCKET_OWNER=1 (владелец по stat, как detect_podman_socket.py --print-owner). + ifneq (,$(KIND_K8S_PODMAN_USE_SOCKET_OWNER)) + _PODMAN_UIDGID := $(shell $(PYTHON) "$(KIND_K8S_DIR)/scripts/detect_podman_socket.py" --print-owner) + else + ifneq (,$(KIND_K8S_PODMAN_CONTAINER_UIDGID)) + _PODMAN_UIDGID := $(KIND_K8S_PODMAN_CONTAINER_UIDGID) + else + _PODMAN_UIDGID := 0:0 + endif + endif + _PODMAN_UID_FIRST := $(word 1,$(subst :, ,$(_PODMAN_UIDGID))) + _PODMAN_HOME := $(if $(filter 0,$(_PODMAN_UID_FIRST)),/root,/tmp) + ifneq (,$(findstring var/folders,$(PODMAN_HOST_SOCK))) + $(error PODMAN_HOST_SOCK содержит /var/folders/ — этот API-сокет нельзя смонтировать в compose. Обновите репозиторий (scripts/detect_podman_socket.py) и удалите такой путь из .env) + endif + # Внутри контейнера — путь podman, не docker.sock; DOCKER_HOST должен указывать на тот же путь (API совместим). + COMPOSE_PODMAN_ENV := KIND_K8S_CONTAINER_UIDGID=$(_PODMAN_UIDGID) KIND_K8S_CONTAINER_HOME=$(_PODMAN_HOME) CONTAINER_SOCKET=$(PODMAN_HOST_SOCK) CONTAINER_SOCKET_MOUNT_TARGET=/run/podman/podman.sock KIND_K8S_REMOTE_SOCKET_URI=unix:///run/podman/podman.sock + COMPOSE_FILE_ARGS := -f docker-compose.yml -f docker-compose.podman.yml +else ifneq (,$(filter docker,$(MAKECMDGOALS))) + COMPOSE := docker compose + # Сброс путей от Podman в .env: сокет в контейнере снова /var/run/docker.sock. + COMPOSE_DOCKER_OVERRIDES := CONTAINER_SOCKET_MOUNT_TARGET=/var/run/docker.sock KIND_K8S_REMOTE_SOCKET_URI=unix:///var/run/docker.sock +endif + +COMPOSE_PODMAN_ENV ?= +COMPOSE_DOCKER_OVERRIDES ?= +COMPOSE_FILE_ARGS ?= +PODMAN_COMPOSE_ENV_FILE ?= + +.PHONY: help docker podman _require_runtime _podman_env_override up down restart logs ps setup clusters-dir check-docker build rebuild kubectl probe-sockets # При «exec format error» у kind: make docker build COMPOSE_BUILD_FLAGS=--platform linux/arm64 COMPOSE_BUILD_FLAGS ?= # Для цели kubectl: имя кластера и аргументы kubectl после --kubeconfig (по умолчанию: get nodes). @@ -57,20 +90,35 @@ _require_runtime: exit 1; \ fi -up: _require_runtime clusters-dir build ## (с docker/podman) Поднять веб-UI в фоне - cd "$(KIND_K8S_DIR)" && $(COMPOSE) up -d kind-k8s-web +# Только для make podman …: перекрыть CONTAINER_SOCKET из .env (устаревший /var/folders/… ломает volume). +_podman_env_override: + @if [ -n "$(COMPOSE_PODMAN_ENV)" ]; then \ + printf '%s\n' \ + "CONTAINER_SOCKET=$(PODMAN_HOST_SOCK)" \ + "KIND_K8S_CONTAINER_UIDGID=$(_PODMAN_UIDGID)" \ + "KIND_K8S_CONTAINER_HOME=$(_PODMAN_HOME)" \ + "CONTAINER_SOCKET_MOUNT_TARGET=/run/podman/podman.sock" \ + "KIND_K8S_REMOTE_SOCKET_URI=unix:///run/podman/podman.sock" \ + > "$(KIND_K8S_DIR)/.env.podman.override"; \ + fi -down: _require_runtime ## (с docker/podman) Остановить compose в этом каталоге - cd "$(KIND_K8S_DIR)" && $(COMPOSE) down +up: _require_runtime _podman_env_override clusters-dir build ## (с docker/podman) Поднять веб-UI в фоне + cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) up -d kind-k8s-web -restart: _require_runtime ## (с docker/podman) Перезапустить контейнер kind-k8s-web - cd "$(KIND_K8S_DIR)" && $(COMPOSE) restart kind-k8s-web +down: _require_runtime _podman_env_override ## (с docker/podman) Остановить compose в этом каталоге + cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) down -logs: _require_runtime ## (с docker/podman) Логи kind-k8s-web (follow -f) - cd "$(KIND_K8S_DIR)" && $(COMPOSE) logs -f kind-k8s-web +restart: _require_runtime _podman_env_override ## (с docker/podman) Перезапустить контейнер kind-k8s-web + cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) restart kind-k8s-web -ps: _require_runtime ## (с docker/podman) Статус контейнеров compose-проекта - cd "$(KIND_K8S_DIR)" && $(COMPOSE) ps +logs: _require_runtime _podman_env_override ## (с docker/podman) Логи kind-k8s-web (follow -f) + cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) logs -f kind-k8s-web + +ps: _require_runtime _podman_env_override ## (с docker/podman) Статус контейнеров compose-проекта + cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) ps + +probe-sockets: ## Проверить сокеты Docker/Podman (доступ без permission denied) + @$(PYTHON) "$(KIND_K8S_DIR)/scripts/probe_container_sockets.py" setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте) @$(PYTHON) "$(SETUP_ENV_SCRIPT)" @@ -78,27 +126,27 @@ setup: ## Интерактивно создать .env (scripts/setup_env_intera clusters-dir: ## Каталог clusters/ для тома (если ещё нет) @mkdir -p "$(KIND_K8S_DIR)/clusters" -check-docker: _require_runtime ## (с docker/podman) Проверить CLI и compose +check-docker: _require_runtime _podman_env_override ## (с docker/podman) Проверить CLI и compose @case "$(COMPOSE)" in \ docker*) command -v docker >/dev/null 2>&1 || { echo >&2 "docker не найден в PATH."; exit 1; } ;; \ podman*) command -v podman >/dev/null 2>&1 || { echo >&2 "podman не найден в PATH."; exit 1; } ;; \ esac - @$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; } + @$(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; } @echo "$(COMPOSE): OK" # kubectl в образе; путь к kubeconfig — как у веб-UI (подстановка server через host.docker.internal, см. kubeconfig_patch.py). -kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"] +kubectl: _require_runtime _podman_env_override ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"] @if [ -z "$(CLUSTER)" ]; then \ echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \ echo >&2 "Пример: make docker kubectl CLUSTER=dev"; \ echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \ exit 1; \ fi - cd "$(KIND_K8S_DIR)" && KC=$$($(COMPOSE) exec -T kind-k8s-web python3 scripts/effective_kubeconfig_path.py $(CLUSTER) | tr -d '\r') && \ - $(COMPOSE) exec kind-k8s-web kubectl --kubeconfig="$$KC" $(KUBECTL_ARGS) + cd "$(KIND_K8S_DIR)" && KC=$$($(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) exec -T kind-k8s-web python3 scripts/effective_kubeconfig_path.py $(CLUSTER) | tr -d '\r') && \ + $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) exec kind-k8s-web kubectl --kubeconfig="$$KC" $(KUBECTL_ARGS) -build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local - cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) +build: _require_runtime _podman_env_override clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local + cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) build $(COMPOSE_BUILD_FLAGS) -rebuild: _require_runtime clusters-dir ## (с docker/podman) Пересобрать образ без кэша и пересоздать контейнер kind-k8s-web - cd "$(KIND_K8S_DIR)" && $(COMPOSE) build --no-cache $(COMPOSE_BUILD_FLAGS) && $(COMPOSE) up -d --force-recreate kind-k8s-web +rebuild: _require_runtime _podman_env_override clusters-dir ## (с docker/podman) Пересобрать образ без кэша и пересоздать контейнер kind-k8s-web + cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) build --no-cache $(COMPOSE_BUILD_FLAGS) && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) up -d --force-recreate kind-k8s-web diff --git a/README.md b/README.md index cb5d836..de12b12 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ | **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) | | **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) | -Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`). +Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`; в начале — только выбор **docker** или **podman**, путь **`CONTAINER_SOCKET`** подставляется автоматически). ## Зачем это нужно @@ -25,8 +25,8 @@ - Верхняя **единая карточка**: заголовок, краткое описание и строка **состояния среды** (`kind` / `kubectl` / Docker или Podman API). - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). -- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление. -- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx** (версия чарта, NodePort 30080 для kind), **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (логин/пароль Kiali). Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с). +- **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание kubeconfig, модалки узлов/подов, ссылка на страницу кластера; с **панели** (`/`) — быстрый переход по ссылке в карточке «Создать кластер». +- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx**, **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (логин/пароль Kiali); журнал операции на странице (прогресс + вывод как при создании кластера), история в **`clusters/<имя>/helm_addon_log.json`**. Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с). - **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания). - **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы). - При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды. @@ -145,9 +145,13 @@ docker compose exec kind-k8s-web kubectl --kubeconfig=/work/clusters/<имя>/ku | **`KIND_K8S_APP_TITLE`** | контейнер / Settings | Заголовок OpenAPI и HTML; пустое значение из compose не ломает приложение (`env_ignore_empty`, fallback) | | **`KIND_K8S_WAIT_NODES`** | контейнер | `0` — не ждать Ready нод после create | | **`KIND_K8S_WAIT_NODES_TIMEOUT_SEC`** | контейнер | Таймаут `kubectl wait` (секунды) | -| **`CONTAINER_SOCKET`** | volume | Сокет Docker/Podman на хосте | +| **`CONTAINER_SOCKET`** | volume | Путь к сокету **на хосте**; при **`make podman …`** перед compose пишется **`.env.podman.override`** (в **`.gitignore`**) с актуальным путём из **`scripts/detect_podman_socket.py`**, чтобы перекрыть **`.env`**: иначе старый **`docker-compose`** часто подставляет том из **`.env`** (в т.ч. неверный сокет **`/var/folders/…/podman-machine-default-api.sock`**) и падает с **operation not supported** | +| **`CONTAINER_SOCKET_MOUNT_TARGET`** | volume | Путь **внутри** контейнера: Docker — `/var/run/docker.sock`; Podman — **`/run/podman/podman.sock`** | +| **`KIND_K8S_REMOTE_SOCKET_URI`** | контейнер `DOCKER_HOST` | URI API (совпадает с точкой монтирования), например **`unix:///run/podman/podman.sock`** для Podman | | **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig для хоста; по умолчанию **включено** (`1` в compose и в `make setup`) | | **`CONTAINER_CLI`** | контейнер | CLI для `docker port` / `podman port` (`docker` или `podman`) | +| **`KIND_K8S_CONTAINER_UIDGID`** | compose `user` | **`uid:gid`** процесса в контейнере; для Docker обычно **`0:0`**; для rootless Podman — **`$(id -u):$(id -g)`** (пишет **`make setup`**) | +| **`KIND_K8S_CONTAINER_HOME`** | контейнер `HOME` | Для не-root в образе без `/home/`: **`/tmp`** (Podman); для root — **`/root`** | | **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами | | **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк показывать в **интерактивном CLI** при выборе версии (веб-UI выводит полный список из API) | | **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Сколько страниц Docker Hub обходить при сборе тегов (старые 1.19.x часто на поздних страницах; в коде по умолчанию **120**, максимум **500**) | @@ -168,16 +172,20 @@ export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" make podman up ``` +**Доступ к сокету (`permission denied` на `/var/run/docker.sock` внутри контейнера):** у rootless Podman сокет обычно принадлежит вашему пользователю, а не «root» из контейнера. В **`.env`** для Podman нужны **`KIND_K8S_CONTAINER_UIDGID=$(id -u):$(id -g)`** и **`KIND_K8S_CONTAINER_HOME=/tmp`** — при **`make setup`** с выбором **Podman** скрипт записывает их сам. Команды **`make podman …`** подмешивают **`docker-compose.podman.yml`** (`userns_mode: keep-id`) и дублируют uid/gid в окружении: без **keep-id** rootless Podman часто даёт **permission denied** на смонтированный сокет даже при верном `user:`. Вручную: **`podman compose -f docker-compose.yml -f docker-compose.podman.yml up -d`** и те же переменные в **`.env`** или в shell. На системах с SELinux при отказе доступа к сокету попробуйте **`CONTAINER_SOCKET_VOLUME_OPTS=:Z`** в `.env`. + ## Структура репозитория (основное) | Путь | Назначение | |------|------------| -| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен; цели `up`, `down`, `restart`, `logs`, `rebuild`, `build` и др. | +| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен; цели `up`, `down`, `restart`, `logs`, `rebuild`, `build` и др. Для Podman: **`--env-file .env.podman.override`** (нужен compose с поддержкой **`--env-file`**, обычно ≥ 1.28). | +| `.env.podman.override` | Создаётся целью **`make podman …`**: `CONTAINER_SOCKET`, `KIND_K8S_*` для сокета; перекрывает устаревшие значения в **`.env`**. | | `scripts/setup_env_interactive.py` | Интерактивное создание `.env` (все ключи и дефолты внутри скрипта) | | `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` | | `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI | | `requirements.txt` | pip-зависимости веб-приложения | | `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет | +| `docker-compose.podman.yml` | Только для **Podman**: `userns_mode: keep-id` (подмешивается в **make podman …**) | | `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`), пути | diff --git a/app/api/v1/endpoints/cluster_addons.py b/app/api/v1/endpoints/cluster_addons.py index fa814e6..2a95ef1 100644 --- a/app/api/v1/endpoints/cluster_addons.py +++ b/app/api/v1/endpoints/cluster_addons.py @@ -1,6 +1,8 @@ """Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali. Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``). +После каждой операции в ``clusters/<имя>/helm_addon_log.json`` **добавляется** новая запись в массив ``entries`` +(история сохраняется, лимит ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``). Автор: Сергей Антропов Сайт: https://devops.org.ru @@ -10,7 +12,11 @@ from __future__ import annotations import asyncio import logging +import uuid +from collections.abc import Callable +from typing import Any +import yaml from fastapi import APIRouter, Body, HTTPException from core.cluster_lifecycle import validate_cluster_name @@ -28,6 +34,7 @@ from core.helm_addons import ( uninstall_metrics_server, ) from core.kind_guard import kind_cluster_lock +from core.provision_log import write_cluster_helm_addon_log from models.schemas import ( ClusterAddonsStatusResponse, HelmAddonApplyResponse, @@ -57,6 +64,79 @@ def _map_helm_error(e: HelmAddonError) -> HTTPException: return HTTPException(status_code=500, detail=msg) +def _parse_optional_values_yaml(raw: str | None) -> dict[str, Any] | None: + """ + Разбор опционального пользовательского YAML для Helm values. + + Пустая строка и ``null`` → ``None``. Корень документа должен быть mapping (объект YAML), + иначе 400. Используется :func:`yaml.safe_load`. + """ + if raw is None: + return None + s = raw.strip() + if not s: + return None + try: + data = yaml.safe_load(s) + except yaml.YAMLError as e: + raise HTTPException( + status_code=400, + detail=f"Некорректный YAML в values_yaml: {e}", + ) from e + if data is None: + return None + if not isinstance(data, dict): + raise HTTPException( + status_code=400, + detail="values_yaml: в корне ожидается объект YAML (mapping), не список и не скаляр.", + ) + return data + + +async def _run_helm_with_journal( + cluster_name: str, + job_kind: str, + result_meta: dict[str, Any], + sync_call: Callable[[], tuple[bool, str]], +) -> str: + """ + Выполнить операцию под блокировкой kind, добавить запись в историю ``helm_addon_log.json``. + + При ошибке запись всё равно добавляется со статусом ``failed``. + """ + log = "" + try: + async with kind_cluster_lock: + _, log = await asyncio.to_thread(sync_call) + await asyncio.to_thread( + write_cluster_helm_addon_log, + cluster_name=cluster_name, + job_id=str(uuid.uuid4()), + job_kind=job_kind, + status="success", + message=None, + lines=log.splitlines() if log else [], + result=result_meta, + ) + return log + except HelmAddonError as e: + msg = str(e) + lines = log.splitlines() if log else [] + if msg and msg not in lines: + lines = [msg] + lines + await asyncio.to_thread( + write_cluster_helm_addon_log, + cluster_name=cluster_name, + job_id=str(uuid.uuid4()), + job_kind=job_kind, + status="failed", + message=msg, + lines=lines, + result=result_meta, + ) + raise + + @router.get( "/clusters/{name}/addons/status", response_model=ClusterAddonsStatusResponse, @@ -102,11 +182,17 @@ async def post_install_ingress_nginx( ver: str | None = None if body is not None and body.chart_version and body.chart_version.strip(): ver = body.chart_version.strip() - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread(install_ingress_nginx, n, ver) - except HelmAddonError as e: - raise _map_helm_error(e) from e + user_v = _parse_optional_values_yaml(body.values_yaml if body else None) + meta = {"addon": "ingress-nginx", "action": "install", "chart_version": ver} + try: + log = await _run_helm_with_journal( + n, + "helm_addon_install_ingress_nginx", + meta, + lambda: install_ingress_nginx(n, ver, user_v), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e logger.info("Установлен ingress-nginx для кластера «%s»", n) return HelmAddonApplyResponse(ok=True, message="ingress-nginx установлен.", log=_trim_log(log)) @@ -120,11 +206,16 @@ async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse: n = name.strip() if not validate_cluster_name(n): raise HTTPException(status_code=400, detail="Некорректное имя кластера") - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread(uninstall_ingress_nginx, n) - except HelmAddonError as e: - raise _map_helm_error(e) from e + meta = {"addon": "ingress-nginx", "action": "delete"} + try: + log = await _run_helm_with_journal( + n, + "helm_addon_delete_ingress_nginx", + meta, + lambda: uninstall_ingress_nginx(n), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log)) @@ -143,17 +234,29 @@ async def post_install_kube_prometheus_stack( prom_ver: str | None = None if body.chart_version and body.chart_version.strip(): prom_ver = body.chart_version.strip() - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread( - install_kube_prometheus_stack, + u = body.grafana_admin_user.strip() + user_v = _parse_optional_values_yaml(body.values_yaml) + meta = { + "addon": "kube-prometheus-stack", + "action": "install", + "chart_version": prom_ver, + "grafana_admin_user": u, + } + try: + log = await _run_helm_with_journal( + n, + "helm_addon_install_kube_prometheus_stack", + meta, + lambda: install_kube_prometheus_stack( n, - grafana_admin_user=body.grafana_admin_user.strip(), + grafana_admin_user=u, grafana_admin_password=body.grafana_admin_password, chart_version=prom_ver, - ) - except HelmAddonError as e: - raise _map_helm_error(e) from e + user_values=user_v, + ), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e logger.info("Установлен kube-prometheus-stack для кластера «%s»", n) return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack установлен.", log=_trim_log(log)) @@ -167,11 +270,16 @@ async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse: n = name.strip() if not validate_cluster_name(n): raise HTTPException(status_code=400, detail="Некорректное имя кластера") - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread(uninstall_kube_prometheus_stack, n) - except HelmAddonError as e: - raise _map_helm_error(e) from e + meta = {"addon": "kube-prometheus-stack", "action": "delete"} + try: + log = await _run_helm_with_journal( + n, + "helm_addon_delete_kube_prometheus_stack", + meta, + lambda: uninstall_kube_prometheus_stack(n), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log)) @@ -190,11 +298,17 @@ async def post_install_metrics_server( ms_ver: str | None = None if body is not None and body.chart_version and body.chart_version.strip(): ms_ver = body.chart_version.strip() - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread(install_metrics_server, n, ms_ver) - except HelmAddonError as e: - raise _map_helm_error(e) from e + user_v = _parse_optional_values_yaml(body.values_yaml if body else None) + meta = {"addon": "metrics-server", "action": "install", "chart_version": ms_ver} + try: + log = await _run_helm_with_journal( + n, + "helm_addon_install_metrics_server", + meta, + lambda: install_metrics_server(n, ms_ver, user_v), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e logger.info("Установлен metrics-server для кластера «%s»", n) return HelmAddonApplyResponse(ok=True, message="metrics-server установлен.", log=_trim_log(log)) @@ -208,11 +322,16 @@ async def delete_metrics_server(name: str) -> HelmAddonApplyResponse: n = name.strip() if not validate_cluster_name(n): raise HTTPException(status_code=400, detail="Некорректное имя кластера") - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread(uninstall_metrics_server, n) - except HelmAddonError as e: - raise _map_helm_error(e) from e + meta = {"addon": "metrics-server", "action": "delete"} + try: + log = await _run_helm_with_journal( + n, + "helm_addon_delete_metrics_server", + meta, + lambda: uninstall_metrics_server(n), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log)) @@ -234,18 +353,35 @@ async def post_install_istio_kiali( kiali_ver: str | None = None if body.kiali_chart_version and body.kiali_chart_version.strip(): kiali_ver = body.kiali_chart_version.strip() - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread( - install_istio_and_kiali, + ku = body.kiali_username.strip() + kiali_uv = _parse_optional_values_yaml(body.values_yaml) + istio_base_v = _parse_optional_values_yaml(body.istio_base_values_yaml) + istiod_v = _parse_optional_values_yaml(body.istio_istiod_values_yaml) + meta = { + "addon": "istio-kiali", + "action": "install", + "istio_chart_version": istio_ver, + "kiali_chart_version": kiali_ver, + "kiali_username": ku, + } + try: + log = await _run_helm_with_journal( + n, + "helm_addon_install_istio_kiali", + meta, + lambda: install_istio_and_kiali( n, - kiali_username=body.kiali_username.strip(), + kiali_username=ku, kiali_password=body.kiali_password, istio_chart_version=istio_ver, kiali_chart_version=kiali_ver, - ) - except HelmAddonError as e: - raise _map_helm_error(e) from e + istio_base_values=istio_base_v, + istiod_values=istiod_v, + kiali_user_values=kiali_uv, + ), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e logger.info("Установлены Istio+Kiali для кластера «%s»", n) return HelmAddonApplyResponse(ok=True, message="Istio и Kiali установлены.", log=_trim_log(log)) @@ -259,9 +395,14 @@ async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse: n = name.strip() if not validate_cluster_name(n): raise HTTPException(status_code=400, detail="Некорректное имя кластера") - async with kind_cluster_lock: - try: - _, log = await asyncio.to_thread(uninstall_istio_and_kiali, n) - except HelmAddonError as e: - raise _map_helm_error(e) from e + meta = {"addon": "istio-kiali", "action": "delete"} + try: + log = await _run_helm_with_journal( + n, + "helm_addon_delete_istio_kiali", + meta, + lambda: uninstall_istio_and_kiali(n), + ) + except HelmAddonError as e: + raise _map_helm_error(e) from e return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log)) diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index c05f613..582a07d 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -52,6 +52,7 @@ from core.container_resource_stats import ( collect_single_cluster_resource_stats, running_kind_clusters_mask, ) +from core.helm_addons import HELM_INSTALLABLE_ADDON_IDS from core.job_store import ( JobRecord, end_job_tracking, @@ -198,6 +199,7 @@ def _stats_sync() -> StatsResponse: total_workers_from_meta=total_workers, jobs_total=len(jobs), jobs_recent_failed=failed, + helm_addons_installable_count=len(HELM_INSTALLABLE_ADDON_IDS), cluster_resources=res_blocks, cluster_resources_error=res_err, aggregate_cluster_resources=agg, diff --git a/app/api/v1/endpoints/helm_charts.py b/app/api/v1/endpoints/helm_charts.py index 08046d9..125260d 100644 --- a/app/api/v1/endpoints/helm_charts.py +++ b/app/api/v1/endpoints/helm_charts.py @@ -1,4 +1,4 @@ -"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``). +"""Справочник версий Helm-чартов и сборка YAML values для редактора на странице аддонов. Автор: Сергей Антропов Сайт: https://devops.org.ru @@ -11,14 +11,76 @@ import logging from fastapi import APIRouter, HTTPException -from core.helm_addons import HelmAddonError, helm_addon_chart_versions_all, require_helm_binary -from models.schemas import HelmAddonChartVersionsResponse +from core.helm_addons import ( + HelmAddonError, + compose_ingress_effective_values, + compose_istio_mesh_preview_values, + compose_kube_prometheus_effective_values, + compose_metrics_server_effective_values, + dump_helm_values_yaml, + helm_addon_chart_versions_all, + helm_repo_ensure, + require_helm_binary, +) +from models.schemas import ( + HelmAddonChartVersionsResponse, + HelmAddonComposeBatchRequest, + HelmAddonComposeBatchResponse, + HelmAddonComposeValuesRequest, + HelmAddonComposeValuesResponse, +) logger = logging.getLogger("kind_k8s.api.helm_charts") router = APIRouter(tags=["helm-charts"]) +def _compose_values_sync(req: HelmAddonComposeValuesRequest) -> HelmAddonComposeValuesResponse: + """Синхронная сборка YAML (вызов из ``asyncio.to_thread``): ``helm show values`` + оверлеи.""" + helm_repo_ensure() + addon = req.addon + if addon == "ingress-nginx": + data = compose_ingress_effective_values(req.chart_version) + return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data)) + if addon == "kube-prometheus-stack": + u = (req.grafana_admin_user or "admin").strip() or "admin" + pw = (req.grafana_admin_password or "").strip() or "ChangeMe12345" + data = compose_kube_prometheus_effective_values(req.chart_version, u, pw) + return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data)) + if addon == "metrics-server": + data = compose_metrics_server_effective_values(req.chart_version) + return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data)) + if addon == "istio-kiali": + k, ib, ir = compose_istio_mesh_preview_values(req.istio_chart_version, req.kiali_chart_version) + return HelmAddonComposeValuesResponse( + values_yaml=dump_helm_values_yaml(k), + istio_base_values_yaml=dump_helm_values_yaml(ib), + istio_istiod_values_yaml=dump_helm_values_yaml(ir), + ) + raise RuntimeError(f"неизвестный addon: {addon}") + + +def _compose_batch_sync(body: HelmAddonComposeBatchRequest) -> HelmAddonComposeBatchResponse: + """Один ``helm repo update`` и шесть выводов ``helm show values`` (с оверлеями где нужно).""" + helm_repo_ensure() + u = (body.grafana_admin_user or "admin").strip() or "admin" + pw = (body.grafana_admin_password or "").strip() or "ChangeMe12345" + ing = dump_helm_values_yaml(compose_ingress_effective_values(body.ingress_chart_version)) + prom = dump_helm_values_yaml( + compose_kube_prometheus_effective_values(body.kube_prometheus_chart_version, u, pw), + ) + ms = dump_helm_values_yaml(compose_metrics_server_effective_values(body.metrics_server_chart_version)) + k, ib, ir = compose_istio_mesh_preview_values(body.istio_chart_version, body.kiali_chart_version) + return HelmAddonComposeBatchResponse( + ingress_nginx_values_yaml=ing, + kube_prometheus_values_yaml=prom, + metrics_server_values_yaml=ms, + kiali_values_yaml=dump_helm_values_yaml(k), + istio_base_values_yaml=dump_helm_values_yaml(ib), + istio_istiod_values_yaml=dump_helm_values_yaml(ir), + ) + + @router.get( "/helm/chart-versions", response_model=HelmAddonChartVersionsResponse, @@ -42,3 +104,54 @@ async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse: logger.exception("Ошибка получения версий чартов") raise HTTPException(status_code=500, detail=str(e)) from e return HelmAddonChartVersionsResponse(**data) + + +@router.post( + "/helm/addons/compose-values", + response_model=HelmAddonComposeValuesResponse, + summary="Собрать YAML values для редактора аддонов (helm show values + форма)", +) +async def post_helm_addon_compose_values( + body: HelmAddonComposeValuesRequest, +) -> HelmAddonComposeValuesResponse: + """ + Выполняет ``helm repo update`` (внутри compose-функций) и ``helm show values`` для выбранного аддона. + + Для **kube-prometheus-stack** в YAML подставляются логин и пароль Grafana из тела запроса; + если пароль пуст, в preview используется временная строка ``ChangeMe12345`` (замените перед установкой). + + Для **istio-kiali** возвращаются три блока YAML: **kiali-server**, **istio/base**, **istio/istiod**. + """ + try: + require_helm_binary() + except HelmAddonError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + try: + return await asyncio.to_thread(_compose_values_sync, body) + except HelmAddonError as e: + logger.exception("compose-values: ошибка helm") + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post( + "/helm/addons/compose-values-batch", + response_model=HelmAddonComposeBatchResponse, + summary="Собрать все YAML values для страницы аддонов одним запросом", +) +async def post_helm_addon_compose_values_batch( + body: HelmAddonComposeBatchRequest, +) -> HelmAddonComposeBatchResponse: + """ + Один проход **``helm repo update``** и подстановка версий из селектов / полей формы на странице аддонов. + + Удобнее, чем четыре отдельных **compose-values**, когда UI обновляет все карточки сразу. + """ + try: + require_helm_binary() + except HelmAddonError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + try: + return await asyncio.to_thread(_compose_batch_sync, body) + except HelmAddonError as e: + logger.exception("compose-values-batch: ошибка helm") + raise HTTPException(status_code=500, detail=str(e)) from e diff --git a/app/api/v1/endpoints/journal.py b/app/api/v1/endpoints/journal.py index a01cf23..f9388b3 100644 --- a/app/api/v1/endpoints/journal.py +++ b/app/api/v1/endpoints/journal.py @@ -1,4 +1,4 @@ -"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``. +"""Чтение журналов: задания (jobs_history), развёртывание (provision_log), Helm-аддоны (helm_addon_log). Автор: Сергей Антропов Сайт: https://devops.org.ru @@ -13,7 +13,18 @@ from fastapi import APIRouter, HTTPException, Query from core.cluster_lifecycle import validate_cluster_name from core.job_journal import collect_recent_journal_entries_page_sync, read_cluster_journal_sync -from models.schemas import ClusterJournalResponse, JournalEntryModel, JournalRecentResponse +from core.provision_log import ( + HELM_ADDON_LOG_FILENAME, + PROVISION_LOG_FILENAME, + collect_cluster_dir_logs_page_sync, +) +from models.schemas import ( + ClusterDirLogEntryModel, + ClusterJournalResponse, + JournalEntryModel, + JournalPagedDirLogsResponse, + JournalRecentResponse, +) logger = logging.getLogger("kind_k8s.api.journal") @@ -26,20 +37,52 @@ def _journal_total_pages(total: int, limit: int) -> int: return (total + limit - 1) // limit +def _build_paged_dir_response( + rows: list[dict], + *, + limit: int, + offset: int, + total: int, +) -> JournalPagedDirLogsResponse: + total_pages = _journal_total_pages(total, limit) + if total <= 0: + page = 1 + else: + page = min((offset // limit) + 1, total_pages) + page = max(1, page) + entries = [ClusterDirLogEntryModel.model_validate(r) for r in rows] + return JournalPagedDirLogsResponse( + limit=limit, + offset=offset, + total=total, + page=page, + total_pages=total_pages, + entries=entries, + ) + + @router.get( "/journal/recent", response_model=JournalRecentResponse, - summary="Страница журнала по всем кластерам (пагинация)", + summary="Страница журнала заданий (jobs_history), опционально один кластер", ) async def get_journal_recent( limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"), offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"), + cluster: str | None = Query( + default=None, + description="Имя кластера: только записи из clusters/<имя>/journal/jobs_history.json", + ), ) -> JournalRecentResponse: - """Сборка из ``clusters/*/journal/jobs_history.json``, новые первыми; ``total`` и ``total_pages`` для UI.""" + """Сборка из ``journal/jobs_history.json``; при ``cluster`` — только выбранный каталог.""" + c = cluster.strip() if cluster else None + if c and not validate_cluster_name(c): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") rows, total = await asyncio.to_thread( collect_recent_journal_entries_page_sync, limit=limit, offset=offset, + cluster_name=c, ) entries = [JournalEntryModel.model_validate(r) for r in rows] total_pages = _journal_total_pages(total, limit) @@ -58,6 +101,44 @@ async def get_journal_recent( ) +@router.get( + "/journal/provision", + response_model=JournalPagedDirLogsResponse, + summary="Журналы развёртывания по кластерам (provision_log.json)", +) +async def get_journal_provision_logs( + limit: int = Query(default=30, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> JournalPagedDirLogsResponse: + """По одному последнему ``provision_log.json`` на кластер, новые первыми.""" + rows, total = await asyncio.to_thread( + collect_cluster_dir_logs_page_sync, + filename=PROVISION_LOG_FILENAME, + limit=limit, + offset=offset, + ) + return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total) + + +@router.get( + "/journal/helm-addons", + response_model=JournalPagedDirLogsResponse, + summary="Журналы установки Helm-аддонов (helm_addon_log.json)", +) +async def get_journal_helm_addon_logs( + limit: int = Query(default=30, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> JournalPagedDirLogsResponse: + """Все записи из ``helm_addon_log.json`` по кластерам (история операций), новые первыми.""" + rows, total = await asyncio.to_thread( + collect_cluster_dir_logs_page_sync, + filename=HELM_ADDON_LOG_FILENAME, + limit=limit, + offset=offset, + ) + return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total) + + @router.get( "/clusters/{name}/journal", response_model=ClusterJournalResponse, diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index e11005b..8894187 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -22,7 +22,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from kind_k8s_paths import clusters_dir, data_root +from kind_k8s_paths import clusters_dir, container_cli_name, data_root from kindest_node_tags import normalize_tag_v_prefix from kubeconfig_patch import ( kubeconfig_host_file, @@ -504,13 +504,15 @@ def create_cluster_non_interactive( if not use_existing_config and (workers < 0 or workers > 20): raise KindClusterError("Количество worker-нод должно быть от 0 до 20.") - ver_tag = normalize_tag_v_prefix(kubernetes_version_tag) - node_image = f"kindest/node:{ver_tag}" - + # Каталог данных кластера до долгих шагов — чтобы при сбое журнал в journal/jobs_history.json + # и provision_log могли быть привязаны к существующему clusters/<имя>/. root = data_root() cdir = clusters_dir() out_dir = cdir / name out_dir.mkdir(parents=True, exist_ok=True) + + ver_tag = normalize_tag_v_prefix(kubernetes_version_tag) + node_image = f"kindest/node:{ver_tag}" cfg_path = out_dir / "kind-config.yaml" kube_path = out_dir / "kubeconfig" meta_path = out_dir / "meta.json" @@ -912,14 +914,14 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None: def _container_cli_bin() -> str: - """Имя CLI к сокету (docker / podman), как в kubeconfig_patch.""" - return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + """Имя CLI к сокету: см. :func:`kind_k8s_paths.container_cli_name`.""" + return container_cli_name() def configured_container_cli() -> str: - """Активное имя CLI из ``CONTAINER_CLI`` (docker/podman). + """Имя CLI, которое реально вызывает процесс (в контейнере веб-UI — всегда ``docker``). - Единый источник для проверки движка, сбора ``docker stats``/``podman stats`` и поля ``container_cli`` в GET /stats. + Единый источник для ``docker stats``/``podman stats``, ``kind``, health и поля ``container_cli`` в API. """ return _container_cli_bin() diff --git a/app/core/container_resource_stats.py b/app/core/container_resource_stats.py index d86a11f..72c826a 100644 --- a/app/core/container_resource_stats.py +++ b/app/core/container_resource_stats.py @@ -9,12 +9,12 @@ from __future__ import annotations import logging -import os import re import shutil import subprocess from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters +from kind_k8s_paths import container_cli_name from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat logger = logging.getLogger("kind_k8s.container_resource_stats") @@ -24,7 +24,7 @@ _STAT_FORMAT = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}} def _container_cli_bin() -> str: - return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + return container_cli_name() def _list_running_container_names(cli: str) -> set[str]: diff --git a/app/core/helm_addons.py b/app/core/helm_addons.py index 66037e8..94d33ee 100644 --- a/app/core/helm_addons.py +++ b/app/core/helm_addons.py @@ -37,6 +37,15 @@ HELM_REPOS: dict[str, str] = { "kiali": "https://kiali.org/helm-charts", } +# Идентификаторы аддонов, доступных для установки на странице «Аддоны» и через API ``/clusters/{name}/addons/…``. +# Должен совпадать с набором в ``HelmComposeValuesRequest.addon`` и кнопками в ``cluster_addons.html``. +HELM_INSTALLABLE_ADDON_IDS: tuple[str, ...] = ( + "ingress-nginx", + "kube-prometheus-stack", + "metrics-server", + "istio-kiali", +) + # Ссылки для ``helm search repo --versions`` (ключи совпадают с полями API версий чартов). HELM_ADDON_CHART_REFS: dict[str, str] = { "ingress_nginx": "ingress-nginx/ingress-nginx", @@ -59,6 +68,121 @@ class HelmAddonError(Exception): self.exit_code = exit_code +def dump_helm_values_yaml(data: dict[str, Any]) -> str: + """Сериализация mapping в YAML для UI и временных файлов Helm.""" + return yaml.safe_dump( + data, + allow_unicode=True, + default_flow_style=False, + sort_keys=False, + ) + + +def helm_show_values_dict(chart_ref: str, chart_version: str | None = None) -> dict[str, Any]: + """ + Словарь дефолтных values чарта: ``helm show values [--version …]``. + + При пустом stdout или ошибке разбора возвращает ``{}`` (ошибка helm — :class:`HelmAddonError`). + """ + require_helm_binary() + cref = chart_ref.strip() + if not cref: + return {} + cmd = ["helm", "show", "values", cref] + ver = (chart_version or "").strip() + if ver: + cmd.extend(["--version", ver]) + rc, out, err = _run_cmd(cmd, timeout=180) + if rc != 0: + raise HelmAddonError( + (err or out or f"helm show values {cref} код {rc}")[:4000], + exit_code=rc, + ) + raw = (out or "").strip() + if not raw: + return {} + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as e: + logger.warning("YAML из helm show values %s: %s", cref, e) + return {} + return data if isinstance(data, dict) else {} + + +def _ingress_kind_service_overlay() -> dict[str, Any]: + """То же, что передаётся через ``--set`` при установке ingress для kind.""" + return { + "controller": { + "service": { + "type": "NodePort", + "nodePorts": {"http": 30080}, + } + } + } + + +def compose_ingress_effective_values(chart_version: str | None = None) -> dict[str, Any]: + """Дефолты чарта ingress-nginx + сервис NodePort (как в install). Репозитории — см. :func:`helm_repo_ensure`.""" + base = helm_show_values_dict("ingress-nginx/ingress-nginx", chart_version) + return deep_merge_values(base, _ingress_kind_service_overlay()) + + +def compose_kube_prometheus_effective_values( + chart_version: str | None, + grafana_admin_user: str, + grafana_admin_password: str, +) -> dict[str, Any]: + """Дефолты kube-prometheus-stack + учётные данные Grafana.""" + base = helm_show_values_dict("prometheus-community/kube-prometheus-stack", chart_version) + overlay: dict[str, Any] = { + "grafana": { + "adminUser": grafana_admin_user.strip(), + "adminPassword": grafana_admin_password, + } + } + return deep_merge_values(base, overlay) + + +def compose_metrics_server_effective_values(chart_version: str | None = None) -> dict[str, Any]: + """Дефолты metrics-server + args для kind (как в install).""" + base = helm_show_values_dict("metrics-server/metrics-server", chart_version) + overlay: dict[str, Any] = { + "args": [ + "--kubelet-insecure-tls", + "--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP", + ] + } + return deep_merge_values(base, overlay) + + +def compose_istio_mesh_preview_values( + istio_chart_version: str | None = None, + kiali_chart_version: str | None = None, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + """ + Три словаря values: ``kiali-server``, ``istio/base``, ``istio/istiod`` + (чистые ``helm show values`` для редактирования в UI). + """ + kiali = helm_show_values_dict("kiali/kiali-server", kiali_chart_version) + ib = helm_show_values_dict("istio/base", istio_chart_version) + ird = helm_show_values_dict("istio/istiod", istio_chart_version) + return kiali, ib, ird + + +def deep_merge_values(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """ + Рекурсивное объединение mapping: ключи из ``override`` перекрывают ``base``; + для вложенных dict сливаем дальше (как типичный merge для Helm values). + """ + out: dict[str, Any] = dict(base) + for k, v in override.items(): + if k in out and isinstance(out[k], dict) and isinstance(v, dict): + out[k] = deep_merge_values(out[k], v) + else: + out[k] = v + return out + + def _helm_timeout_sec() -> int: raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip() try: @@ -283,6 +407,7 @@ def _helm_upgrade_install( chart_version: str | None = None, extra_args: list[str] | None = None, values_file: Path | None = None, + extra_values_files: list[Path] | None = None, ) -> tuple[int, str, str]: kcfg = str(_kubeconfig_runtime_path(cluster_name)) cmd = [ @@ -305,6 +430,8 @@ def _helm_upgrade_install( cmd.extend(["--version", ver]) if values_file is not None: cmd.extend(["-f", str(values_file)]) + for p in extra_values_files or []: + cmd.extend(["-f", str(p)]) if extra_args: cmd.extend(extra_args) return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60) @@ -321,23 +448,41 @@ def _helm_uninstall(cluster_name: str, release: str, namespace: str) -> tuple[in # --- Установка / удаление по типам --- -def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple[bool, str]: - """ingress-nginx с NodePort для kind.""" +def install_ingress_nginx( + cluster_name: str, + chart_version: str | None, + user_values: dict[str, Any] | None = None, +) -> tuple[bool, str]: + """ + ingress-nginx: полный YAML values из UI (``helm show values`` + NodePort) или переданный словарь; + после файла values остаются ``--set`` для NodePort (приоритет как у Helm). + """ helm_repo_ensure() + eff = user_values if user_values is not None else compose_ingress_effective_values(chart_version) extra: list[str] = [ "--set", "controller.service.type=NodePort", "--set", "controller.service.nodePorts.http=30080", ] - rc, out, err = _helm_upgrade_install( - cluster_name, - "ingress-nginx", - "ingress-nginx/ingress-nginx", - "ingress-nginx", - chart_version=chart_version, - extra_args=extra, - ) + user_tmp: Path | None = None + try: + fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-ing-values-") + os.close(fd) + user_tmp = Path(name) + user_tmp.write_text(dump_helm_values_yaml(eff), encoding="utf-8") + rc, out, err = _helm_upgrade_install( + cluster_name, + "ingress-nginx", + "ingress-nginx/ingress-nginx", + "ingress-nginx", + chart_version=chart_version, + extra_args=extra, + extra_values_files=[user_tmp], + ) + finally: + if user_tmp is not None: + user_tmp.unlink(missing_ok=True) text = "\n".join(filter(None, [out, err])).strip() if rc != 0: raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) @@ -358,18 +503,23 @@ def install_kube_prometheus_stack( grafana_admin_user: str, grafana_admin_password: str, chart_version: str | None = None, + user_values: dict[str, Any] | None = None, ) -> tuple[bool, str]: + """Полный values из UI или автосборка (``helm show values`` + Grafana из формы).""" helm_repo_ensure() - values = { - "grafana": { - "adminUser": grafana_admin_user, - "adminPassword": grafana_admin_password, - } - } - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp: - yaml.safe_dump(values, tmp, allow_unicode=True) - tmp_path = Path(tmp.name) + if user_values is not None: + values = user_values + else: + values = compose_kube_prometheus_effective_values( + chart_version, + grafana_admin_user, + grafana_admin_password, + ) + fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="helm-prom-stack-") + os.close(fd) + tmp_path = Path(tmp_name) try: + tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8") rc, out, err = _helm_upgrade_install( cluster_name, "kube-prometheus-stack", @@ -394,18 +544,22 @@ def uninstall_kube_prometheus_stack(cluster_name: str) -> tuple[bool, str]: return True, text or "kube-prometheus-stack удалён (или отсутствовал)." -def install_metrics_server(cluster_name: str, chart_version: str | None = None) -> tuple[bool, str]: +def install_metrics_server( + cluster_name: str, + chart_version: str | None = None, + user_values: dict[str, Any] | None = None, +) -> tuple[bool, str]: + """Полный values из UI или автосборка (чарт + args для kind).""" helm_repo_ensure() - values = { - "args": [ - "--kubelet-insecure-tls", - "--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP", - ] - } - with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp: - yaml.safe_dump(values, tmp, allow_unicode=True) - tmp_path = Path(tmp.name) + if user_values is not None: + values = user_values + else: + values = compose_metrics_server_effective_values(chart_version) + fd_m, tmp_ms = tempfile.mkstemp(suffix=".yaml", prefix="helm-metrics-") + os.close(fd_m) + tmp_path = Path(tmp_ms) try: + tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8") rc, out, err = _helm_upgrade_install( cluster_name, "metrics-server", @@ -437,8 +591,16 @@ def install_istio_and_kiali( kiali_password: str, istio_chart_version: str | None = None, kiali_chart_version: str | None = None, + istio_base_values: dict[str, Any] | None = None, + istiod_values: dict[str, Any] | None = None, + kiali_user_values: dict[str, Any] | None = None, ) -> tuple[bool, str]: - """Istio base + istiod + секрет входа Kiali + chart kiali-server (strategy=login).""" + """ + Istio base + istiod (опциональные values из UI) + секрет Kiali + kiali-server. + + Для каждого чарта: если словарь ``None`` — без файла ``-f`` (дефолты чарта); + если непустой dict — полный values как в редакторе. + """ helm_repo_ensure() _ensure_namespace(cluster_name, "istio-system") @@ -446,27 +608,53 @@ def install_istio_and_kiali( istio_ver = (istio_chart_version or "").strip() or None kiali_ver = (kiali_chart_version or "").strip() or None - rc, out, err = _helm_upgrade_install( - cluster_name, - "istio-base", - "istio/base", - "istio-system", - chart_version=istio_ver, - ) - log_parts.append(out or err) - if rc != 0: - raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc) + base_tmp: Path | None = None + try: + base_file: Path | None = None + if istio_base_values: + fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istio-base-") + os.close(fd) + base_tmp = Path(name) + base_tmp.write_text(dump_helm_values_yaml(istio_base_values), encoding="utf-8") + base_file = base_tmp + rc, out, err = _helm_upgrade_install( + cluster_name, + "istio-base", + "istio/base", + "istio-system", + chart_version=istio_ver, + values_file=base_file, + ) + log_parts.append(out or err) + if rc != 0: + raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc) + finally: + if base_tmp is not None: + base_tmp.unlink(missing_ok=True) - rc2, o2, e2 = _helm_upgrade_install( - cluster_name, - "istiod", - "istio/istiod", - "istio-system", - chart_version=istio_ver, - ) - log_parts.append(o2 or e2) - if rc2 != 0: - raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2) + istiod_tmp: Path | None = None + try: + istiod_file: Path | None = None + if istiod_values: + fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istiod-") + os.close(fd) + istiod_tmp = Path(name) + istiod_tmp.write_text(dump_helm_values_yaml(istiod_values), encoding="utf-8") + istiod_file = istiod_tmp + rc2, o2, e2 = _helm_upgrade_install( + cluster_name, + "istiod", + "istio/istiod", + "istio-system", + chart_version=istio_ver, + values_file=istiod_file, + ) + log_parts.append(o2 or e2) + if rc2 != 0: + raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2) + finally: + if istiod_tmp is not None: + istiod_tmp.unlink(missing_ok=True) # Секрет для стратегии login (ключи username / passphrase — ожидание Kiali). secret = { @@ -484,14 +672,27 @@ def install_istio_and_kiali( raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs) log_parts.append(msgs) - rc3, o3, e3 = _helm_upgrade_install( - cluster_name, - "kiali-server", - "kiali/kiali-server", - "istio-system", - chart_version=kiali_ver, - extra_args=["--set", "auth.strategy=login"], - ) + kiali_extra: list[Path] = [] + kiali_tmp: Path | None = None + try: + if kiali_user_values: + fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-kiali-user-") + os.close(fd) + kiali_tmp = Path(name) + kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8") + kiali_extra.append(kiali_tmp) + rc3, o3, e3 = _helm_upgrade_install( + cluster_name, + "kiali-server", + "kiali/kiali-server", + "istio-system", + chart_version=kiali_ver, + extra_args=["--set", "auth.strategy=login"], + extra_values_files=kiali_extra or None, + ) + finally: + if kiali_tmp is not None: + kiali_tmp.unlink(missing_ok=True) log_parts.append(o3 or e3) if rc3 != 0: raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3) diff --git a/app/core/job_journal.py b/app/core/job_journal.py index a7ce8a0..11e1f52 100644 --- a/app/core/job_journal.py +++ b/app/core/job_journal.py @@ -82,9 +82,18 @@ def append_job_from_record_sync( return cdir = clusters_dir() / name + # Страница «Журнал» читает clusters/<имя>/journal/jobs_history.json — нужен каталог кластера. + # После отмены create_cluster каталог мог быть удалён (rmtree) — не создаём пустой каталог заново. if not cdir.is_dir(): - logger.debug("Журнал кластера: каталог %s отсутствует", cdir) - return + if (status or "").lower() == "cancelled": + logger.debug("Журнал кластера: каталог %s удалён при отмене — запись jobs_history пропускаем", cdir) + return + try: + cdir.mkdir(parents=True, exist_ok=True) + logger.info("Журнал: создан каталог кластера для записи jobs_history: %s", cdir) + except OSError as e: + logger.warning("Журнал кластера: не удалось создать %s: %s", cdir, e) + return jdir = cdir / JOURNAL_SUBDIR finished = datetime.now(timezone.utc).isoformat() @@ -185,21 +194,49 @@ def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]: return flat -def collect_recent_journal_entries_page_sync(*, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]: +def collect_recent_journal_entries_page_sync( + *, + limit: int, + offset: int, + cluster_name: str | None = None, +) -> tuple[list[dict[str, Any]], int]: """ - Страница записей из всех ``clusters/*/journal/jobs_history.json``, новые первыми. + Страница записей из ``journal/jobs_history.json``, новые первыми. - В каждую запись добавляется поле ``source_cluster`` (имя каталога). - Возвращает ``(страница записей, общее число записей)``. + Если ``cluster_name`` задан — только этот кластер (файл на диске), иначе объединение по всем кластерам. + В каждую запись добавляется поле ``source_cluster``. """ lim = max(1, min(limit, 100)) off = max(0, offset) + + if cluster_name and str(cluster_name).strip(): + n = str(cluster_name).strip() + if not validate_cluster_name(n): + return [], 0 + raw = read_cluster_journal_sync(n) + if not raw or not isinstance(raw.get("entries"), list): + return [], 0 + entries = [e for e in raw["entries"] if isinstance(e, dict)] + + def _entry_time_key(e: dict[str, Any]) -> str: + return str(e.get("finished_at_utc") or e.get("created_at_utc") or "") + + entries.sort(key=_entry_time_key, reverse=True) + total = len(entries) + chunk = entries[off : off + lim] + out: list[dict[str, Any]] = [] + for e in chunk: + row = dict(e) + row["source_cluster"] = n + out.append(row) + return out, total + flat = _collect_all_journal_rows_sorted_sync() total = len(flat) chunk = flat[off : off + lim] - out: list[dict[str, Any]] = [] + out2: list[dict[str, Any]] = [] for cluster_dir_name, e in chunk: row = dict(e) row["source_cluster"] = cluster_dir_name - out.append(row) - return out, total + out2.append(row) + return out2, total diff --git a/app/core/provision_log.py b/app/core/provision_log.py index 2848c9d..22a7dce 100644 --- a/app/core/provision_log.py +++ b/app/core/provision_log.py @@ -1,5 +1,8 @@ """Сохранение полного журнала развёртывания кластера в ``clusters/<имя>/provision_log.json``. +Журнал операций Helm-аддонов — ``helm_addon_log.json``: **история** операций (массив ``entries``), +старый формат «один объект в файле» при чтении мигрируется автоматически. + Автор: Сергей Антропов Сайт: https://devops.org.ru """ @@ -8,10 +11,13 @@ from __future__ import annotations import json import logging +import os +import threading from datetime import datetime, timezone from pathlib import Path from typing import Any +from core.cluster_lifecycle import validate_cluster_name from kind_k8s_paths import clusters_dir logger = logging.getLogger("kind_k8s.provision_log") @@ -19,12 +25,42 @@ logger = logging.getLogger("kind_k8s.provision_log") PROVISION_LOG_FILENAME = "provision_log.json" PROVISION_LOG_VERSION = 1 +HELM_ADDON_LOG_FILENAME = "helm_addon_log.json" +"""Имя файла истории Helm; корневой объект с полем ``entries`` (версия файла 2).""" +HELM_ADDON_LOG_VERSION = 1 +"""Версия схемы одной записи внутри ``entries``.""" +HELM_ADDON_LOG_FILE_VERSION = 2 +"""Версия обёртки файла (список ``entries``).""" + +_locks_guard = threading.Lock() +_helm_log_locks: dict[str, threading.Lock] = {} + + +def _helm_cluster_lock(cluster_name: str) -> threading.Lock: + with _locks_guard: + if cluster_name not in _helm_log_locks: + _helm_log_locks[cluster_name] = threading.Lock() + return _helm_log_locks[cluster_name] + + +def _max_helm_addon_entries() -> int: + raw = (os.environ.get("KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES") or "500").strip() + try: + return max(20, min(int(raw), 5000)) + except ValueError: + return 500 + def provision_log_file_path(cluster_name: str) -> Path: """Путь к JSON с журналом операции для кластера ``cluster_name``.""" return clusters_dir() / cluster_name.strip() / PROVISION_LOG_FILENAME +def helm_addon_log_file_path(cluster_name: str) -> Path: + """Путь к JSON с историей журналов установки/удаления Helm-аддонов.""" + return clusters_dir() / cluster_name.strip() / HELM_ADDON_LOG_FILENAME + + def write_cluster_provision_log( *, cluster_name: str, @@ -72,3 +108,156 @@ def write_cluster_provision_log( except OSError: pass return None + + +def _helm_addon_rows_from_parsed_json(data: dict[str, Any], cname: str) -> list[dict[str, Any]]: + """Разобрать содержимое ``helm_addon_log.json`` в плоский список строк для UI/API.""" + raw_entries = data.get("entries") + if isinstance(raw_entries, list) and raw_entries: + out: list[dict[str, Any]] = [] + for e in raw_entries: + if isinstance(e, dict): + row = dict(e) + row["source_cluster"] = cname + out.append(row) + return out + # Легаси: один объект операции в корне файла + if data.get("job_id") is not None or data.get("finished_at_utc") is not None: + row = dict(data) + row["source_cluster"] = cname + return [row] + return [] + + +def write_cluster_helm_addon_log( + *, + cluster_name: str, + job_id: str, + job_kind: str, + status: str, + message: str | None, + lines: list[str], + result: dict[str, Any] | None, +) -> Path | None: + """ + Добавить запись в историю ``helm_addon_log.json`` (новые записи в начале списка). + + Файл хранит ``{"file_version": 2, "entries": [ ... ]}``; лимит длины — ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``. + """ + name = cluster_name.strip() + cdir = clusters_dir() / name + if not cdir.is_dir(): + logger.debug("Каталог кластера отсутствует, helm_addon_log не пишем: %s", cdir) + return None + + path = cdir / HELM_ADDON_LOG_FILENAME + new_entry: dict[str, Any] = { + "version": HELM_ADDON_LOG_VERSION, + "job_id": job_id, + "kind": job_kind, + "cluster_name": name, + "finished_at_utc": datetime.now(timezone.utc).isoformat(), + "status": status, + "message": message, + "lines": list(lines), + "result": result, + } + + lock = _helm_cluster_lock(name) + with lock: + entries: list[dict[str, Any]] = [] + if path.is_file(): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + ex = raw.get("entries") + if isinstance(ex, list): + entries = [e for e in ex if isinstance(e, dict)] + elif raw.get("job_id") is not None or raw.get("finished_at_utc") is not None: + legacy = {k: v for k, v in raw.items() if k != "source_cluster"} + entries = [legacy] + except (OSError, json.JSONDecodeError) as e: + logger.warning("helm_addon_log %s не прочитан, создаём заново: %s", path, e) + entries = [] + + entries.insert(0, new_entry) + cap = _max_helm_addon_entries() + if len(entries) > cap: + entries = entries[:cap] + + payload: dict[str, Any] = { + "file_version": HELM_ADDON_LOG_FILE_VERSION, + "entries": entries, + } + tmp = path.with_suffix(path.suffix + ".tmp") + try: + tmp.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, default=str), + encoding="utf-8", + ) + tmp.replace(path) + logger.info( + "Сохранён журнал Helm-аддонов: %s (запись %s, всего записей в файле: %s)", + path, + job_id, + len(entries), + ) + return path + except OSError as e: + logger.warning("Не удалось записать %s: %s", path, e) + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + return None + + +def collect_cluster_dir_logs_page_sync( + *, + filename: str, + limit: int, + offset: int, +) -> tuple[list[dict[str, Any]], int]: + """ + Собрать JSON с диска по кластерам. + + - ``provision_log.json`` — по одной строке на кластер (как раньше). + - ``helm_addon_log.json`` — по одной строке на **каждую** запись в ``entries`` (или одна для легаси-файла). + + Сортировка по ``finished_at_utc`` (новые первыми), затем пагинация. + """ + lim = max(1, min(limit, 100)) + off = max(0, offset) + root = clusters_dir() + if not root.is_dir(): + return [], 0 + rows: list[dict[str, Any]] = [] + for sub in sorted(root.iterdir()): + if not sub.is_dir(): + continue + cname = sub.name + if not validate_cluster_name(cname): + continue + path = sub / filename + if not path.is_file(): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(data, dict): + continue + if filename == HELM_ADDON_LOG_FILENAME: + rows.extend(_helm_addon_rows_from_parsed_json(data, cname)) + else: + row = dict(data) + row["source_cluster"] = cname + rows.append(row) + + def sort_key(r: dict[str, Any]) -> str: + return str(r.get("finished_at_utc") or "") + + rows.sort(key=sort_key, reverse=True) + total = len(rows) + chunk = rows[off : off + lim] + return chunk, total diff --git a/app/create_cluster.py b/app/create_cluster.py index 48f118f..81b0389 100644 --- a/app/create_cluster.py +++ b/app/create_cluster.py @@ -31,7 +31,7 @@ from core.cluster_lifecycle import ( validate_cluster_name, ) from kindest_node_tags import fetch_kindest_node_tags, normalize_tag_v_prefix -from kind_k8s_paths import clusters_dir +from kind_k8s_paths import clusters_dir, container_cli_name # Имя кластера: поддомен DNS (RFC 1123) — дублируем только для CLI-подсказок; # основная проверка в core.cluster_lifecycle. @@ -42,7 +42,7 @@ def _which(cmd: str) -> str | None: def _container_cli_bin() -> str: - return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + return container_cli_name() def _in_container() -> bool: diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 8d8fbed..364e429 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -19,16 +19,17 @@ | Маршрут | Описание | |---------|----------| -| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | -| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | -| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. | -| `GET /cluster-addons` | HTML **Аддоны**: выбор кластера, **`GET /api/v1/helm/chart-versions`** для выпадающих списков версий чартов (или «Последняя»), установка/удаление Helm-релизов. | -| `GET /journal` | HTML **Журнал**: **`GET /api/v1/journal/recent`** с пагинацией (**30** записей на страницу), навигация по страницам внизу таблицы. | +| `GET /` | HTML **Панель**: CTA создания кластера, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. | +| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. | +| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). | +| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. В шапке активна **Кластеры**. | +| `GET /cluster-addons` | HTML **Аддоны**: **`GET /helm/chart-versions`**, **`POST /helm/addons/compose-values`** (подстановка **реальных** `helm show values` + логины в редактор), три YAML для Istio, установка/удаление релизов. | +| `GET /journal` | HTML **Журналы**: переключатель (как «Простой/Расширенный» в редактировании кластера) — **по кластеру** (`journal/recent?cluster=`), **развёртывание** (`/journal/provision`), **Helm-аддоны** (`/journal/helm-addons`); пагинация по **30** записей. | | `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path` — **`GET /api/v1/docs/readme`**, с `?path=app/docs/…` — **`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2** — **одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | -Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/cluster_addons.html` (Helm-аддоны), `app/templates/journal.html` (журнал заданий), `app/templates/documentation.html` (README). +Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (панель), `app/templates/clusters.html` (список кластеров и донаты узлов), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/cluster_addons.html` (Helm-аддоны), `app/templates/journal.html` (журнал заданий), `app/templates/documentation.html` (README). **kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** — **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**. @@ -58,27 +59,87 @@ | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | | GET | `/api/v1/clusters/{name}/addons/status` | Статус Helm-релизов (ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali); нужен **helm** в образе | | GET | `/api/v1/helm/chart-versions` | Версии чартов для UI (после `helm repo update`); кэш **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`**; поля: `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio`, `kiali_server` | -| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx** (NodePort 30080); тело: `{ "chart_version": "опционально" }` (пусто = последняя) | +| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx**; тело: опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта, см. **compose-values** и раздел ниже) | | DELETE | `/api/v1/clusters/{name}/addons/ingress-nginx` | Удалить ingress-nginx | -| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально **`chart_version`** | +| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта; пусто — автосборка) | | DELETE | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Удалить стек | -| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server** (kind: `--kubelet-insecure-tls`); тело опционально: `{ "chart_version": "…" }` или `{}` | +| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server**; тело опционально: `chart_version`, **`values_yaml`** (полный YAML; пусто — автосборка с args для kind) | | DELETE | `/api/v1/clusters/{name}/addons/metrics-server` | Удалить metrics-server | -| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: `kiali_username`, `kiali_password` (≥8), опционально **`istio_chart_version`**, **`kiali_chart_version`** | +| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: учётные данные Kiali, версии, опционально **`values_yaml`** (kiali-server), **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`** | | DELETE | `/api/v1/clusters/{name}/addons/istio-kiali` | Удалить kiali-server, istiod, istio-base | -| GET | `/api/v1/journal/recent` | Пагинация журнала по всем кластерам: query **`limit`** (по умолчанию **30**, макс. 100), **`offset`** (по умолчанию **0**); в ответе **`total`**, **`total_pages`**, **`page`** | -| GET | `/api/v1/clusters/{name}/journal` | Полный файл журнала кластера (массив `entries` или пусто, если файла нет) | +| POST | `/api/v1/helm/addons/compose-values` | Собрать YAML для **одного** аддона (тело: `addon`, версии, поля Grafana при необходимости) | +| POST | `/api/v1/helm/addons/compose-values-batch` | Собрать **все** шесть YAML для страницы аддонов **одним** запросом (один **`helm repo update`**); предпочтительно для UI | +| GET | `/api/v1/journal/recent` | Журнал заданий из **`journal/jobs_history.json`**: query **`limit`**, **`offset`**, опционально **`cluster`** (только выбранный каталог); ответ **`total`**, **`total_pages`**, **`page`** | +| GET | `/api/v1/journal/provision` | Сводка **`provision_log.json`** по кластерам (по одному последнему файлу на каталог), пагинация | +| GET | `/api/v1/journal/helm-addons` | Сводка **`helm_addon_log.json`**: все записи из **`entries`** по кластерам (история), тот же формат строки, что у provision | +| GET | `/api/v1/clusters/{name}/journal` | Полный файл **`jobs_history.json`** кластера (массив `entries` или пусто) | | GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) | | GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) | | DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) | | POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) | +После каждого **POST/DELETE …/addons/…** в каталоге кластера в **`helm_addon_log.json`** **добавляется** новая запись в массив **`entries`** (история сохраняется, лимит **`KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES`**). Формат записи как у **`provision_log.json`**: `lines`, `kind`, `status`, `result`; пароли в файл не пишутся. + +#### POST `/api/v1/helm/addons/compose-values-batch` + +Тело **`HelmAddonComposeBatchRequest`**: версии чартов из селектов (`ingress_chart_version`, `kube_prometheus_chart_version`, `metrics_server_chart_version`, `istio_chart_version`, `kiali_chart_version`) и опционально **`grafana_admin_user`** / **`grafana_admin_password`**. + +Ответ **`HelmAddonComposeBatchResponse`**: поля **`ingress_nginx_values_yaml`**, **`kube_prometheus_values_yaml`**, **`metrics_server_values_yaml`**, **`kiali_values_yaml`**, **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`**. + +#### POST `/api/v1/helm/addons/compose-values` + +Тело JSON (**`HelmAddonComposeValuesRequest`**): + +- **`addon`**: `ingress-nginx` | `kube-prometheus-stack` | `metrics-server` | `istio-kiali` +- **`chart_version`** — для ingress / kube-prometheus-stack / metrics-server (пусто = последняя версия в `helm search`) +- **`grafana_admin_user`**, **`grafana_admin_password`** — для `kube-prometheus-stack` (если пароль пуст, в preview подставляется временная строка **`ChangeMe12345`** — замените в YAML или в форме перед установкой) +- **`istio_chart_version`**, **`kiali_chart_version`** — для `istio-kiali` + +Ответ (**`HelmAddonComposeValuesResponse`**): поле **`values_yaml`** — полный текст для основного чарта; для **`istio-kiali`** дополнительно **`istio_base_values_yaml`** и **`istio_istiod_values_yaml`**. Выполняется **`helm repo update`** (внутри цепочки) и **`helm show values`**. + +#### Поля YAML в POST …/addons/… (установка) + +Строки **`values_yaml`** / **`istio_base_values_yaml`** / **`istio_istiod_values_yaml`**: корень YAML — **object**. Пустая строка или отсутствие поля — сервер подставляет значения как при **compose-values** (кроме Istio: пустой блок = без файла **`-f`** для соответствующего чарта). Максимум **131072** символа на поле. + +- **ingress-nginx** — один файл **`-f`** с полным values; затем **`--set`** NodePort (сильнее файла). +- **kube-prometheus-stack** — один файл: либо из **`values_yaml`**, либо автосборка из версии и полей Grafana. +- **metrics-server** — аналогично. +- **istio-kiali** — до трёх опциональных файлов: **istio/base**, **istio/istiod**, **kiali-server**; секрет логина Kiali и **`auth.strategy=login`** отдельно. + +**Пример POST compose-values (`kube-prometheus-stack`):** + +```json +{ + "addon": "kube-prometheus-stack", + "chart_version": "65.1.1", + "grafana_admin_user": "admin", + "grafana_admin_password": "MySecurePwd12" +} +``` + +**Пример ответа 200 (фрагмент):** поле **`values_yaml`** — многострочная строка с полным YAML чарта. + +**Пример POST установки istio-kiali с тремя YAML:** + +```json +{ + "kiali_username": "kiali-admin", + "kiali_password": "MySecurePwd12", + "istio_chart_version": "1.22.0", + "kiali_chart_version": "1.89.0", + "istio_base_values_yaml": "defaultRevision: default\n", + "istio_istiod_values_yaml": "pilot:\\n resources: {}\\n", + "values_yaml": "auth:\\n strategy: token\\n" +} +``` + ### Фоновые задания (jobs) - Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется. - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**. -- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`**. +- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent?cluster=…`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`** (режим «По кластеру»). +- Операции **Helm-аддонов** (install/delete) **дописывают** запись в **`clusters/<имя>/helm_addon_log.json`** (массив **`entries`**, новые сверху), тот же набор полей, что у **`provision_log.json`**. Сводка по всем кластерам: **`GET /api/v1/journal/helm-addons`**. - Создание кластера: `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**). @@ -203,6 +264,7 @@ Accept: text/markdown "total_workers_from_meta": 2, "jobs_total": 3, "jobs_recent_failed": 0, + "helm_addons_installable_count": 4, "cluster_resources": [ { "cluster_name": "dev", @@ -250,6 +312,7 @@ Accept: text/markdown - `total_workers_from_meta` — целое **≥ 0**; **0**, если ни в одном `meta.json` нет поля `worker_nodes` или оно не число. - `jobs_total` — число заданий в текущей памяти процесса (не более 200). - `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку). +- `helm_addons_installable_count` — число типовых Helm-аддонов в каталоге UI (**Аддоны**, `HELM_INSTALLABLE_ADDON_IDS` в `core/helm_addons.py`); совпадает с кнопками установки на `/cluster-addons`. - `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение. - `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 на полное кольцо). @@ -308,16 +371,17 @@ Accept: text/markdown ## GET /api/v1/journal/recent -Объединение записей из всех **`clusters/*/journal/jobs_history.json`**, сортировка по **`finished_at_utc`** (новые первыми). В каждой записи добавляется **`source_cluster`** — имя каталога кластера. +Записи из **`journal/jobs_history.json`**. Без параметра **`cluster`** — объединение по всем кластерам, сортировка по времени (новые первыми). С **`cluster=<имя>`** — только файл выбранного каталога (порядок как в файле, новые сверху). В каждой записи есть **`source_cluster`**. **Query:** | Параметр | Описание | |----------|----------| -| `limit` | Записей на страницу, **1–100**, по умолчанию **30** (как в веб-интерфейсе **`/journal`**) | -| `offset` | Смещение от начала списка, **≥ 0**, по умолчанию **0** (первая страница: `offset=0`, вторая при `limit=30`: `offset=30`) | +| `limit` | Записей на страницу, **1–100**, по умолчанию **30** | +| `offset` | Смещение, **≥ 0**, по умолчанию **0** | +| `cluster` | Необязательно: DNS-имя кластера — только его **`jobs_history.json`** | -**Поля ответа:** **`limit`**, **`offset`**, **`total`** (всего записей), **`page`** (текущая страница с 1), **`total_pages`**, **`entries`** (фрагмент текущей страницы). +**Поля ответа:** **`limit`**, **`offset`**, **`total`**, **`page`**, **`total_pages`**, **`entries`** (элементы **`JournalEntryModel`** — в т.ч. **`log_lines`**). **Пример ответа 200 (первая страница, до 30 записей):** @@ -347,6 +411,20 @@ Accept: text/markdown --- +## GET /api/v1/journal/provision + +Для каждого каталога **`clusters/<имя>/`**, где есть **`provision_log.json`**, читается один объект; список сортируется по **`finished_at_utc`** (новые первыми). Пагинация: **`limit`**, **`offset`** (те же ограничения, что у **`/journal/recent`**). + +**Ответ:** **`JournalPagedDirLogsResponse`** — в каждом элементе **`entries`** поля как у файла provision: **`kind`**, **`lines`**, **`status`**, **`message`**, **`result`**, плюс **`source_cluster`**. + +--- + +## GET /api/v1/journal/helm-addons + +Аналогично **`/journal/provision`**, но источник — **`helm_addon_log.json`**: для каждого кластера разворачиваются **все** элементы массива **`entries`** (история операций Helm), строки сортируются по **`finished_at_utc`** (новые первыми). Формат одной записи совпадает с **`provision_log.json`**. + +--- + ## GET /api/v1/clusters/{name}/journal Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**. diff --git a/app/kind_k8s_paths.py b/app/kind_k8s_paths.py index b1b2f84..286b989 100644 --- a/app/kind_k8s_paths.py +++ b/app/kind_k8s_paths.py @@ -32,3 +32,22 @@ def data_root() -> Path: def clusters_dir() -> Path: """``/clusters``.""" return data_root() / "clusters" + + +def container_cli_name() -> str: + """ + Имя исполняемого файла для вызовов к движку контейнеров (``docker`` / ``podman``). + + В образе веб-UI ставится только **docker-cli** (Alpine ``apk add docker-cli``); бинарника + ``podman`` в контейнере нет. На хост при этом может быть смонтирован сокет **Podman** + (см. ``CONTAINER_SOCKET``): API совместим с Docker, клиент ``docker`` обращается к нему + через ``DOCKER_HOST`` и смонтированный ``/var/run/docker.sock``. + + Поэтому при ``KIND_K8S_IN_CONTAINER`` всегда возвращаем ``"docker"`` — независимо от + ``CONTAINER_CLI`` в ``.env`` (его имеет смысл задавать ``podman`` только при локальном + запуске приложения на хосте без контейнера). + """ + raw_in = (os.environ.get("KIND_K8S_IN_CONTAINER") or "").strip().lower() + if raw_in in ("1", "true", "yes", "on"): + return "docker" + return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" diff --git a/app/kubeconfig_patch.py b/app/kubeconfig_patch.py index 51fd177..1d1f6b9 100644 --- a/app/kubeconfig_patch.py +++ b/app/kubeconfig_patch.py @@ -32,6 +32,8 @@ import tempfile import threading from pathlib import Path +from kind_k8s_paths import container_cli_name + logger = logging.getLogger("kind_k8s.kubeconfig_patch") # Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения). @@ -39,8 +41,8 @@ KUBECONFIG_HOST_BASENAME = "kubeconfig.host" def _container_cli() -> str: - """CLI для `port` (обычно docker к сокету Docker или Podman).""" - return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + """CLI для ``port`` к смонтированному сокету (в контейнере веб-UI — ``docker``).""" + return container_cli_name() def _control_plane_container_name(cluster_name: str) -> str: diff --git a/app/main.py b/app/main.py index 38469d3..6546803 100644 --- a/app/main.py +++ b/app/main.py @@ -60,9 +60,24 @@ if _static_dir.is_dir(): templates = Jinja2Templates(directory=str(_templates_dir)) +@app.get("/clusters", response_class=HTMLResponse, summary="Список кластеров и сводка узлов") +async def clusters_page(request: Request) -> HTMLResponse: + """Страница «Кластеры»: донаты ресурсов узлов и таблица (как раньше на панели).""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="

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

", + status_code=500, + ) + return templates.TemplateResponse( + request, + "clusters.html", + {"app_title": settings.app_title, "nav_active": "clusters"}, + ) + + @app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс") async def dashboard(request: Request) -> HTMLResponse: - """Главная страница: обзор, сводка по узлам, таблица кластеров.""" + """Главная панель: CTA создания, статистика среды (без таблицы кластеров — см. /clusters).""" if not _templates_dir.is_dir(): return HTMLResponse( content="

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

", @@ -92,7 +107,7 @@ async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLRespon "cluster_detail.html", { "app_title": settings.app_title, - "nav_active": "panel", + "nav_active": "clusters", "cluster_name": n, }, ) @@ -114,7 +129,7 @@ async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse "cluster_edit.html", { "app_title": settings.app_title, - "nav_active": "panel", + "nav_active": "clusters", "cluster_name": n, }, ) diff --git a/app/models/schemas.py b/app/models/schemas.py index 9baa793..97b2587 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -117,6 +117,11 @@ class StatsResponse(BaseModel): ) jobs_total: int jobs_recent_failed: int + helm_addons_installable_count: int = Field( + 0, + ge=0, + description="Число типовых Helm-аддонов в каталоге UI (страница «Аддоны»), доступных для установки", + ) cluster_resources: list[KindClusterResources] = Field( default_factory=list, description="CPU/RAM/I/O узлов kind по данным container CLI", @@ -311,6 +316,9 @@ class HelmAddonApplyResponse(BaseModel): log: str = Field(default="", description="Сводный вывод helm/kubectl") +_VALUES_YAML_MAX = 131_072 + + class IngressNginxInstallRequest(BaseModel): """Тело POST …/addons/ingress-nginx — установка ingress-nginx.""" @@ -319,6 +327,15 @@ class IngressNginxInstallRequest(BaseModel): max_length=64, description="Версия чарта; пусто — последняя из репозитория", ) + values_yaml: str | None = Field( + default=None, + max_length=_VALUES_YAML_MAX, + description=( + "Полный YAML values для чарта (корень — объект), как в редакторе на странице аддонов " + "(``helm show values`` + сервис для kind). Пусто — собрать автоматически на сервере. " + "После ``-f`` применяются ``--set`` NodePort (приоритет Helm)." + ), + ) class KubePrometheusStackInstallRequest(BaseModel): @@ -331,6 +348,14 @@ class KubePrometheusStackInstallRequest(BaseModel): max_length=64, description="Версия чарта kube-prometheus-stack; пусто — последняя", ) + values_yaml: str | None = Field( + default=None, + max_length=_VALUES_YAML_MAX, + description=( + "Полный YAML values чарта (``helm show values`` + логин/пароль Grafana из формы). " + "Пусто — собрать на сервере из версии чарта и полей формы." + ), + ) class MetricsServerInstallRequest(BaseModel): @@ -341,6 +366,14 @@ class MetricsServerInstallRequest(BaseModel): max_length=64, description="Версия чарта metrics-server; пусто — последняя", ) + values_yaml: str | None = Field( + default=None, + max_length=_VALUES_YAML_MAX, + description=( + "Полный YAML values чарта (``helm show values`` + args для kind). " + "Пусто — собрать на сервере." + ), + ) class IstioKialiInstallRequest(BaseModel): @@ -358,6 +391,75 @@ class IstioKialiInstallRequest(BaseModel): max_length=64, description="Версия чарта kiali/kiali-server; пусто — последняя", ) + values_yaml: str | None = Field( + default=None, + max_length=_VALUES_YAML_MAX, + description=( + "Полный YAML values чарта **kiali-server** (``helm show values``). " + "Пусто — без файла ``-f`` (дефолты чарта). Секрет логина/пароля из формы отдельно; " + "``auth.strategy=login`` через ``--set``." + ), + ) + istio_base_values_yaml: str | None = Field( + default=None, + max_length=_VALUES_YAML_MAX, + description="Полный YAML values чарта **istio/base**; пусто — без ``-f``.", + ) + istio_istiod_values_yaml: str | None = Field( + default=None, + max_length=_VALUES_YAML_MAX, + description="Полный YAML values чарта **istio/istiod**; пусто — без ``-f``.", + ) + + +class HelmAddonComposeValuesRequest(BaseModel): + """Тело POST /helm/addons/compose-values — собрать YAML для редактора (helm show values + форма).""" + + addon: Literal["ingress-nginx", "kube-prometheus-stack", "metrics-server", "istio-kiali"] + chart_version: str | None = Field( + default=None, + max_length=64, + description="Версия чарта: ingress-nginx, kube-prometheus-stack или metrics-server", + ) + grafana_admin_user: str | None = Field(default=None, max_length=256) + grafana_admin_password: str | None = Field( + default=None, + max_length=256, + description="Для preview kube-prometheus-stack; если пусто — подставляется временный пароль в YAML", + ) + istio_chart_version: str | None = Field(default=None, max_length=64) + kiali_chart_version: str | None = Field(default=None, max_length=64) + + +class HelmAddonComposeValuesResponse(BaseModel): + """Ответ compose-values: строки YAML для текстовых полей UI.""" + + values_yaml: str = Field(..., description="Основной чарт; для istio-kiali — kiali-server") + istio_base_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/base") + istio_istiod_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/istiod") + + +class HelmAddonComposeBatchRequest(BaseModel): + """Тело POST /helm/addons/compose-values-batch — одним запросом обновить все редакторы на странице аддонов.""" + + ingress_chart_version: str | None = Field(default=None, max_length=64) + grafana_admin_user: str | None = Field(default=None, max_length=256) + grafana_admin_password: str | None = Field(default=None, max_length=256) + kube_prometheus_chart_version: str | None = Field(default=None, max_length=64) + metrics_server_chart_version: str | None = Field(default=None, max_length=64) + istio_chart_version: str | None = Field(default=None, max_length=64) + kiali_chart_version: str | None = Field(default=None, max_length=64) + + +class HelmAddonComposeBatchResponse(BaseModel): + """Шесть строк YAML за один вызов (один ``helm repo update``).""" + + ingress_nginx_values_yaml: str + kube_prometheus_values_yaml: str + metrics_server_values_yaml: str + kiali_values_yaml: str + istio_base_values_yaml: str + istio_istiod_values_yaml: str class HelmAddonChartVersionsResponse(BaseModel): @@ -400,7 +502,7 @@ class ClusterJournalResponse(BaseModel): class JournalRecentResponse(BaseModel): - """Страница агрегированного журнала (все ``clusters/*/journal/jobs_history.json``).""" + """Страница журнала заданий из ``journal/jobs_history.json`` (все кластеры или один).""" limit: int = Field(description="Размер страницы (записей за запрос)") offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)") @@ -408,3 +510,34 @@ class JournalRecentResponse(BaseModel): page: int = Field(ge=1, description="Номер текущей страницы с 1") total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет") entries: list[JournalEntryModel] = Field(default_factory=list) + + +class ClusterDirLogEntryModel(BaseModel): + """Запись из ``provision_log.json`` или ``helm_addon_log.json`` (формат как у развёртывания).""" + + model_config = {"extra": "allow"} + + version: int | None = None + job_id: str = "" + kind: str = "" + cluster_name: str = "" + finished_at_utc: str = "" + status: str = "" + message: str | None = None + lines: list[str] = Field(default_factory=list) + result: dict[str, Any] | None = None + source_cluster: str | None = Field( + default=None, + description="Имя каталога кластера в агрегированных ответах /journal/provision и /journal/helm-addons", + ) + + +class JournalPagedDirLogsResponse(BaseModel): + """Пагинация по одному последнему логу на кластер (развёртывание или Helm-аддоны).""" + + limit: int + offset: int + total: int + page: int + total_pages: int + entries: list[ClusterDirLogEntryModel] = Field(default_factory=list) diff --git a/app/static/js/cluster_addons.js b/app/static/js/cluster_addons.js index a3ad90f..6eddab3 100644 --- a/app/static/js/cluster_addons.js +++ b/app/static/js/cluster_addons.js @@ -12,6 +12,115 @@ const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + /** data-endpoint кнопки → поле ответа GET …/addons/status */ + var ENDPOINT_TO_STATUS_KEY = { + "ingress-nginx": "ingress_nginx", + "kube-prometheus-stack": "kube_prometheus_stack", + "metrics-server": "metrics_server", + "istio-kiali": "istio_mesh_ready", + }; + + var ADDON_HUMAN_LABEL = { + "ingress-nginx": "ingress-nginx", + "kube-prometheus-stack": "kube-prometheus-stack", + "metrics-server": "metrics-server", + "istio-kiali": "Istio + Kiali", + }; + + /** Последний успешный снимок статуса (для подписи «Установить» / «Обновить»). */ + var lastAddonsStatus = null; + + /** @type {((value: boolean) => void) | null} */ + var confirmModalResolver = null; + var confirmModalIsAlert = false; + + function isConfirmModalOpen() { + const el = document.getElementById("confirm-modal-overlay"); + return !!(el && !el.classList.contains("hidden")); + } + + function closeConfirmModal(confirmed) { + const ov = document.getElementById("confirm-modal-overlay"); + const cancelBtn = document.getElementById("confirm-modal-cancel"); + if (ov) ov.classList.add("hidden"); + document.body.classList.remove("modal-open"); + if (cancelBtn) cancelBtn.classList.remove("hidden"); + if (confirmModalResolver) { + var fn = confirmModalResolver; + confirmModalResolver = null; + var out = confirmModalIsAlert ? true : !!confirmed; + confirmModalIsAlert = false; + fn(out); + } else { + confirmModalIsAlert = false; + } + } + + /** + * Модальное подтверждение (разметка из partials/dashboard_modals.html). + * @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: boolean }} opts + * @returns {Promise} + */ + function openConfirmModal(opts) { + return new Promise(function (resolve) { + const ov = document.getElementById("confirm-modal-overlay"); + const titleEl = document.getElementById("confirm-modal-title"); + const msgEl = document.getElementById("confirm-modal-message"); + const okBtn = document.getElementById("confirm-modal-ok"); + const cancelBtn = document.getElementById("confirm-modal-cancel"); + if (!ov || !titleEl || !msgEl || !okBtn) { + resolve(false); + return; + } + if (confirmModalResolver) { + const prevFn = confirmModalResolver; + confirmModalResolver = null; + confirmModalIsAlert = false; + prevFn(false); + ov.classList.add("hidden"); + document.body.classList.remove("modal-open"); + if (cancelBtn) cancelBtn.classList.remove("hidden"); + } + confirmModalResolver = resolve; + confirmModalIsAlert = !!opts.alert; + titleEl.textContent = opts.title || "Подтвердите действие"; + msgEl.textContent = opts.message || ""; + okBtn.textContent = opts.confirmLabel || (confirmModalIsAlert ? "Понятно" : "Подтвердить"); + okBtn.className = opts.danger ? "btn-danger" : ""; + if (cancelBtn) { + cancelBtn.classList.toggle("hidden", confirmModalIsAlert); + } + ov.classList.remove("hidden"); + document.body.classList.add("modal-open"); + okBtn.focus(); + }); + } + + function bindConfirmModalControls() { + const confirmOv = document.getElementById("confirm-modal-overlay"); + const confirmCancel = document.getElementById("confirm-modal-cancel"); + const confirmOk = document.getElementById("confirm-modal-ok"); + if (confirmCancel) { + confirmCancel.addEventListener("click", function () { + closeConfirmModal(false); + }); + } + if (confirmOk) { + confirmOk.addEventListener("click", function () { + closeConfirmModal(true); + }); + } + if (confirmOv) { + confirmOv.addEventListener("click", function (ev) { + if (ev.target === confirmOv) closeConfirmModal(confirmModalIsAlert ? true : false); + }); + } + document.addEventListener("keydown", function (ev) { + if (ev.key !== "Escape" || !isConfirmModalOpen()) return; + closeConfirmModal(false); + }); + } + /** * @param {string} path * @param {RequestInit} [opts] @@ -42,22 +151,80 @@ const sel = document.getElementById("addons-cluster-select"); const statusBar = document.getElementById("addons-status-bar"); - const logPre = document.getElementById("addons-log-pre"); - const overlay = document.getElementById("addons-busy-overlay"); - const busyLabel = document.getElementById("addons-busy-label"); + const addonsCardsWrap = document.getElementById("addons-cards-wrap"); + const helmWrap = document.getElementById("addons-helm-progress-wrap"); + const helmHint = document.getElementById("addons-helm-progress-hint"); + const helmBar = document.getElementById("addons-helm-progress-bar"); + const helmTrack = document.getElementById("addons-helm-progress-track"); + const helmLabel = document.getElementById("addons-helm-progress-label"); + const helmLogPanel = document.getElementById("addons-helm-log-panel"); + const bootstrapMsg = document.getElementById("addons-bootstrap-msg"); function getClusterName() { if (!sel || !sel.value) return ""; return String(sel.value).trim(); } - function setBusy(show, label) { - if (!overlay) return; - overlay.classList.toggle("hidden", !show); - overlay.setAttribute("aria-busy", show ? "true" : "false"); - overlay.setAttribute("aria-hidden", show ? "false" : "true"); - document.body.classList.toggle("cluster-addons-busy", !!show); - if (busyLabel && label) busyLabel.textContent = label; + /** Карточки аддонов — только при выбранном кластере в селекте. */ + function updateAddonsCardsVisibility() { + if (!addonsCardsWrap) return; + var n = getClusterName(); + addonsCardsWrap.classList.toggle("hidden", !n); + addonsCardsWrap.setAttribute("aria-hidden", n ? "false" : "true"); + } + + /** + * Предупреждения загрузки списка кластеров / версий чартов (не путать с журналом Helm). + * @param {string} text пустая строка — скрыть блок + */ + function setBootstrapMsg(text) { + if (!bootstrapMsg) return; + var s = (text || "").trim(); + if (!s) { + bootstrapMsg.classList.add("hidden"); + bootstrapMsg.textContent = ""; + return; + } + bootstrapMsg.classList.remove("hidden"); + bootstrapMsg.textContent = s; + } + + /** Показать карточку «Ход операции Helm» (как create-progress-wrap на странице создания кластера). */ + function revealHelmProgressSection() { + if (!helmWrap) return; + helmWrap.classList.remove("hidden"); + helmWrap.setAttribute("aria-hidden", "false"); + } + + function updateHelmProgressBar(pct, stage) { + var p = Math.max(0, Math.min(100, Number(pct) || 0)); + if (helmBar) helmBar.style.width = p + "%"; + if (helmTrack) helmTrack.setAttribute("aria-valuenow", String(p)); + if (helmLabel) helmLabel.textContent = stage || "—"; + } + + /** Полный текст журнала в панели с теми же классами, что «Журнал задания» при создании кластера. */ + function setHelmLogText(text) { + if (!helmLogPanel) return; + helmLogPanel.textContent = text || ""; + helmLogPanel.scrollTop = helmLogPanel.scrollHeight; + } + + /** + * Блокировка форм на время синхронного запроса Helm (без полноэкранного спиннера). + * @param {boolean} disabled + */ + function setAddonFormsDisabled(disabled) { + document + .querySelectorAll( + "#addons-cards-wrap .cluster-addon-card button, #addons-cards-wrap .cluster-addon-card input, #addons-cards-wrap .cluster-addon-card select, #addons-cards-wrap .cluster-addon-card textarea", + ) + .forEach(function (el) { + el.disabled = !!disabled; + }); + if (sel) sel.disabled = !!disabled; + var refBtn = document.getElementById("addons-refresh-status"); + if (refBtn) refBtn.disabled = !!disabled; } /** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */ @@ -70,21 +237,177 @@ document.body.classList.remove("cluster-addons-page-loading"); } - function appendLog(line) { - if (!logPre) return; - logPre.textContent = (logPre.textContent ? logPre.textContent + "\n" : "") + line; - logPre.scrollTop = logPre.scrollHeight; + /** + * Текстовое сообщение в полосе статуса (нет кластера / ошибка). + * @param {string} text + */ + function renderAddonStatusMessage(text) { + if (!statusBar) return; + statusBar.className = "cluster-addons-status-bar cluster-addons-status-bar--message muted"; + statusBar.replaceChildren(); + const p = document.createElement("p"); + p.className = "cluster-addons-status-msg"; + p.textContent = text; + statusBar.appendChild(p); } - function formatStatus(st) { - if (!st) return ""; - const parts = [ - "ingress-nginx: " + (st.ingress_nginx ? "да" : "нет"), - "prometheus-stack: " + (st.kube_prometheus_stack ? "да" : "нет"), - "metrics-server: " + (st.metrics_server ? "да" : "нет"), - "Istio+Kiali: " + (st.istio_mesh_ready ? "да" : "нет"), - ]; - return parts.join(" · "); + /** Максимальная длина поля values_yaml на сервере (см. Pydantic max_length). */ + var VALUES_YAML_MAX_LEN = 131072; + + /** + * Полоса под селектором кластера: только сообщения (нет кластера / ошибка), без сетки статусов. + */ + function clearAddonToolbarStatusBar() { + if (!statusBar) return; + statusBar.className = "cluster-addons-status-bar"; + statusBar.replaceChildren(); + } + + /** + * Бейджи «Установлен» / «Не установлен» в правом верхнем углу карточки аддона. + * @param {object | null | undefined} st ответ GET …/addons/status или null при сбросе + */ + function updateAddonCardBadges(st) { + document.querySelectorAll(".cluster-addon-card[data-addon]").forEach(function (card) { + var da = card.getAttribute("data-addon") || ""; + var key = ENDPOINT_TO_STATUS_KEY[da]; + var on = !!(st && key && st[key]); + var badge = card.querySelector(".cluster-addon-card__badge"); + if (!badge) return; + badge.textContent = on ? "Установлен" : "Не установлен"; + badge.className = + "cluster-addon-card__badge" + (on ? " cluster-addon-card__badge--on" : " cluster-addon-card__badge--off"); + var hum = ADDON_HUMAN_LABEL[da] || da; + badge.setAttribute("aria-label", hum + ": " + (on ? "установлен" : "не установлен")); + }); + } + + /** + * Прикрепить к телу POST YAML из textarea (полные values чарта). Для istio-kiali — три поля. + * Пустой textarea → поле не отправляется (сервер соберёт defaults или пропустит -f). + * @param {string} ep data-endpoint + * @param {Record} payload + * @returns {boolean} + */ + function attachAddonYamlToPayload(ep, payload) { + if (ep === "istio-kiali") { + var triple = [ + { id: "addons-values-yaml-istio-kiali", key: "values_yaml" }, + { id: "addons-values-yaml-istio-base", key: "istio_base_values_yaml" }, + { id: "addons-values-yaml-istio-istiod", key: "istio_istiod_values_yaml" }, + ]; + for (var i = 0; i < triple.length; i++) { + var ta = document.getElementById(triple[i].id); + if (!ta) continue; + var s = String(ta.value || "").trim(); + if (!s) continue; + if (s.length > VALUES_YAML_MAX_LEN) { + alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов)."); + return false; + } + payload[triple[i].key] = s; + } + return true; + } + var id = "addons-values-yaml-" + ep; + var ta2 = document.getElementById(id); + if (!ta2) return true; + var s2 = String(ta2.value || "").trim(); + if (!s2) return true; + if (s2.length > VALUES_YAML_MAX_LEN) { + alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов)."); + return false; + } + payload.values_yaml = s2; + return true; + } + + var COMPOSE_DEBOUNCE_MS = 600; + /** @type {ReturnType | null} */ + var composeDebounceTimer = null; + + /** + * Заполнить все textarea: один POST **compose-values-batch** (один helm repo update на сервере). + */ + async function refreshAllComposeValuesFromServer() { + setBootstrapMsg(""); + var si = document.getElementById("addons-ing-version-select"); + var pv = document.getElementById("addons-prom-version-select"); + var ms = document.getElementById("addons-ms-version-select"); + var isel = document.getElementById("addons-istio-version-select"); + var ksel = document.getElementById("addons-kiali-version-select"); + var gu = document.getElementById("addons-grafana-user"); + var gp = document.getElementById("addons-grafana-pass"); + var batchBody = { + ingress_chart_version: si && si.value.trim() ? si.value.trim() : null, + kube_prometheus_chart_version: pv && pv.value.trim() ? pv.value.trim() : null, + metrics_server_chart_version: ms && ms.value.trim() ? ms.value.trim() : null, + istio_chart_version: isel && isel.value.trim() ? isel.value.trim() : null, + kiali_chart_version: ksel && ksel.value.trim() ? ksel.value.trim() : null, + grafana_admin_user: gu ? gu.value.trim() || null : null, + grafana_admin_password: gp ? gp.value || null : null, + }; + try { + var r = await api("/helm/addons/compose-values-batch", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(batchBody), + }); + var ti = document.getElementById("addons-values-yaml-ingress-nginx"); + var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack"); + var tm = document.getElementById("addons-values-yaml-metrics-server"); + var tk = document.getElementById("addons-values-yaml-istio-kiali"); + var tb = document.getElementById("addons-values-yaml-istio-base"); + var tiod = document.getElementById("addons-values-yaml-istio-istiod"); + if (ti) ti.value = r.ingress_nginx_values_yaml || ""; + if (tp) tp.value = r.kube_prometheus_values_yaml || ""; + if (tm) tm.value = r.metrics_server_values_yaml || ""; + if (tk) tk.value = r.kiali_values_yaml || ""; + if (tb) tb.value = r.istio_base_values_yaml || ""; + if (tiod) tiod.value = r.istio_istiod_values_yaml || ""; + } catch (e) { + setBootstrapMsg("Не удалось подставить values из чартов (helm show values): " + e.message); + } + } + + function scheduleComposeValuesRefresh() { + if (composeDebounceTimer) clearTimeout(composeDebounceTimer); + composeDebounceTimer = setTimeout(function () { + composeDebounceTimer = null; + refreshAllComposeValuesFromServer(); + }, COMPOSE_DEBOUNCE_MS); + } + + function addonEndpointInstalled(endpoint) { + var key = ENDPOINT_TO_STATUS_KEY[endpoint]; + return !!(lastAddonsStatus && key && lastAddonsStatus[key]); + } + + /** + * Текст «Установить»/«Обновить», видимость «Удалить» только при установленном аддоне; равная ширина — в CSS. + */ + function updateAddonActionButtons() { + document.querySelectorAll(".cluster-addon-card__actions").forEach(function (row) { + var installBtn = row.querySelector(".btn-addon-install"); + var removeBtn = row.querySelector(".btn-addon-remove"); + if (!installBtn) return; + var ep = installBtn.getAttribute("data-endpoint") || ""; + var on = addonEndpointInstalled(ep); + installBtn.textContent = on ? "Обновить" : "Установить"; + var hum = ADDON_HUMAN_LABEL[ep] || ep; + installBtn.setAttribute("aria-label", (on ? "Обновить " : "Установить ") + hum); + if (removeBtn) { + if (on) { + removeBtn.classList.remove("hidden"); + removeBtn.removeAttribute("hidden"); + removeBtn.setAttribute("aria-label", "Удалить " + hum); + } else { + removeBtn.classList.add("hidden"); + removeBtn.setAttribute("hidden", "hidden"); + removeBtn.removeAttribute("aria-label"); + } + } + }); } /** @@ -120,13 +443,15 @@ async function loadChartVersions() { try { const d = await api("/helm/chart-versions"); + setBootstrapMsg(""); fillVersionSelect(document.getElementById("addons-ing-version-select"), d.ingress_nginx); fillVersionSelect(document.getElementById("addons-prom-version-select"), d.kube_prometheus_stack); fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server); fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio); fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server); + await refreshAllComposeValuesFromServer(); } catch (e) { - appendLog("Список версий чартов недоступен: " + e.message); + setBootstrapMsg("Список версий чартов недоступен: " + e.message); } } @@ -134,6 +459,7 @@ if (!sel) return; try { const rows = await api("/clusters"); + setBootstrapMsg(""); sel.innerHTML = ""; const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; }); if (!withKube.length) { @@ -159,23 +485,37 @@ o.value = ""; o.textContent = "Ошибка загрузки списка"; sel.appendChild(o); - appendLog("Ошибка GET /clusters: " + e.message); + setBootstrapMsg("Ошибка GET /clusters: " + e.message); } } async function refreshStatus() { const n = getClusterName(); - if (!statusBar) return; + if (!statusBar) { + updateAddonsCardsVisibility(); + return; + } if (!n) { - statusBar.textContent = "Выберите кластер для просмотра статуса аддонов."; + lastAddonsStatus = null; + clearAddonToolbarStatusBar(); + updateAddonCardBadges(null); + updateAddonActionButtons(); + updateAddonsCardsVisibility(); return; } try { const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status"); - statusBar.textContent = formatStatus(st); + lastAddonsStatus = st; + clearAddonToolbarStatusBar(); + updateAddonCardBadges(st); + updateAddonActionButtons(); } catch (e) { - statusBar.textContent = "Статус недоступен: " + e.message; + lastAddonsStatus = null; + renderAddonStatusMessage("Статус недоступен: " + e.message); + updateAddonCardBadges(null); + updateAddonActionButtons(); } + updateAddonsCardsVisibility(); } async function runInstall(endpoint, payload) { @@ -184,22 +524,63 @@ alert("Выберите кластер."); return; } - setBusy(true, "Установка (Helm), подождите…"); - if (logPre) logPre.textContent = ""; - appendLog("POST …/addons/" + endpoint + " для «" + n + "»"); + const isUpdate = addonEndpointInstalled(endpoint); + const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint; + revealHelmProgressSection(); + setAddonFormsDisabled(true); + if (helmHint) { + helmHint.textContent = isUpdate + ? "Обновление релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении." + : "Установка релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении."; + } + updateHelmProgressBar(8, isUpdate ? "Обновление Helm…" : "Установка Helm…"); + setHelmLogText( + "Кластер: «" + + n + + "»\n" + + (isUpdate ? "POST (обновление)" : "POST (установка)") + + " /api/v1/clusters/…/addons/" + + endpoint + + "\n\nОжидаем завершение команды (несколько минут — норма)…", + ); try { const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: payload !== undefined ? JSON.stringify(payload) : "{}", }); - appendLog(r.message || "OK"); - if (r.log) appendLog(r.log); + var okLines = [ + "Кластер: «" + n + "»", + (isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint, + "", + r.message || "OK", + ]; + if (r.log) { + okLines.push(""); + okLines.push(String(r.log)); + } + setHelmLogText(okLines.join("\n")); + updateHelmProgressBar(100, "Готово"); + if (helmHint) { + helmHint.textContent = + "Операция завершена. Полный журнал добавлен в файл helm_addon_log.json в каталоге кластера (история записей)."; + } await refreshStatus(); } catch (e) { - appendLog("Ошибка: " + e.message); + var failLines = [ + "Кластер: «" + n + "»", + (isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint, + "", + "Ошибка: " + e.message, + ]; + setHelmLogText(failLines.join("\n")); + updateHelmProgressBar(100, "Ошибка"); + if (helmHint) { + helmHint.textContent = + "Запрос не удался. При ответе сервера об ошибке Helm в журнале кластера могла появиться запись со статусом failed."; + } } finally { - setBusy(false); + setAddonFormsDisabled(false); } } @@ -209,22 +590,66 @@ alert("Выберите кластер."); return; } - if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return; - setBusy(true, "Удаление релиза, подождите…"); - if (logPre) logPre.textContent = ""; - appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»"); + const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint; + const ok = await openConfirmModal({ + title: "Удалить аддон?", + message: + "Кластер «" + + n + + "»: снять релиз «" + + hum + + "» с кластера (helm uninstall). Действие необратимо для установленных компонентов.", + confirmLabel: "Удалить", + danger: true, + }); + if (!ok) return; + revealHelmProgressSection(); + setAddonFormsDisabled(true); + if (helmHint) { + helmHint.textContent = + "Удаление релиза «" + hum + "» (helm uninstall); ниже — полный вывод по завершении."; + } + updateHelmProgressBar(8, "Удаление Helm…"); + setHelmLogText( + "Кластер: «" + n + "»\nDELETE /api/v1/clusters/…/addons/" + endpoint + "\n\nОжидаем завершение команды…", + ); try { const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { method: "DELETE", headers: { Accept: "application/json" }, }); - appendLog(r.message || "OK"); - if (r.log) appendLog(r.log); + var okDel = [ + "Кластер: «" + n + "»", + "DELETE /addons/" + endpoint, + "", + r.message || "OK", + ]; + if (r.log) { + okDel.push(""); + okDel.push(String(r.log)); + } + setHelmLogText(okDel.join("\n")); + updateHelmProgressBar(100, "Готово"); + if (helmHint) { + helmHint.textContent = + "Операция завершена. Запись добавлена в helm_addon_log.json в каталоге кластера (история)."; + } await refreshStatus(); } catch (e) { - appendLog("Ошибка: " + e.message); + var failDel = [ + "Кластер: «" + n + "»", + "DELETE /addons/" + endpoint, + "", + "Ошибка: " + e.message, + ]; + setHelmLogText(failDel.join("\n")); + updateHelmProgressBar(100, "Ошибка"); + if (helmHint) { + helmHint.textContent = + "Запрос не удался. При ошибке Helm на сервере в журнале кластера могла появиться запись со статусом failed."; + } } finally { - setBusy(false); + setAddonFormsDisabled(false); } } @@ -234,7 +659,10 @@ if (ep === "ingress-nginx") { const sel = document.getElementById("addons-ing-version-select"); const v = sel && sel.value.trim(); - runInstall(ep, v ? { chart_version: v } : {}); + const payload = {}; + if (v) payload.chart_version = v; + if (!attachAddonYamlToPayload(ep, payload)) return; + runInstall(ep, payload); return; } if (ep === "kube-prometheus-stack") { @@ -249,6 +677,7 @@ } const payload = { grafana_admin_user: user, grafana_admin_password: pw }; if (pv && pv.value.trim()) payload.chart_version = pv.value.trim(); + if (!attachAddonYamlToPayload(ep, payload)) return; runInstall(ep, payload); return; } @@ -256,7 +685,8 @@ const ms = document.getElementById("addons-ms-version-select"); const payload = {}; if (ms && ms.value.trim()) payload.chart_version = ms.value.trim(); - runInstall(ep, Object.keys(payload).length ? payload : {}); + if (!attachAddonYamlToPayload(ep, payload)) return; + runInstall(ep, payload); return; } if (ep === "istio-kiali") { @@ -273,6 +703,7 @@ const payload = { kiali_username: user, kiali_password: pw }; if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim(); if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim(); + if (!attachAddonYamlToPayload(ep, payload)) return; runInstall(ep, payload); } }); @@ -292,6 +723,23 @@ sel.addEventListener("change", function () { refreshStatus(); }); } + bindConfirmModalControls(); + + [ + "addons-ing-version-select", + "addons-prom-version-select", + "addons-ms-version-select", + "addons-istio-version-select", + "addons-kiali-version-select", + ].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener("change", function () { scheduleComposeValuesRefresh(); }); + }); + var grafanaUserEl = document.getElementById("addons-grafana-user"); + var grafanaPassEl = document.getElementById("addons-grafana-pass"); + if (grafanaUserEl) grafanaUserEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); }); + if (grafanaPassEl) grafanaPassEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); }); + loadClusters() .then(function () { return refreshStatus(); diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 9295161..5f0c891 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -32,9 +32,6 @@ var cachedClusterResourcesByName = {}; /** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */ var cachedHealthPayload = null; - /** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */ - var cachedLocalClusterDirsCount = null; - /** * Кэш GET /versions для проверки тега перед созданием кластера. * null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов. @@ -51,6 +48,11 @@ return (document.body.getAttribute("data-dashboard-mode") || "home") === "home"; } + /** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */ + function isClustersListPage() { + return document.body.getAttribute("data-dashboard-mode") === "clusters"; + } + function isCreatePage() { return document.body.getAttribute("data-dashboard-mode") === "create"; } @@ -84,6 +86,16 @@ document.body.classList.remove("dashboard-home-loading"); } + /** Скрыть спиннер первой загрузки страницы «Кластеры». */ + function hideClustersPageLoadingOverlay() { + const el = document.getElementById("clusters-page-loading-overlay"); + if (!el) return; + el.classList.add("hidden"); + el.setAttribute("aria-busy", "false"); + el.setAttribute("aria-hidden", "true"); + document.body.classList.remove("dashboard-clusters-loading"); + } + /** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */ function showClusterLifecycleJobOverlay(labelText) { const el = document.getElementById("cluster-lifecycle-job-overlay"); @@ -149,32 +161,6 @@ return d.innerHTML; } - function pad2(n) { - return n < 10 ? "0" + n : String(n); - } - - /** - * Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC). - * @param {string} iso - */ - function formatJobTimeUtc(iso) { - if (!iso) return "—"; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return iso; - return ( - pad2(d.getUTCDate()) + - "." + - pad2(d.getUTCMonth() + 1) + - "." + - String(d.getUTCFullYear()).slice(-2) + - " " + - pad2(d.getUTCHours()) + - ":" + - pad2(d.getUTCMinutes()) + - " UTC" - ); - } - /** * Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig). * @param {string} clusterName @@ -459,6 +445,7 @@ const failureWrap = document.getElementById("stats-failure-wrap"); const env = document.getElementById("stats-env"); const metricsGrid = document.getElementById("stats-grid"); + const resCard = document.getElementById("home-resources-summary-card"); if (failureWrap) { failureWrap.classList.remove("hidden"); failureWrap.replaceChildren(); @@ -480,6 +467,7 @@ } if (env) env.classList.add("hidden"); if (metricsGrid) metricsGrid.classList.add("hidden"); + if (resCard) resCard.classList.add("hidden"); } /** Показать снова ряд среды и сетку метрик (после успешных запросов). */ @@ -487,31 +475,18 @@ const failureWrap = document.getElementById("stats-failure-wrap"); const env = document.getElementById("stats-env"); const metricsGrid = document.getElementById("stats-grid"); + const resCard = document.getElementById("home-resources-summary-card"); 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; + if (resCard) resCard.classList.remove("hidden"); } /** - * Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats). + * Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` — первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»). */ function paintStatsEnvRow() { const el = document.getElementById("stats-env"); @@ -539,9 +514,6 @@ el.appendChild( buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null), ); - const dirVal = - cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—"; - el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal)); } async function loadHealth() { @@ -659,7 +631,7 @@ } /** - * SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``). + * SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»). */ function renderAggregateDonuts(agg, resourcesError, containerCliRaw) { renderAggregateDonutsToRoot( @@ -1016,6 +988,163 @@ renderRows(root, items); } + /** + * Блоки K8s на странице кластера с фильтром по namespace (ключ → поле overview → id корня таблицы). + * Автор: Сергей Антропов — https://devops.org.ru + */ + var CLUSTER_K8S_NS_BLOCKS = [ + { key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" }, + { key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" }, + { key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" }, + { key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" }, + { key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" }, + { key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" }, + { key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" }, + { key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" }, + { key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" }, + { key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" }, + ]; + + /** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */ + var lastClusterK8sOverview = null; + /** Однократная привязка делегирования change на фильтры namespace. */ + var clusterNsFiltersBound = false; + + /** + * Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview). + * @param {object|undefined|null} block + * @returns {object[]} + */ + function k8sBlockItemsArray(block) { + if (!block || typeof block !== "object") return []; + var items = block.items; + return Array.isArray(items) ? items : []; + } + + /** + * Отбор объектов по выбранному namespace (пустая строка — все). + * @param {object[]|undefined} items + * @param {string} ns + * @returns {object[]} + */ + function k8sFilterItemsByNamespace(items, ns) { + if (!ns) return items || []; + return (items || []).filter(function (it) { + return ((it.metadata && it.metadata.namespace) || "") === ns; + }); + } + + /** + * Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта. + * Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк). + * @param {object[]|undefined} items + * @returns {object[]} + */ + function k8sSortNamespacedItems(items) { + if (!items || !items.length) return items || []; + return items.slice().sort(function (a, b) { + var nsA = (a.metadata && a.metadata.namespace) || ""; + var nsB = (b.metadata && b.metadata.namespace) || ""; + var c = String(nsA).localeCompare(String(nsB), "ru"); + if (c !== 0) return c; + var nA = (a.metadata && a.metadata.name) || ""; + var nB = (b.metadata && b.metadata.name) || ""; + return String(nA).localeCompare(String(nB), "ru"); + }); + } + + /** + * Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace`` + * из таблиц ресурсов (pods/deployments/…); без дублей, сортировка по ru. + * Автор: Сергей Антропов — https://devops.org.ru + * @param {object} d ответ cluster overview + * @returns {string[]} + */ + function k8sNamespaceNamesFromOverview(d) { + var seen = Object.create(null); + var ordered = []; + + function addName(raw) { + if (raw == null) return; + var s = String(raw).trim(); + if (!s || seen[s]) return; + seen[s] = true; + ordered.push(s); + } + + var nsBlock = d.k8s_namespaces; + if (nsBlock && nsBlock.ok) { + k8sBlockItemsArray(nsBlock).forEach(function (it) { + var meta = it && it.metadata; + addName(meta && meta.name); + }); + } + + for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) { + var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop]; + if (!blk || !blk.ok) continue; + k8sBlockItemsArray(blk).forEach(function (it) { + var meta = it && it.metadata; + addName(meta && meta.namespace); + }); + } + + ordered.sort(function (a, b) { + return a.localeCompare(b, "ru"); + }); + return ordered; + } + + /** + * Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-. + * @param {object | null | undefined} block + * @param {string | null} kubeErr + * @param {HTMLElement | null} root + * @param {string} filterKey + * @param {function(HTMLElement, object[]): void} renderFn + */ + function clusterK8sRenderBlockNs(block, kubeErr, root, filterKey, renderFn) { + if (!root) return; + var sel = document.getElementById("cluster-k8s-filter-" + filterKey); + var ns = sel && sel.value ? sel.value : ""; + if (kubeErr) { + clusterK8sShowMessage(root, kubeErr); + return; + } + if (!block || !block.ok) { + clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны."); + return; + } + var items = k8sFilterItemsByNamespace(k8sBlockItemsArray(block), ns); + items = k8sSortNamespacedItems(items); + renderFn(root, items); + } + + /** + * Заполняет option во всех селектах фильтра; сохраняет прежнее value, если namespace ещё в списке. + * @param {string[]} namespaceNames + */ + function clusterK8sPopulateNsSelects(namespaceNames) { + CLUSTER_K8S_NS_BLOCKS.forEach(function (row) { + var sel = document.getElementById("cluster-k8s-filter-" + row.key); + if (!sel) return; + var prev = (sel.value || "").trim(); + sel.replaceChildren(); + var o0 = document.createElement("option"); + o0.value = ""; + o0.textContent = "Все namespace"; + sel.appendChild(o0); + namespaceNames.forEach(function (name) { + var o = document.createElement("option"); + o.value = name; + o.textContent = name; + sel.appendChild(o); + }); + if (prev && namespaceNames.indexOf(prev) >= 0) sel.value = prev; + else sel.value = ""; + }); + } + function k8sNodeReady(item) { const cond = (item.status && item.status.conditions) || []; for (var i = 0; i < cond.length; i++) { @@ -1244,13 +1373,70 @@ clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows); } + /** + * Фабрика функций рендера по ключу фильтра (для подов нужен clusterName). + * @param {string} cname + * @returns {Record} + */ + function buildClusterK8sNsFilterRenderMap(cname) { + return { + pods: function (root, items) { + renderK8sPodsTable(root, items, cname); + }, + deployments: renderK8sDeploymentsTable, + statefulsets: renderK8sStsDsTable, + daemonsets: renderK8sStsDsTable, + replicasets: renderK8sReplicaSetsTable, + jobs: renderK8sJobsTable, + cronjobs: renderK8sCronJobsTable, + services: renderK8sServicesTable, + ingresses: renderK8sIngressesTable, + pvcs: renderK8sPvcsTable, + }; + } + + /** + * Перерисовка одной таблицы после смены select (без нового API-запроса). + * @param {string} key ключ data-cluster-k8s-ns-filter + */ + function refillClusterK8sNsFilter(key) { + if (!lastClusterK8sOverview) return; + var d = lastClusterK8sOverview; + var kubeErr = d.kubeconfig_error || null; + var cname = d.cluster_name || getClusterPageName() || ""; + var renderByKey = buildClusterK8sNsFilterRenderMap(cname); + var renderFn = renderByKey[key]; + if (!renderFn) return; + for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) { + if (CLUSTER_K8S_NS_BLOCKS[i].key !== key) continue; + var row = CLUSTER_K8S_NS_BLOCKS[i]; + clusterK8sRenderBlockNs(d[row.prop], kubeErr, document.getElementById(row.rootId), key, renderFn); + return; + } + } + + /** Делегирование: один listener на document для всех .cluster-k8s-ns-select. */ + function ensureClusterNsFilterListeners() { + if (clusterNsFiltersBound) return; + document.addEventListener("change", function (ev) { + var t = ev.target; + if (!t || !t.matches || !t.matches("select.cluster-k8s-ns-select[data-cluster-k8s-ns-filter]")) return; + var key = t.getAttribute("data-cluster-k8s-ns-filter"); + if (key) refillClusterK8sNsFilter(key); + }); + clusterNsFiltersBound = true; + } + /** * Таблицы Kubernetes из полей ``k8s_*`` ответа overview. * @param {object} d */ function fillClusterK8sTablesFromOverview(d) { + lastClusterK8sOverview = d; const kubeErr = d.kubeconfig_error || null; const cname = d.cluster_name || getClusterPageName() || ""; + ensureClusterNsFilterListeners(); + clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable); clusterK8sRenderBlock( d.k8s_namespaces, @@ -1258,53 +1444,20 @@ document.getElementById("cluster-k8s-namespaces-root"), renderK8sNamespacesTable, ); - clusterK8sRenderBlock(d.k8s_pods, kubeErr, document.getElementById("cluster-k8s-pods-root"), function (root, items) { - renderK8sPodsTable(root, items, cname); + + const renderByKey = buildClusterK8sNsFilterRenderMap(cname); + CLUSTER_K8S_NS_BLOCKS.forEach(function (row) { + clusterK8sRenderBlockNs( + d[row.prop], + kubeErr, + document.getElementById(row.rootId), + row.key, + renderByKey[row.key], + ); }); - clusterK8sRenderBlock( - d.k8s_deployments, - kubeErr, - document.getElementById("cluster-k8s-deployments-root"), - renderK8sDeploymentsTable, - ); - clusterK8sRenderBlock( - d.k8s_statefulsets, - kubeErr, - document.getElementById("cluster-k8s-statefulsets-root"), - renderK8sStsDsTable, - ); - clusterK8sRenderBlock( - d.k8s_daemonsets, - kubeErr, - document.getElementById("cluster-k8s-daemonsets-root"), - renderK8sStsDsTable, - ); - clusterK8sRenderBlock( - d.k8s_replicasets, - kubeErr, - document.getElementById("cluster-k8s-replicasets-root"), - renderK8sReplicaSetsTable, - ); - clusterK8sRenderBlock(d.k8s_jobs, kubeErr, document.getElementById("cluster-k8s-jobs-root"), renderK8sJobsTable); - clusterK8sRenderBlock( - d.k8s_cronjobs, - kubeErr, - document.getElementById("cluster-k8s-cronjobs-root"), - renderK8sCronJobsTable, - ); - clusterK8sRenderBlock( - d.k8s_services, - kubeErr, - document.getElementById("cluster-k8s-services-root"), - renderK8sServicesTable, - ); - clusterK8sRenderBlock( - d.k8s_ingresses, - kubeErr, - document.getElementById("cluster-k8s-ingresses-root"), - renderK8sIngressesTable, - ); - clusterK8sRenderBlock(d.k8s_pvcs, kubeErr, document.getElementById("cluster-k8s-pvcs-root"), renderK8sPvcsTable); + + /* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */ + clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d)); } /** @@ -1506,43 +1659,57 @@ async function loadStats() { const grid = document.getElementById("stats-grid"); - if (!grid) return; + const donutsRoot = document.getElementById("aggregate-donuts"); + const needGrid = !!grid; + const needDonuts = !!donutsRoot; + if (!needGrid && !needDonuts) return; try { const s = await api("/stats"); - 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], - ["Сумма workers (meta)", s.total_workers_from_meta ?? 0], - ["Заданий в памяти", s.jobs_total], - ["Заданий с ошибкой", s.jobs_recent_failed], - ]; - grid.replaceChildren(); - rows.forEach(function (kv) { - 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); - }); + if (needGrid) { + revealStatsCardBody(); + paintStatsEnvRow(); + const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker"; + updateStatsEngineHint(cliForHint); + const dirVal = + s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined + ? String(s.local_cluster_dirs_count) + : "—"; + const rows = [ + ["Каталогов", dirVal], + ["Кластеров в kind", s.kind_clusters_count], + ["Сумма workers (meta)", s.total_workers_from_meta ?? 0], + ["Заданий в памяти", s.jobs_total], + ["Заданий с ошибкой", s.jobs_recent_failed], + ["Аддонов", s.helm_addons_installable_count ?? 0], + ]; + grid.replaceChildren(); + rows.forEach(function (kv) { + 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); + }); + } cachedClusterResourcesByName = {}; - renderAggregateDonuts( - s.aggregate_cluster_resources, - s.cluster_resources_error, - s.container_cli || "docker", - ); + if (needDonuts) { + renderAggregateDonuts( + s.aggregate_cluster_resources, + s.cluster_resources_error, + s.container_cli || "docker", + ); + } } catch (e) { - coverStatsCardWithError("Статистика", e.message || String(e)); - grid.replaceChildren(); + if (needGrid) { + coverStatsCardWithError("Статистика", e.message || String(e)); + grid.replaceChildren(); + } cachedClusterResourcesByName = {}; const donuts = document.getElementById("aggregate-donuts"); if (donuts) { @@ -1692,14 +1859,6 @@ }; } - function jobBadgeClass(status) { - if (status === "success") return "badge badge-ok"; - if (status === "failed") return "badge badge-err"; - if (status === "running") return "badge badge-run"; - if (status === "cancelled") return "badge badge-cancelled"; - return "badge"; - } - async function loadClusters() { const tbody = document.querySelector("#tbl-clusters tbody"); const msg = document.getElementById("list-msg"); @@ -1768,124 +1927,145 @@ } /** - * Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена). + * Формат времени как на странице «Журнал» (ISO → «YYYY-MM-DD HH:MM:SS.sss Z»). + * @param {string} iso */ - async function clearCompletedJobs() { - const ok = await openConfirmModal({ - title: "Очистить завершённые задания?", - message: - "Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.", - confirmLabel: "Очистить", - danger: false, - }); - if (!ok) return; - const msg = document.getElementById("jobs-msg"); + function formatJournalUtcDisplay(iso) { + if (!iso) return "—"; try { - const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } }); - const text = await r.text(); - var data = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch (e) { - data = {}; - } - if (!r.ok) { - showToast(formatApiError(data, text || r.statusText), true); - return; - } - var n = data.removed != null ? data.removed : 0; - if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать."; - showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false); - await loadJobs(); - await loadStats(); + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z"); } catch (e) { - if (msg) msg.textContent = "Ошибка: " + e.message; - showToast(e.message, true); + return iso; } } /** - * Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка). + * Класс плашки статуса записи журнала (success / failed / cancelled). + * @param {string} st */ - function bindJobsTableJobLogOpen() { + function journalStatusPillClass(st) { + const s = (st || "").toLowerCase(); + if (s === "success") return "journal-status-pill journal-status-pill--ok"; + if (s === "failed") return "journal-status-pill journal-status-pill--err"; + if (s === "cancelled") return "journal-status-pill journal-status-pill--warn"; + return "journal-status-pill"; + } + + /** + * Раскрытие строки лога — как на /journal (режим заданий по кластеру). + * Автор: Сергей Антропов — https://devops.org.ru + */ + function bindCreateJobsJournalToggle() { const tbl = document.getElementById("tbl-jobs"); - if (!tbl || tbl.getAttribute("data-jobs-job-log-bound") === "1") return; - tbl.setAttribute("data-jobs-job-log-bound", "1"); + if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return; + tbl.setAttribute("data-create-jobs-log-bound", "1"); tbl.addEventListener( "click", function (ev) { - const btn = ev.target && ev.target.closest ? ev.target.closest("button.jobs-open-log-btn") : null; - if (!btn || !tbl.contains(btn)) return; - const jobId = btn.getAttribute("data-job-id"); - if (!jobId) return; - ev.preventDefault(); - openJobLogModal(jobId); + const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null; + if (!btn || !tbl.contains(btn) || btn.disabled) return; + const tr = btn.closest("tr.journal-data-row"); + if (!tr) return; + const trDetails = tr.nextElementSibling; + if (!trDetails || !trDetails.classList.contains("journal-details-row")) return; + const pre = trDetails.querySelector("pre.journal-log-pre"); + if (!pre) return; + const open = trDetails.classList.contains("hidden"); + trDetails.classList.toggle("hidden", !open); + pre.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + btn.textContent = open ? "Скрыть лог" : "Показать лог"; }, false, ); } + /** + * Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания + * (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе). + */ async function loadJobs() { const tbody = document.querySelector("#tbl-jobs tbody"); const msg = document.getElementById("jobs-msg"); if (!tbody) return; try { - const rows = await api("/jobs?limit=30"); + const data = await api("/journal/recent?limit=30&offset=0"); + const entries = Array.isArray(data.entries) ? data.entries : []; const frag = document.createDocumentFragment(); - rows.forEach(function (j) { + entries.forEach(function (e) { const tr = document.createElement("tr"); - const st = escapeHtml(j.status || ""); - var kindTag = ""; - if (j.kind === "start_cluster") kindTag = "[старт] "; - else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] "; - else if (j.kind === "stop_containers") kindTag = "[стоп] "; - else if (j.kind === "start_containers") kindTag = "[запуск узлов] "; - var cellMsg = kindTag + (j.message || "").slice(0, 140); - if ((j.status === "running" || j.status === "queued") && j.progress_stage) { - cellMsg = - kindTag + - j.progress_stage + - (j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); - } - var msgCol = - "
" + escapeHtml(cellMsg) + "
"; - if (j.status === "failed" || j.status === "cancelled") { - msgCol += - ""; - } - const iso = j.created_at_utc || ""; - const timeLabel = formatJobTimeUtc(iso); + tr.className = "journal-data-row"; + const cluster = e.source_cluster || e.cluster_name || "—"; + const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc); + const kind = e.kind || ""; + const message = (e.message || "").trim(); + const lines = Array.isArray(e.log_lines) ? e.log_lines : []; + const logText = lines.join("\n"); + const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11); + tr.innerHTML = - "" + - "" + - escapeHtml(j.cluster_name || "—") + + "" + + escapeHtml(finished) + "" + + "" + + escapeHtml(cluster) + + "" + + "" + + escapeHtml(kind) + + "" + "" + - st + + escapeHtml(e.status || "") + "" + - "" + - msgCol + + "" + + escapeHtml(message || "—") + + "" + + "" + + "" + ""; + frag.appendChild(tr); + + if (logText) { + const trDetails = document.createElement("tr"); + trDetails.className = "journal-details-row hidden"; + trDetails.innerHTML = + "" + + "" + + ""; + const pre = trDetails.querySelector("pre"); + if (pre) pre.textContent = logText; + frag.appendChild(trDetails); + } else { + const btn = tr.querySelector(".btn-journal-toggle"); + if (btn) { + btn.disabled = true; + btn.textContent = "Нет строк"; + } + } }); tbody.replaceChildren(frag); if (msg) { - msg.textContent = rows.length - ? "" - : "Заданий ещё не было (или контейнер перезапускали)."; + if (!entries.length) { + msg.textContent = + "Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами)."; + } else { + const total = typeof data.total === "number" ? data.total : entries.length; + msg.textContent = + total > entries.length + ? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах." + : ""; + } } } catch (e) { - if (msg) msg.textContent = "Задания: " + e.message; + if (msg) msg.textContent = "Журнал заданий: " + e.message; } } @@ -2298,8 +2478,10 @@ async function refreshLists() { try { await loadHealth(); - if (isHomePage()) { + if (isHomePage() || isClustersListPage()) { await loadStats(); + } + if (isClustersListPage()) { await loadClusters(); } if (isCreatePage()) { @@ -2390,15 +2572,15 @@ }); } - const btnClearJobs = document.getElementById("btn-jobs-clear"); - if (btnClearJobs) { - btnClearJobs.addEventListener("click", function () { - clearCompletedJobs(); + const btnJobsRefresh = document.getElementById("btn-jobs-refresh"); + if (btnJobsRefresh) { + btnJobsRefresh.addEventListener("click", function () { + loadJobs(); }); } if (document.getElementById("tbl-jobs")) { - bindJobsTableJobLogOpen(); + bindCreateJobsJournalToggle(); } const mClose = document.getElementById("modal-close"); @@ -2486,6 +2668,9 @@ await loadHealth(); if (isHomePage()) { await loadStats(); + } + if (isClustersListPage()) { + await loadStats(); await loadClusters(); } if (isCreatePage()) { @@ -2502,8 +2687,12 @@ if (isHomePage() && lm) { lm.textContent = "Ошибка загрузки панели: " + errText; } + if (isClustersListPage() && lm) { + lm.textContent = "Ошибка загрузки: " + errText; + } } finally { hideHomePageLoadingOverlay(); + hideClustersPageLoadingOverlay(); } })(); if (isCreatePage()) { diff --git a/app/static/js/journal.js b/app/static/js/journal.js index 8bfde71..781a056 100644 --- a/app/static/js/journal.js +++ b/app/static/js/journal.js @@ -1,5 +1,5 @@ /** - * Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу). + * Страница «Журнал»: режимы per_cluster / provision / helm_addons, пагинация по 30. * * Автор: Сергей Антропов * Сайт: https://devops.org.ru @@ -11,15 +11,14 @@ if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return; const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); - /** Размер страницы (синхронно с default limit в API). */ var PAGE_SIZE = 30; - /** Текущая страница с 1 (состояние для пагинации). */ + /** @type {'per_cluster' | 'provision' | 'helm_addons'} */ + var viewMode = "per_cluster"; var currentPage = 1; + /** Выбранный кластер в режиме per_cluster */ + var selectedCluster = ""; - /** - * @param {string} path - */ async function apiGet(path) { const url = path.startsWith("http") ? path : API + path; const r = await fetch(url, { headers: { Accept: "application/json" } }); @@ -50,8 +49,11 @@ const tableWrap = document.getElementById("journal-table-wrap"); const refreshBtn = document.getElementById("journal-refresh-btn"); const pagNav = document.getElementById("journal-pagination"); + const clusterSelect = document.getElementById("journal-cluster-select"); + const clusterPicker = document.getElementById("journal-cluster-picker"); + const thKind = document.getElementById("journal-th-kind"); + const sectionTitle = document.getElementById("journal-table-section-title"); - /** Скрыть полноэкранный спиннер первой загрузки (повторные вызовы безопасны). */ function hideJournalPageLoadingOverlay() { const el = document.getElementById("journal-page-loading-overlay"); if (!el) return; @@ -86,12 +88,6 @@ return "journal-status-pill"; } - /** - * Список номеров страниц и многоточий для компактной навигации. - * @param {number} current - * @param {number} total - * @returns {(number|string)[]} - */ function visiblePageItems(current, total) { const delta = 2; const range = []; @@ -115,9 +111,6 @@ return out; } - /** - * @param {number} itemCount число строк на текущей странице (для подписи «записи N–M») - */ function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) { if (!pagNav) return; pagNav.innerHTML = ""; @@ -141,20 +134,11 @@ return b; } - const firstOff = 0; - const prevOff = Math.max(0, offset - limit); - const nextOff = Math.min(Math.max(0, total - 1), offset + limit); - const lastOff = totalPages > 0 ? (totalPages - 1) * limit : 0; - wrap.appendChild( - mkBtn("« Первая", page <= 1, "Первая страница", function () { - onSelectPage(1); - }), + mkBtn("« Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }), ); wrap.appendChild( - mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () { - onSelectPage(page - 1); - }), + mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }), ); const pagesEl = document.createElement("div"); @@ -180,9 +164,7 @@ if (pNum === page) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current"); if (pNum !== page) { - b.addEventListener("click", function () { - onSelectPage(pNum); - }); + b.addEventListener("click", function () { onSelectPage(pNum); }); } pagesEl.appendChild(b); }); @@ -190,27 +172,13 @@ wrap.appendChild(pagesEl); wrap.appendChild( - mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () { - onSelectPage(page + 1); - }), + mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }), ); wrap.appendChild( - mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { - onSelectPage(totalPages); - }), + mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }), ); pagNav.appendChild(wrap); - - const from = total === 0 || itemCount === 0 ? 0 : offset + 1; - const to = total === 0 || itemCount === 0 ? 0 : offset + itemCount; - const hint = document.createElement("p"); - hint.className = "muted journal-pagination-summary"; - hint.textContent = - from && to - ? "Записи " + from + "–" + to + " из " + total + " · страница " + page + " из " + totalPages - : "Всего записей: " + total + (totalPages ? " · страниц: " + totalPages : ""); - pagNav.appendChild(hint); } function goToPage(p) { @@ -220,15 +188,120 @@ } /** - * @param {boolean} resetPage сбросить на 1 (после «Обновить») + * Построить строки таблицы: jobs (log_lines) или dir-лог (lines). */ + function renderTableRows(entries, mode) { + if (!tbody) return; + tbody.innerHTML = ""; + entries.forEach(function (e) { + const tr = document.createElement("tr"); + tr.className = "journal-data-row"; + const cluster = e.source_cluster || e.cluster_name || "—"; + const finished = formatUtc( + e.finished_at_utc || e.created_at_utc, + ); + const kind = e.kind || ""; + const msg = (e.message || "").trim(); + let lines = []; + if (mode === "per_cluster") { + lines = Array.isArray(e.log_lines) ? e.log_lines : []; + } else { + lines = Array.isArray(e.lines) ? e.lines : []; + } + const logText = lines.join("\n"); + const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11); + + tr.innerHTML = + "" + esc(finished) + "" + + "" + esc(cluster) + "" + + "" + esc(kind) + "" + + "" + esc(e.status || "") + "" + + "" + esc(msg || "—") + "" + + "" + + "" + + ""; + tbody.appendChild(tr); + + if (logText) { + const trDetails = document.createElement("tr"); + trDetails.className = "journal-details-row hidden"; + trDetails.innerHTML = + "" + + "" + + ""; + tbody.appendChild(trDetails); + const pre = trDetails.querySelector("pre"); + if (pre) pre.textContent = logText; + const btn = tr.querySelector(".btn-journal-toggle"); + if (btn && trDetails && pre) { + btn.addEventListener("click", function () { + const open = trDetails.classList.contains("hidden"); + trDetails.classList.toggle("hidden", !open); + pre.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + btn.textContent = open ? "Скрыть лог" : "Показать лог"; + }); + } + } else { + const btn = tr.querySelector(".btn-journal-toggle"); + if (btn) { + btn.disabled = true; + btn.textContent = "Нет строк"; + } + } + }); + } + + function applyViewLabels() { + if (thKind) { + if (viewMode === "per_cluster") thKind.textContent = "Тип задания"; + else thKind.textContent = "Вид операции"; + } + if (sectionTitle) { + if (viewMode === "per_cluster") sectionTitle.textContent = "Задания выбранного кластера"; + else if (viewMode === "provision") sectionTitle.textContent = "Развёртывание по кластерам"; + else sectionTitle.textContent = "Helm-аддоны по кластерам"; + } + if (clusterPicker) { + clusterPicker.classList.toggle("hidden", viewMode !== "per_cluster"); + } + } + + function buildListUrl() { + const off = (currentPage - 1) * PAGE_SIZE; + const q = + "?limit=" + encodeURIComponent(String(PAGE_SIZE)) + + "&offset=" + encodeURIComponent(String(off)); + if (viewMode === "per_cluster") { + return "/journal/recent" + q + "&cluster=" + encodeURIComponent(selectedCluster); + } + if (viewMode === "provision") return "/journal/provision" + q; + return "/journal/helm-addons" + q; + } + async function loadJournal(resetPage) { if (!tbody || !statusEl) { hideJournalPageLoadingOverlay(); return; } + applyViewLabels(); + if (resetPage) currentPage = 1; - const offset = (currentPage - 1) * PAGE_SIZE; + + if (viewMode === "per_cluster" && !selectedCluster) { + statusEl.textContent = "Выберите кластер."; + tbody.innerHTML = ""; + if (emptyEl) { + emptyEl.classList.remove("hidden"); + emptyEl.textContent = "Укажите кластер в списке выше."; + } + if (tableWrap) tableWrap.classList.add("hidden"); + if (pagNav) pagNav.classList.add("hidden"); + hideJournalPageLoadingOverlay(); + return; + } + statusEl.textContent = "Загрузка…"; tbody.innerHTML = ""; if (emptyEl) emptyEl.classList.add("hidden"); @@ -236,25 +309,19 @@ if (pagNav) pagNav.classList.add("hidden"); try { - const data = await apiGet( - "/journal/recent?limit=" + - encodeURIComponent(String(PAGE_SIZE)) + - "&offset=" + - encodeURIComponent(String(offset)), - ); + const data = await apiGet(buildListUrl()); const entries = data.entries || []; const total = typeof data.total === "number" ? data.total : entries.length; const limit = typeof data.limit === "number" ? data.limit : PAGE_SIZE; - const respOffset = typeof data.offset === "number" ? data.offset : offset; + const respOffset = typeof data.offset === "number" ? data.offset : (currentPage - 1) * PAGE_SIZE; const totalPages = typeof data.total_pages === "number" ? data.total_pages : Math.ceil(total / limit) || 0; let page = typeof data.page === "number" ? data.page : currentPage; - if (total > 0 && currentPage > totalPages) { + if (total > 0 && currentPage > totalPages && totalPages > 0) { currentPage = totalPages; await loadJournal(false); return; } - if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) { currentPage = totalPages; await loadJournal(false); @@ -264,76 +331,40 @@ page = currentPage; if (!entries.length) { - if (emptyEl) emptyEl.classList.remove("hidden"); + if (emptyEl) { + emptyEl.classList.remove("hidden"); + emptyEl.textContent = + viewMode === "per_cluster" + ? "Нет записей в journal/jobs_history.json для этого кластера." + : viewMode === "provision" + ? "Нет файлов provision_log.json (или каталогов кластеров)." + : "Нет файлов helm_addon_log.json — установите аддон со страницы «Аддоны»."; + } if (tableWrap) tableWrap.classList.add("hidden"); statusEl.textContent = total === 0 - ? "Нет записей в журналах." - : "Страница пуста (всего записей: " + total + ")."; + ? "Записей нет." + : "Страница пуста (всего: " + total + ")."; renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage); return; } + if (emptyEl) emptyEl.classList.add("hidden"); + if (tableWrap) tableWrap.classList.remove("hidden"); + const fromN = respOffset + 1; const toN = respOffset + entries.length; statusEl.textContent = "Записи " + fromN + "–" + toN + " из " + total + " · страница " + page + " из " + (totalPages || 1) + "."; - entries.forEach(function (e) { - const tr = document.createElement("tr"); - tr.className = "journal-data-row"; - const cluster = e.source_cluster || e.cluster_name || "—"; - const finished = formatUtc(e.finished_at_utc || e.created_at_utc); - const msg = (e.message || "").trim(); - const lines = Array.isArray(e.log_lines) ? e.log_lines : []; - const logText = lines.join("\n"); - const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11); - - tr.innerHTML = - "" + esc(finished) + "" + - "" + esc(cluster) + "" + - "" + esc(e.kind || "") + "" + - "" + esc(e.status || "") + "" + - "" + esc(msg || "—") + "" + - "" + - "" + - ""; - tbody.appendChild(tr); - - if (logText) { - const trDetails = document.createElement("tr"); - trDetails.className = "journal-details-row hidden"; - trDetails.innerHTML = - "" + - "" + - ""; - tbody.appendChild(trDetails); - const pre = trDetails.querySelector("pre"); - if (pre) pre.textContent = logText; - const btn = tr.querySelector(".btn-journal-toggle"); - if (btn && trDetails && pre) { - btn.addEventListener("click", function () { - const open = trDetails.classList.contains("hidden"); - trDetails.classList.toggle("hidden", !open); - pre.hidden = !open; - btn.setAttribute("aria-expanded", open ? "true" : "false"); - btn.textContent = open ? "Скрыть лог" : "Показать лог"; - }); - } - } else { - const btn = tr.querySelector(".btn-journal-toggle"); - if (btn) { - btn.disabled = true; - btn.textContent = "Нет строк"; - } - } - }); - + renderTableRows(entries, viewMode); renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage); } catch (err) { statusEl.textContent = "Ошибка: " + err.message; - if (emptyEl) emptyEl.classList.remove("hidden"); + if (emptyEl) { + emptyEl.classList.remove("hidden"); + emptyEl.textContent = "Не удалось загрузить данные."; + } if (tableWrap) tableWrap.classList.add("hidden"); if (pagNav) pagNav.classList.add("hidden"); } finally { @@ -341,7 +372,70 @@ } } - if (refreshBtn) refreshBtn.addEventListener("click", function () { loadJournal(true); }); + async function loadClusterList() { + if (!clusterSelect) return; + try { + const rows = await apiGet("/clusters"); + clusterSelect.innerHTML = ""; + const names = rows + .map(function (c) { return c.name; }) + .filter(Boolean) + .sort(); + if (!names.length) { + const o = document.createElement("option"); + o.value = ""; + o.textContent = "Нет каталогов кластеров"; + clusterSelect.appendChild(o); + selectedCluster = ""; + return; + } + names.forEach(function (name) { + const o = document.createElement("option"); + o.value = name; + o.textContent = name; + clusterSelect.appendChild(o); + }); + if (!selectedCluster || names.indexOf(selectedCluster) < 0) { + selectedCluster = names[0]; + } + clusterSelect.value = selectedCluster; + } catch (e) { + clusterSelect.innerHTML = ""; + const o = document.createElement("option"); + o.value = ""; + o.textContent = "Ошибка загрузки списка"; + clusterSelect.appendChild(o); + selectedCluster = ""; + } + } - loadJournal(false); + function bindViewRadios() { + document.querySelectorAll('input[name="journal_view"]').forEach(function (radio) { + radio.addEventListener("change", function () { + if (!radio.checked) return; + viewMode = radio.value; + currentPage = 1; + loadJournal(false); + }); + }); + } + + if (clusterSelect) { + clusterSelect.addEventListener("change", function () { + selectedCluster = String(clusterSelect.value || "").trim(); + currentPage = 1; + if (viewMode === "per_cluster") loadJournal(false); + }); + } + + if (refreshBtn) { + refreshBtn.addEventListener("click", function () { loadJournal(true); }); + } + + bindViewRadios(); + + (async function init() { + await loadClusterList(); + await loadJournal(false); + })(); })(); diff --git a/app/static/js/theme.js b/app/static/js/theme.js index f98be2e..3a37b42 100644 --- a/app/static/js/theme.js +++ b/app/static/js/theme.js @@ -44,10 +44,59 @@ setTheme(getTheme() === "dark" ? "light" : "dark"); } + /** + * Нормализует pathname: убирает хвостовые слэши (кроме корня «/»). + * Нужен для сопоставления с маршрутами FastAPI без зависимости от редиректов. + */ + function normalizePathname(path) { + var p = path || "/"; + if (p.length > 1 && p.charAt(p.length - 1) === "/") { + p = p.replace(/\/+$/, ""); + } + return p || "/"; + } + + /** + * Какой пункт шапки (href пилюли) должен быть активен по текущему URL. + * Дублирует логику nav_active из шаблонов: страницы кластера и /cluster/…/edit + * относятся к разделу «Кластеры», чтобы подсветка не зависела только от SSR + * (кэш образа, устаревший контейнер и т.п.). + */ + function desiredNavHrefFromPath() { + var path = normalizePathname(window.location.pathname); + if (path === "/") return "/"; + if (path === "/clusters") return "/clusters"; + if (/^\/cluster\/[^/]+\/edit$/.test(path)) return "/clusters"; + if (/^\/cluster\/[^/]+$/.test(path)) return "/clusters"; + if (path.indexOf("/cluster-addons") === 0) return "/cluster-addons"; + if (path.indexOf("/journal") === 0) return "/journal"; + if (path.indexOf("/documentation") === 0) return "/documentation"; + if (path.indexOf("/cluster-create") === 0) return "/clusters"; + return null; + } + + /** Снимает активность со всех пилюль в прокручиваемой полосе и включает одну по href. */ + function syncNavPillActive() { + var want = desiredNavHrefFromPath(); + if (!want) return; + var scroll = document.querySelector(".nav-links-scroll"); + if (!scroll) return; + var pills = scroll.querySelectorAll("a.nav-link.nav-pill[href]"); + pills.forEach(function (a) { + a.classList.remove("nav-pill--active"); + }); + pills.forEach(function (a) { + if (a.getAttribute("href") === want) { + a.classList.add("nav-pill--active"); + } + }); + } + function init() { var btn = document.getElementById("theme-toggle"); if (btn) btn.addEventListener("click", toggleTheme); syncToggleButton(); + syncNavPillActive(); } /* defer: скрипт в выполняется после разбора документа — кнопка уже есть. */ diff --git a/app/static/style.css b/app/static/style.css index 055d41b..ccc0ab2 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -440,6 +440,16 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) font-size: 1.05rem; } +/* Отдельная карточка на панели: донаты + список кластеров (GET /stats) */ +.home-resources-summary-inner { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.home-resources-summary-card .donuts-card-title { + margin-bottom: 0.5rem; +} + /* Пояснение: узлы kind и метрики идут через Docker или Podman (CONTAINER_CLI) */ .stats-engine-hint { margin: 0; @@ -460,10 +470,10 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) padding: 0.85rem 0.65rem; } -/* Среда: Docker API · Kind · Kubectl · Каталогов — один ряд, 4 колонки */ +/* Среда: Docker API · Kind · Kubectl (3 в ряд); число каталогов — в сетке метрик под ними */ .stats-env-cards { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 0.65rem; flex-shrink: 0; } @@ -513,10 +523,10 @@ html[data-theme="light"] .stats-env-card--metric { background: rgba(59, 130, 246, 0.07); } -/* Метрики: по 4 карточки в ряд (4 метрики — один ряд) */ +/* Метрики панели: по 3 карточки в ряд */ .stats-metrics-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 0.65rem; flex: 1 1 auto; align-content: start; @@ -524,7 +534,7 @@ html[data-theme="light"] .stats-env-card--metric { @media (max-width: 520px) { .stats-metrics-grid { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: 1fr; } } @@ -909,6 +919,46 @@ html[data-theme="light"] .cluster-node-res-card__bar-track { background: rgba(239, 68, 68, 0.12); } +/* Страница «Кластеры»: заголовок слева, кнопка «Создать кластер» справа */ +.clusters-page-hero-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem 1.5rem; +} +.clusters-page-hero-text { + flex: 1 1 14rem; + min-width: 0; +} +.clusters-page-hero-lead { + margin: 0; +} +.clusters-page-hero-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + padding-top: 0.15rem; +} +.clusters-page-create-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.6rem 1.35rem; + font-size: 0.95rem; + font-weight: 650; + border-radius: 10px; + text-decoration: none; + border: 1px solid transparent; + background: var(--accent); + color: #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.14); + white-space: nowrap; +} +.clusters-page-create-btn:hover { + filter: brightness(1.08); +} + /* Прогресс создания кластера */ .create-progress-wrap { margin-top: 1rem; @@ -2170,6 +2220,31 @@ html[data-theme="light"] .cluster-kube-hint.cluster-kube-hint--info { margin: 0 0 0.55rem; font-size: 1.02rem; } +/* Заголовок карточки K8s + выпадающий фильтр namespace справа */ +.cluster-k8s-card-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 0.55rem; +} +.cluster-k8s-card-header .cluster-detail-section-title { + margin: 0; + flex: 1 1 auto; + min-width: 0; +} +.cluster-k8s-ns-filter-wrap { + flex: 0 0 auto; +} +.cluster-k8s-ns-select { + width: auto; + min-width: 10rem; + max-width: min(18rem, 100%); + margin: 0; + padding: 0.35rem 0.5rem; + font-size: 0.88rem; +} /* Сетка карточек узлов на странице кластера: не более 3 карточек в ряд */ .cluster-page-node-cards--wide.cluster-nodes-resources__cards-row { @@ -2769,69 +2844,199 @@ html[data-theme="light"] .cluster-edit-yaml-preview-head { flex: 1 1 14rem; max-width: 28rem; } -.cluster-addons-hint { - margin: 0.35rem 0 0; - font-size: 0.82rem; -} .cluster-addons-status-bar { - margin-top: 0.65rem; - font-size: 0.85rem; + margin-top: 0.85rem; +} +.cluster-addons-status-bar--message .cluster-addons-status-msg { + margin: 0; + font-size: 0.88rem; line-height: 1.45; } +.cluster-addons-status-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); + gap: 0.65rem; +} +.cluster-addons-status-card { + border-radius: 10px; + border: 1px solid var(--border); + padding: 0.65rem 0.75rem; + background: rgba(0, 0, 0, 0.14); + display: flex; + flex-direction: column; + gap: 0.35rem; + min-height: 3.5rem; + justify-content: center; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +html[data-theme="light"] .cluster-addons-status-card { + background: rgba(15, 23, 42, 0.04); +} +.cluster-addons-status-card--on { + border-color: rgba(34, 197, 94, 0.45); + box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12); +} +.cluster-addons-status-card--off { + border-color: var(--border); + opacity: 0.92; +} +.cluster-addons-status-card__label { + font-size: 0.78rem; + font-weight: 600; + color: var(--muted); + line-height: 1.25; + word-break: break-word; +} +.cluster-addons-status-card__value { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: 0.02em; +} +.cluster-addons-status-card--on .cluster-addons-status-card__value { + color: rgb(34, 197, 94); +} +.cluster-addons-status-card--off .cluster-addons-status-card__value { + color: var(--muted); +} .cluster-addons-grid { display: grid; + grid-template-columns: minmax(0, 1fr); gap: 1rem; margin: 1rem 0; } -@media (min-width: 52rem) { - .cluster-addons-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +/* Шапка карточки аддона: заголовок + бейдж «установлен» справа */ +.cluster-addon-card__header { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: 0.65rem; + margin-bottom: 0.35rem; } -.cluster-addon-card__title { - margin: 0 0 0.35rem; +.cluster-addon-card__header .cluster-addon-card__title { + margin: 0; + flex: 1 1 auto; + min-width: 0; font-size: 1.05rem; + line-height: 1.3; +} +.cluster-addon-card__badge { + flex: 0 0 auto; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 0.28rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--border); + line-height: 1.2; + white-space: nowrap; +} +.cluster-addon-card__badge--on { + border-color: rgba(34, 197, 94, 0.5); + color: rgb(34, 197, 94); + background: rgba(34, 197, 94, 0.1); +} +.cluster-addon-card__badge--off { + color: var(--muted); + background: rgba(0, 0, 0, 0.12); +} +html[data-theme="light"] .cluster-addon-card__badge--off { + background: rgba(15, 23, 42, 0.06); +} +.cluster-addon-card__values-details { + margin: 0.75rem 0 0; + border-radius: 8px; + border: 1px solid var(--border); + padding: 0.5rem 0.65rem; + background: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .cluster-addon-card__values-details { + background: rgba(15, 23, 42, 0.03); +} +.cluster-addon-card__values-summary { + cursor: pointer; + font-weight: 600; + font-size: 0.88rem; + list-style-position: outside; + padding: 0.15rem 0; +} +.cluster-addon-card__values-hint { + margin: 0.5rem 0 0.45rem; + font-size: 0.78rem; + line-height: 1.45; +} +.cluster-addon-card__values-subhead { + margin: 0.65rem 0 0.35rem; + font-size: 0.8rem; + font-weight: 700; + color: var(--text); +} +.cluster-addon-card__values-details .cluster-addon-card__values-subhead:first-of-type { + margin-top: 0.35rem; +} +.cluster-addon-card__values-textarea { + display: block; + width: 100%; + box-sizing: border-box; + margin: 0; + min-height: 6rem; + padding: 0.5rem 0.6rem; + font-size: 0.78rem; + line-height: 1.4; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.18); + color: inherit; + resize: vertical; +} +html[data-theme="light"] .cluster-addon-card__values-textarea { + background: rgba(255, 255, 255, 0.85); } .cluster-addon-card__desc { margin: 0 0 0.75rem; font-size: 0.85rem; line-height: 1.45; } -.cluster-addon-card__actions { +.cluster-addon-card__actions--equal { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + width: 100%; margin-top: 0.85rem; gap: 0.5rem; + align-items: stretch; +} +.cluster-addon-card__actions--equal .btn-addon-install, +.cluster-addon-card__actions--equal .btn-addon-remove { + flex: 1 1 0; + min-width: 0; + min-height: 2.65rem; + margin-top: 0; + box-sizing: border-box; + display: inline-flex; align-items: center; + justify-content: center; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; } .cluster-addon-card--wide { - grid-column: 1 / -1; + grid-column: 1; } .cluster-addon-istio-grid { margin-top: 0.25rem; } -.cluster-addons-log-card { +/* Карточка хода Helm на странице аддонов (разметка как у create-progress-wrap + job-log-panel) */ +.cluster-addons-helm-progress { margin-bottom: 1.5rem; } -.cluster-addons-log-pre { +.cluster-addons-helm-progress .job-log-panel { + max-height: min(55vh, 420px); +} +.cluster-addons-bootstrap-msg { margin: 0.5rem 0 0; - max-height: min(50vh, 22rem); - overflow: auto; - padding: 0.65rem 0.75rem; - font-size: 0.78rem; - line-height: 1.4; - white-space: pre-wrap; - word-break: break-word; - border-radius: 8px; - border: 1px solid var(--border); - background: rgba(0, 0, 0, 0.12); -} -html[data-theme="light"] .cluster-addons-log-pre { - background: rgba(15, 23, 42, 0.04); -} -.cluster-addons-busy-overlay { - z-index: 9995; -} -body.cluster-addons-busy { - overflow: hidden; + font-size: 0.88rem; + line-height: 1.45; } /* Версии чартов на странице аддонов */ @@ -2930,8 +3135,58 @@ html[data-theme="light"] .journal-details-cell { border: 1px solid var(--border); } -.journal-page-size-hint { +/* Три сегмента на странице журнала — шире блок переключателя */ +.journal-toolbar-card .cluster-edit-segmented { + max-width: 100%; + flex-wrap: wrap; +} +@media (min-width: 960px) { + .journal-toolbar-card .cluster-edit-segmented { + flex-wrap: nowrap; + max-width: 56rem; + } +} +.journal-view-fieldset { + margin-bottom: 0.25rem; +} +.journal-cluster-picker { + margin-top: 1rem; + margin-bottom: 0.5rem; +} +.journal-cluster-picker.hidden { + display: none; +} +.journal-cluster-picker-label { + display: block; font-size: 0.88rem; + font-weight: 600; + margin-bottom: 0.35rem; +} +.journal-cluster-select { + width: 100%; + max-width: 24rem; + padding: 0.45rem 0.65rem; + font-size: 0.9rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} +.journal-cluster-picker-hint { + margin: 0.45rem 0 0; + font-size: 0.84rem; + max-width: 40rem; +} +.journal-toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; + margin-top: 0.65rem; +} +.journal-cell-kind { + word-break: break-word; + max-width: 12rem; } /* Пагинация журнала */ @@ -3000,12 +3255,6 @@ html[data-theme="light"] .journal-details-cell { font-size: 0.9rem; user-select: none; } -.journal-pagination-summary { - width: 100%; - text-align: center; - margin: 0.85rem 0 0; - font-size: 0.84rem; -} @media (max-width: 640px) { .journal-pagination-inner { flex-direction: column; diff --git a/app/templates/base.html b/app/templates/base.html index 9fc8a27..fa8d675 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -57,6 +57,7 @@ {# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #} -
-

Журнал последней операции

-

+{# Журнал операции Helm: те же блоки, что на странице создания кластера (прогресс + job-log-panel). Без полноэкранного спиннера. #}
+
 
-
+{% include "partials/dashboard_modals.html" %}
 {% endblock %}
 
 {% block scripts %}
diff --git a/app/templates/cluster_create.html b/app/templates/cluster_create.html
index f2c0636..22462e3 100644
--- a/app/templates/cluster_create.html
+++ b/app/templates/cluster_create.html
@@ -119,16 +119,18 @@
 

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

- +
-
- +
+
- - - - + + + + + + diff --git a/app/templates/cluster_detail.html b/app/templates/cluster_detail.html index 3b7f32a..0747149 100644 --- a/app/templates/cluster_detail.html +++ b/app/templates/cluster_detail.html @@ -35,7 +35,7 @@

- Панель + Кластеры Кластер {{ cluster_name | e }}

@@ -78,7 +78,9 @@

Kubernetes (kubectl)

Данные по всему кластеру: ресурсы в namespace — со всех пространств имён; узлы и список - namespace — без ограничения по отдельному ns. + namespace — без ограничения по отдельному ns. В карточках ресурсов выпадающий список справа от заголовка + содержит все namespace из ответа API и из фактических объектов в таблицах; при выборе «Все namespace» строки + таблицы упорядочены по namespace, затем по имени ресурса.

@@ -94,52 +96,182 @@
-

Поды

+
+

Поды

+
+ + +
+
-

Deployments

+
+

Deployments

+
+ + +
+
-

StatefulSets

+
+

StatefulSets

+
+ + +
+
-

DaemonSets

+
+

DaemonSets

+
+ + +
+
-

ReplicaSets

+
+

ReplicaSets

+
+ + +
+
-

Jobs

+
+

Jobs

+
+ + +
+
-

CronJobs

+
+

CronJobs

+
+ + +
+
-

Сервисы

+
+

Сервисы

+
+ + +
+
-

Ingresses

+
+

Ingresses

+
+ + +
+
-

PVC

+
+

PVC

+
+ + +
+
diff --git a/app/templates/cluster_edit.html b/app/templates/cluster_edit.html index cf8bed9..da9ac6d 100644 --- a/app/templates/cluster_edit.html +++ b/app/templates/cluster_edit.html @@ -17,7 +17,7 @@ {% block content %}

- Панель + Кластеры Кластер {{ cluster_name | e }} diff --git a/app/templates/clusters.html b/app/templates/clusters.html new file mode 100644 index 0000000..becbc1a --- /dev/null +++ b/app/templates/clusters.html @@ -0,0 +1,81 @@ +{# Список кластеров и сводка ресурсов узлов (перенесено с панели). + Автор: Сергей Антропов — https://devops.org.ru #} +{% extends "base.html" %} + +{% block page_title %}Кластеры{% endblock %} + +{% block body_extra_class %} dashboard-clusters-loading{% endblock %} + +{% block body_attrs %}data-dashboard-mode="clusters"{% endblock %} + +{% block footer %} +

+{% endblock %} + +{% block content %} +
+ +
+ + Загрузка кластеров и метрик узлов… +
+
+ +
+
+
+

Кластеры

+

+ Запущенные узлы kind, kubeconfig и действия. +

+
+ +
+
+ +
+

Ресурсы узлов (сводка)

+
+
+ +
+

Список кластеров

+
+
Дата, время (UTC)КластерСтатусСообщениеЗавершено (UTC)КластерТип заданияСтатусСообщениеЛог
+ + + + + + + + + + + + +
ИмяkindkubeconfigВерсияWorkersСостояниеДействия
+
+

+
+ +{% include "partials/dashboard_modals.html" %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 6bea57e..1c58288 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,4 +1,4 @@ -{# Главная: создание (CTA), статистика, донаты, таблица кластеров. +{# Главная: CTA, статистика, карточка «Ресурсы узлов» (только донаты); список кластеров на /clusters. Автор: Сергей Антропов — https://devops.org.ru #} {% extends "base.html" %} @@ -36,7 +36,7 @@

Создать кластер

- Кластеры kind, kubeconfig и kubectl — ниже на странице. Данные обновляются автоматически. + Список кластеров, сводка по узлам и действия — на странице Кластеры. Данные там обновляются автоматически.

Перейти к форме создания
@@ -53,30 +53,15 @@ -
-

Ресурсы узлов (сводка)

-
-
- -
-

Кластеры

-
- - - - - - - - - - - - - -
ИмяkindkubeconfigВерсияWorkersСостояниеДействия
+
+
+

Ресурсы узлов (сводка)

+
-

{% include "partials/dashboard_modals.html" %} diff --git a/app/templates/journal.html b/app/templates/journal.html index 85a890b..74d3c54 100644 --- a/app/templates/journal.html +++ b/app/templates/journal.html @@ -1,4 +1,5 @@ -{# Агрегированный журнал завершённых заданий (JSON в каталогах кластеров). +{# Журналы: задания по кластеру, развёртывание (provision_log), Helm (helm_addon_log). + Переключатель режима — как «Простой / Расширенный» на странице редактирования кластера. Автор: Сергей Антропов — https://devops.org.ru #} {% extends "base.html" %} @@ -18,7 +19,6 @@ {% endblock %} {% block content %} -{# Полноэкранный спиннер до первой загрузки журнала; скрывается в journal.js #}
-

Журнал заданий

-

- Записи о завершённых операциях (create, start, stop и др.) хранятся в - clusters/<имя>/journal/jobs_history.json на томе с хоста. Сводка по всем кластерам, по 30 записей на страницу; - детали задания в процессе — по-прежнему в создании кластера и GET /api/v1/jobs/{id}. -

+

Журналы

-

Действия

-
+

Вид журнала

+ +
+ Режим просмотра +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +

+ Журнал заданий только для выбранного каталога в clusters/. +

+
+ +
- На странице по 30 записей.

-

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

- +

Записи

+
- + - + diff --git a/app/templates/partials/dashboard_modals.html b/app/templates/partials/dashboard_modals.html index 87405d5..299e755 100644 --- a/app/templates/partials/dashboard_modals.html +++ b/app/templates/partials/dashboard_modals.html @@ -54,7 +54,7 @@ -{# Журнал фонового задания (ошибка / отмена) — страница «Последние задания». #} +{# Журнал задания по API GET /jobs/{id} (модалка при необходимости из скриптов). #}
Завершено (UTC) КластерТипТип / операция Статус СообщениеЖурналЛог