Веб-интерфейс: страница /clusters, навигация и крошки для кластеров

- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
Sergey Antropoff
2026-04-04 13:42:21 +03:00
parent 17f6233fd7
commit eb063aec20
38 changed files with 3990 additions and 734 deletions
+2
View File
@@ -2,6 +2,8 @@
# Содержимое clusters/<имя>/ в репозиторий не попадает (только clusters/.gitkeep). # Содержимое clusters/<имя>/ в репозиторий не попадает (только clusters/.gitkeep).
.DS_Store .DS_Store
.env .env
# Генерируется «make podman …» — перекрывает CONTAINER_SOCKET из .env (устаревший путь в /var/folders).
.env.podman.override
# Кэш и байткод Python (не коммитить) # Кэш и байткод Python (не коммитить)
__pycache__/ __pycache__/
+75 -27
View File
@@ -10,17 +10,50 @@
# #
# Автор: Сергей Антропов — https://devops.org.ru # Автор: Сергей Антропов — 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)))) KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py
PYTHON ?= python3 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 # При «exec format error» у kind: make docker build COMPOSE_BUILD_FLAGS=--platform linux/arm64
COMPOSE_BUILD_FLAGS ?= COMPOSE_BUILD_FLAGS ?=
# Для цели kubectl: имя кластера и аргументы kubectl после --kubeconfig (по умолчанию: get nodes). # Для цели kubectl: имя кластера и аргументы kubectl после --kubeconfig (по умолчанию: get nodes).
@@ -57,20 +90,35 @@ _require_runtime:
exit 1; \ exit 1; \
fi fi
up: _require_runtime clusters-dir build ## (с docker/podman) Поднять веб-UI в фоне # Только для make podman …: перекрыть CONTAINER_SOCKET из .env (устаревший /var/folders/… ломает volume).
cd "$(KIND_K8S_DIR)" && $(COMPOSE) up -d kind-k8s-web _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 в этом каталоге up: _require_runtime _podman_env_override clusters-dir build ## (с docker/podman) Поднять веб-UI в фоне
cd "$(KIND_K8S_DIR)" && $(COMPOSE) down 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 down: _require_runtime _podman_env_override ## (с docker/podman) Остановить compose в этом каталоге
cd "$(KIND_K8S_DIR)" && $(COMPOSE) restart kind-k8s-web 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) restart: _require_runtime _podman_env_override ## (с docker/podman) Перезапустить контейнер kind-k8s-web
cd "$(KIND_K8S_DIR)" && $(COMPOSE) logs -f 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-проекта logs: _require_runtime _podman_env_override ## (с docker/podman) Логи kind-k8s-web (follow -f)
cd "$(KIND_K8S_DIR)" && $(COMPOSE) ps 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 на хосте) setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте)
@$(PYTHON) "$(SETUP_ENV_SCRIPT)" @$(PYTHON) "$(SETUP_ENV_SCRIPT)"
@@ -78,27 +126,27 @@ setup: ## Интерактивно создать .env (scripts/setup_env_intera
clusters-dir: ## Каталог clusters/ для тома (если ещё нет) clusters-dir: ## Каталог clusters/ для тома (если ещё нет)
@mkdir -p "$(KIND_K8S_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 \ @case "$(COMPOSE)" in \
docker*) command -v docker >/dev/null 2>&1 || { echo >&2 "docker не найден в PATH."; exit 1; } ;; \ 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; } ;; \ podman*) command -v podman >/dev/null 2>&1 || { echo >&2 "podman не найден в PATH."; exit 1; } ;; \
esac 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" @echo "$(COMPOSE): OK"
# kubectl в образе; путь к kubeconfig — как у веб-UI (подстановка server через host.docker.internal, см. kubeconfig_patch.py). # 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 \ @if [ -z "$(CLUSTER)" ]; then \
echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \ echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \
echo >&2 "Пример: make docker kubectl CLUSTER=dev"; \ echo >&2 "Пример: make docker kubectl CLUSTER=dev"; \
echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \ echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \
exit 1; \ exit 1; \
fi fi
cd "$(KIND_K8S_DIR)" && KC=$$($(COMPOSE) exec -T kind-k8s-web python3 scripts/effective_kubeconfig_path.py $(CLUSTER) | tr -d '\r') && \ 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) exec kind-k8s-web kubectl --kubeconfig="$$KC" $(KUBECTL_ARGS) $(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 build: _require_runtime _podman_env_override clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) 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 rebuild: _require_runtime _podman_env_override 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 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
+13 -5
View File
@@ -11,7 +11,7 @@
| **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) | | **[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**) | | **`/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` / `kubectl` / Docker или Podman API).
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление. - **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание 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 с). - **Аддоны** (`/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`** (из памяти удаляются только завершённые задания). - **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы). - **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды. - При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **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_APP_TITLE`** | контейнер / Settings | Заголовок OpenAPI и HTML; пустое значение из compose не ломает приложение (`env_ignore_empty`, fallback) |
| **`KIND_K8S_WAIT_NODES`** | контейнер | `0` — не ждать Ready нод после create | | **`KIND_K8S_WAIT_NODES`** | контейнер | `0` — не ждать Ready нод после create |
| **`KIND_K8S_WAIT_NODES_TIMEOUT_SEC`** | контейнер | Таймаут `kubectl wait` (секунды) | | **`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`) | | **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig для хоста; по умолчанию **включено** (`1` в compose и в `make setup`) |
| **`CONTAINER_CLI`** | контейнер | CLI для `docker port` / `podman port` (`docker` или `podman`) | | **`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/<uid>`: **`/tmp`** (Podman); для root — **`/root`** |
| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами | | **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами |
| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк показывать в **интерактивном CLI** при выборе версии (веб-UI выводит полный список из API) | | **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк показывать в **интерактивном CLI** при выборе версии (веб-UI выводит полный список из API) |
| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Сколько страниц Docker Hub обходить при сборе тегов (старые 1.19.x часто на поздних страницах; в коде по умолчанию **120**, максимум **500**) | | **`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 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/setup_env_interactive.py` | Интерактивное создание `.env` (все ключи и дефолты внутри скрипта) |
| `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` | | `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` |
| `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI | | `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI |
| `requirements.txt` | pip-зависимости веб-приложения | | `requirements.txt` | pip-зависимости веб-приложения |
| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет | | `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/main.py` | FastAPI: главная `/`, создание кластера `/cluster-create`, `/documentation`, редирект `/ui`, монтирование `/static` |
| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) | | `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) |
| `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути | | `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
+161 -20
View File
@@ -1,6 +1,8 @@
"""Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali. """Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali.
Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``). Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``).
После каждой операции в ``clusters/<имя>/helm_addon_log.json`` **добавляется** новая запись в массив ``entries``
(история сохраняется, лимит ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``).
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
@@ -10,7 +12,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import uuid
from collections.abc import Callable
from typing import Any
import yaml
from fastapi import APIRouter, Body, HTTPException from fastapi import APIRouter, Body, HTTPException
from core.cluster_lifecycle import validate_cluster_name from core.cluster_lifecycle import validate_cluster_name
@@ -28,6 +34,7 @@ from core.helm_addons import (
uninstall_metrics_server, uninstall_metrics_server,
) )
from core.kind_guard import kind_cluster_lock from core.kind_guard import kind_cluster_lock
from core.provision_log import write_cluster_helm_addon_log
from models.schemas import ( from models.schemas import (
ClusterAddonsStatusResponse, ClusterAddonsStatusResponse,
HelmAddonApplyResponse, HelmAddonApplyResponse,
@@ -57,6 +64,79 @@ def _map_helm_error(e: HelmAddonError) -> HTTPException:
return HTTPException(status_code=500, detail=msg) 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( @router.get(
"/clusters/{name}/addons/status", "/clusters/{name}/addons/status",
response_model=ClusterAddonsStatusResponse, response_model=ClusterAddonsStatusResponse,
@@ -102,9 +182,15 @@ async def post_install_ingress_nginx(
ver: str | None = None ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip(): if body is not None and body.chart_version and body.chart_version.strip():
ver = body.chart_version.strip() ver = body.chart_version.strip()
async with kind_cluster_lock: user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "ingress-nginx", "action": "install", "chart_version": ver}
try: try:
_, log = await asyncio.to_thread(install_ingress_nginx, n, ver) 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: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
logger.info("Установлен ingress-nginx для кластера «%s»", n) logger.info("Установлен ingress-nginx для кластера «%s»", n)
@@ -120,9 +206,14 @@ async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse:
n = name.strip() n = name.strip()
if not validate_cluster_name(n): if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock: meta = {"addon": "ingress-nginx", "action": "delete"}
try: try:
_, log = await asyncio.to_thread(uninstall_ingress_nginx, n) log = await _run_helm_with_journal(
n,
"helm_addon_delete_ingress_nginx",
meta,
lambda: uninstall_ingress_nginx(n),
)
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log)) return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log))
@@ -143,14 +234,26 @@ async def post_install_kube_prometheus_stack(
prom_ver: str | None = None prom_ver: str | None = None
if body.chart_version and body.chart_version.strip(): if body.chart_version and body.chart_version.strip():
prom_ver = body.chart_version.strip() prom_ver = body.chart_version.strip()
async with kind_cluster_lock: 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: try:
_, log = await asyncio.to_thread( log = await _run_helm_with_journal(
install_kube_prometheus_stack,
n, n,
grafana_admin_user=body.grafana_admin_user.strip(), "helm_addon_install_kube_prometheus_stack",
meta,
lambda: install_kube_prometheus_stack(
n,
grafana_admin_user=u,
grafana_admin_password=body.grafana_admin_password, grafana_admin_password=body.grafana_admin_password,
chart_version=prom_ver, chart_version=prom_ver,
user_values=user_v,
),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -167,9 +270,14 @@ async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse:
n = name.strip() n = name.strip()
if not validate_cluster_name(n): if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock: meta = {"addon": "kube-prometheus-stack", "action": "delete"}
try: try:
_, log = await asyncio.to_thread(uninstall_kube_prometheus_stack, n) log = await _run_helm_with_journal(
n,
"helm_addon_delete_kube_prometheus_stack",
meta,
lambda: uninstall_kube_prometheus_stack(n),
)
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log)) return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log))
@@ -190,9 +298,15 @@ async def post_install_metrics_server(
ms_ver: str | None = None ms_ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip(): if body is not None and body.chart_version and body.chart_version.strip():
ms_ver = body.chart_version.strip() ms_ver = body.chart_version.strip()
async with kind_cluster_lock: user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "metrics-server", "action": "install", "chart_version": ms_ver}
try: try:
_, log = await asyncio.to_thread(install_metrics_server, n, ms_ver) 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: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
logger.info("Установлен metrics-server для кластера «%s»", n) logger.info("Установлен metrics-server для кластера «%s»", n)
@@ -208,9 +322,14 @@ async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
n = name.strip() n = name.strip()
if not validate_cluster_name(n): if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock: meta = {"addon": "metrics-server", "action": "delete"}
try: try:
_, log = await asyncio.to_thread(uninstall_metrics_server, n) log = await _run_helm_with_journal(
n,
"helm_addon_delete_metrics_server",
meta,
lambda: uninstall_metrics_server(n),
)
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log)) return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log))
@@ -234,15 +353,32 @@ async def post_install_istio_kiali(
kiali_ver: str | None = None kiali_ver: str | None = None
if body.kiali_chart_version and body.kiali_chart_version.strip(): if body.kiali_chart_version and body.kiali_chart_version.strip():
kiali_ver = body.kiali_chart_version.strip() kiali_ver = body.kiali_chart_version.strip()
async with kind_cluster_lock: 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: try:
_, log = await asyncio.to_thread( log = await _run_helm_with_journal(
install_istio_and_kiali,
n, n,
kiali_username=body.kiali_username.strip(), "helm_addon_install_istio_kiali",
meta,
lambda: install_istio_and_kiali(
n,
kiali_username=ku,
kiali_password=body.kiali_password, kiali_password=body.kiali_password,
istio_chart_version=istio_ver, istio_chart_version=istio_ver,
kiali_chart_version=kiali_ver, kiali_chart_version=kiali_ver,
istio_base_values=istio_base_v,
istiod_values=istiod_v,
kiali_user_values=kiali_uv,
),
) )
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
@@ -259,9 +395,14 @@ async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse:
n = name.strip() n = name.strip()
if not validate_cluster_name(n): if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock: meta = {"addon": "istio-kiali", "action": "delete"}
try: try:
_, log = await asyncio.to_thread(uninstall_istio_and_kiali, n) log = await _run_helm_with_journal(
n,
"helm_addon_delete_istio_kiali",
meta,
lambda: uninstall_istio_and_kiali(n),
)
except HelmAddonError as e: except HelmAddonError as e:
raise _map_helm_error(e) from e raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log)) return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log))
+2
View File
@@ -52,6 +52,7 @@ from core.container_resource_stats import (
collect_single_cluster_resource_stats, collect_single_cluster_resource_stats,
running_kind_clusters_mask, running_kind_clusters_mask,
) )
from core.helm_addons import HELM_INSTALLABLE_ADDON_IDS
from core.job_store import ( from core.job_store import (
JobRecord, JobRecord,
end_job_tracking, end_job_tracking,
@@ -198,6 +199,7 @@ def _stats_sync() -> StatsResponse:
total_workers_from_meta=total_workers, total_workers_from_meta=total_workers,
jobs_total=len(jobs), jobs_total=len(jobs),
jobs_recent_failed=failed, jobs_recent_failed=failed,
helm_addons_installable_count=len(HELM_INSTALLABLE_ADDON_IDS),
cluster_resources=res_blocks, cluster_resources=res_blocks,
cluster_resources_error=res_err, cluster_resources_error=res_err,
aggregate_cluster_resources=agg, aggregate_cluster_resources=agg,
+116 -3
View File
@@ -1,4 +1,4 @@
"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``). """Справочник версий Helm-чартов и сборка YAML values для редактора на странице аддонов.
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
@@ -11,14 +11,76 @@ import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from core.helm_addons import HelmAddonError, helm_addon_chart_versions_all, require_helm_binary from core.helm_addons import (
from models.schemas import HelmAddonChartVersionsResponse 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") logger = logging.getLogger("kind_k8s.api.helm_charts")
router = APIRouter(tags=["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( @router.get(
"/helm/chart-versions", "/helm/chart-versions",
response_model=HelmAddonChartVersionsResponse, response_model=HelmAddonChartVersionsResponse,
@@ -42,3 +104,54 @@ async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse:
logger.exception("Ошибка получения версий чартов") logger.exception("Ошибка получения версий чартов")
raise HTTPException(status_code=500, detail=str(e)) from e raise HTTPException(status_code=500, detail=str(e)) from e
return HelmAddonChartVersionsResponse(**data) 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
+85 -4
View File
@@ -1,4 +1,4 @@
"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``. """Чтение журналов: задания (jobs_history), развёртывание (provision_log), Helm-аддоны (helm_addon_log).
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
@@ -13,7 +13,18 @@ from fastapi import APIRouter, HTTPException, Query
from core.cluster_lifecycle import validate_cluster_name from core.cluster_lifecycle import validate_cluster_name
from core.job_journal import collect_recent_journal_entries_page_sync, read_cluster_journal_sync 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") 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 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( @router.get(
"/journal/recent", "/journal/recent",
response_model=JournalRecentResponse, response_model=JournalRecentResponse,
summary="Страница журнала по всем кластерам (пагинация)", summary="Страница журнала заданий (jobs_history), опционально один кластер",
) )
async def get_journal_recent( async def get_journal_recent(
limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"), limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"),
offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"), offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"),
cluster: str | None = Query(
default=None,
description="Имя кластера: только записи из clusters/<имя>/journal/jobs_history.json",
),
) -> JournalRecentResponse: ) -> 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( rows, total = await asyncio.to_thread(
collect_recent_journal_entries_page_sync, collect_recent_journal_entries_page_sync,
limit=limit, limit=limit,
offset=offset, offset=offset,
cluster_name=c,
) )
entries = [JournalEntryModel.model_validate(r) for r in rows] entries = [JournalEntryModel.model_validate(r) for r in rows]
total_pages = _journal_total_pages(total, limit) 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( @router.get(
"/clusters/{name}/journal", "/clusters/{name}/journal",
response_model=ClusterJournalResponse, response_model=ClusterJournalResponse,
+10 -8
View File
@@ -22,7 +22,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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 kindest_node_tags import normalize_tag_v_prefix
from kubeconfig_patch import ( from kubeconfig_patch import (
kubeconfig_host_file, kubeconfig_host_file,
@@ -504,13 +504,15 @@ def create_cluster_non_interactive(
if not use_existing_config and (workers < 0 or workers > 20): if not use_existing_config and (workers < 0 or workers > 20):
raise KindClusterError("Количество worker-нод должно быть от 0 до 20.") raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag) # Каталог данных кластера до долгих шагов — чтобы при сбое журнал в journal/jobs_history.json
node_image = f"kindest/node:{ver_tag}" # и provision_log могли быть привязаны к существующему clusters/<имя>/.
root = data_root() root = data_root()
cdir = clusters_dir() cdir = clusters_dir()
out_dir = cdir / name out_dir = cdir / name
out_dir.mkdir(parents=True, exist_ok=True) 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" cfg_path = out_dir / "kind-config.yaml"
kube_path = out_dir / "kubeconfig" kube_path = out_dir / "kubeconfig"
meta_path = out_dir / "meta.json" 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: def _container_cli_bin() -> str:
"""Имя CLI к сокету (docker / podman), как в kubeconfig_patch.""" """Имя CLI к сокету: см. :func:`kind_k8s_paths.container_cli_name`."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" return container_cli_name()
def configured_container_cli() -> str: 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() return _container_cli_bin()
+2 -2
View File
@@ -9,12 +9,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import re import re
import shutil import shutil
import subprocess import subprocess
from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters 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 from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat
logger = logging.getLogger("kind_k8s.container_resource_stats") 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: 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]: def _list_running_container_names(cli: str) -> set[str]:
+223 -22
View File
@@ -37,6 +37,15 @@ HELM_REPOS: dict[str, str] = {
"kiali": "https://kiali.org/helm-charts", "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 <ref> --versions`` (ключи совпадают с полями API версий чартов). # Ссылки для ``helm search repo <ref> --versions`` (ключи совпадают с полями API версий чартов).
HELM_ADDON_CHART_REFS: dict[str, str] = { HELM_ADDON_CHART_REFS: dict[str, str] = {
"ingress_nginx": "ingress-nginx/ingress-nginx", "ingress_nginx": "ingress-nginx/ingress-nginx",
@@ -59,6 +68,121 @@ class HelmAddonError(Exception):
self.exit_code = exit_code 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 <ref> [--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: def _helm_timeout_sec() -> int:
raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip() raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip()
try: try:
@@ -283,6 +407,7 @@ def _helm_upgrade_install(
chart_version: str | None = None, chart_version: str | None = None,
extra_args: list[str] | None = None, extra_args: list[str] | None = None,
values_file: Path | None = None, values_file: Path | None = None,
extra_values_files: list[Path] | None = None,
) -> tuple[int, str, str]: ) -> tuple[int, str, str]:
kcfg = str(_kubeconfig_runtime_path(cluster_name)) kcfg = str(_kubeconfig_runtime_path(cluster_name))
cmd = [ cmd = [
@@ -305,6 +430,8 @@ def _helm_upgrade_install(
cmd.extend(["--version", ver]) cmd.extend(["--version", ver])
if values_file is not None: if values_file is not None:
cmd.extend(["-f", str(values_file)]) cmd.extend(["-f", str(values_file)])
for p in extra_values_files or []:
cmd.extend(["-f", str(p)])
if extra_args: if extra_args:
cmd.extend(extra_args) cmd.extend(extra_args)
return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60) return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60)
@@ -321,15 +448,29 @@ 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]: def install_ingress_nginx(
"""ingress-nginx с NodePort для kind.""" 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() helm_repo_ensure()
eff = user_values if user_values is not None else compose_ingress_effective_values(chart_version)
extra: list[str] = [ extra: list[str] = [
"--set", "--set",
"controller.service.type=NodePort", "controller.service.type=NodePort",
"--set", "--set",
"controller.service.nodePorts.http=30080", "controller.service.nodePorts.http=30080",
] ]
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( rc, out, err = _helm_upgrade_install(
cluster_name, cluster_name,
"ingress-nginx", "ingress-nginx",
@@ -337,7 +478,11 @@ def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple
"ingress-nginx", "ingress-nginx",
chart_version=chart_version, chart_version=chart_version,
extra_args=extra, 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() text = "\n".join(filter(None, [out, err])).strip()
if rc != 0: if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc) 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_user: str,
grafana_admin_password: str, grafana_admin_password: str,
chart_version: str | None = None, chart_version: str | None = None,
user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Полный values из UI или автосборка (``helm show values`` + Grafana из формы)."""
helm_repo_ensure() helm_repo_ensure()
values = { if user_values is not None:
"grafana": { values = user_values
"adminUser": grafana_admin_user, else:
"adminPassword": grafana_admin_password, values = compose_kube_prometheus_effective_values(
} chart_version,
} grafana_admin_user,
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp: grafana_admin_password,
yaml.safe_dump(values, tmp, allow_unicode=True) )
tmp_path = Path(tmp.name) fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="helm-prom-stack-")
os.close(fd)
tmp_path = Path(tmp_name)
try: try:
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
rc, out, err = _helm_upgrade_install( rc, out, err = _helm_upgrade_install(
cluster_name, cluster_name,
"kube-prometheus-stack", "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 удалён (или отсутствовал)." 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() helm_repo_ensure()
values = { if user_values is not None:
"args": [ values = user_values
"--kubelet-insecure-tls", else:
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP", values = compose_metrics_server_effective_values(chart_version)
] fd_m, tmp_ms = tempfile.mkstemp(suffix=".yaml", prefix="helm-metrics-")
} os.close(fd_m)
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp: tmp_path = Path(tmp_ms)
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
try: try:
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
rc, out, err = _helm_upgrade_install( rc, out, err = _helm_upgrade_install(
cluster_name, cluster_name,
"metrics-server", "metrics-server",
@@ -437,8 +591,16 @@ def install_istio_and_kiali(
kiali_password: str, kiali_password: str,
istio_chart_version: str | None = None, istio_chart_version: str | None = None,
kiali_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]: ) -> 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() helm_repo_ensure()
_ensure_namespace(cluster_name, "istio-system") _ensure_namespace(cluster_name, "istio-system")
@@ -446,27 +608,53 @@ def install_istio_and_kiali(
istio_ver = (istio_chart_version or "").strip() or None istio_ver = (istio_chart_version or "").strip() or None
kiali_ver = (kiali_chart_version or "").strip() or None kiali_ver = (kiali_chart_version or "").strip() or None
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( rc, out, err = _helm_upgrade_install(
cluster_name, cluster_name,
"istio-base", "istio-base",
"istio/base", "istio/base",
"istio-system", "istio-system",
chart_version=istio_ver, chart_version=istio_ver,
values_file=base_file,
) )
log_parts.append(out or err) log_parts.append(out or err)
if rc != 0: if rc != 0:
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc) 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)
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( rc2, o2, e2 = _helm_upgrade_install(
cluster_name, cluster_name,
"istiod", "istiod",
"istio/istiod", "istio/istiod",
"istio-system", "istio-system",
chart_version=istio_ver, chart_version=istio_ver,
values_file=istiod_file,
) )
log_parts.append(o2 or e2) log_parts.append(o2 or e2)
if rc2 != 0: if rc2 != 0:
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2) 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). # Секрет для стратегии login (ключи username / passphrase — ожидание Kiali).
secret = { secret = {
@@ -484,6 +672,15 @@ def install_istio_and_kiali(
raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs) raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs)
log_parts.append(msgs) log_parts.append(msgs)
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( rc3, o3, e3 = _helm_upgrade_install(
cluster_name, cluster_name,
"kiali-server", "kiali-server",
@@ -491,7 +688,11 @@ def install_istio_and_kiali(
"istio-system", "istio-system",
chart_version=kiali_ver, chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"], 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) log_parts.append(o3 or e3)
if rc3 != 0: if rc3 != 0:
raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3) raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3)
+45 -8
View File
@@ -82,8 +82,17 @@ def append_job_from_record_sync(
return return
cdir = clusters_dir() / name cdir = clusters_dir() / name
# Страница «Журнал» читает clusters/<имя>/journal/jobs_history.json — нужен каталог кластера.
# После отмены create_cluster каталог мог быть удалён (rmtree) — не создаём пустой каталог заново.
if not cdir.is_dir(): if not cdir.is_dir():
logger.debug("Журнал кластера: каталог %s отсутствует", cdir) 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 return
jdir = cdir / JOURNAL_SUBDIR jdir = cdir / JOURNAL_SUBDIR
@@ -185,21 +194,49 @@ def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]:
return flat 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)) lim = max(1, min(limit, 100))
off = max(0, offset) 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() flat = _collect_all_journal_rows_sorted_sync()
total = len(flat) total = len(flat)
chunk = flat[off : off + lim] chunk = flat[off : off + lim]
out: list[dict[str, Any]] = [] out2: list[dict[str, Any]] = []
for cluster_dir_name, e in chunk: for cluster_dir_name, e in chunk:
row = dict(e) row = dict(e)
row["source_cluster"] = cluster_dir_name row["source_cluster"] = cluster_dir_name
out.append(row) out2.append(row)
return out, total return out2, total
+189
View File
@@ -1,5 +1,8 @@
"""Сохранение полного журнала развёртывания кластера в ``clusters/<имя>/provision_log.json``. """Сохранение полного журнала развёртывания кластера в ``clusters/<имя>/provision_log.json``.
Журнал операций Helm-аддонов — ``helm_addon_log.json``: **история** операций (массив ``entries``),
старый формат «один объект в файле» при чтении мигрируется автоматически.
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
""" """
@@ -8,10 +11,13 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from core.cluster_lifecycle import validate_cluster_name
from kind_k8s_paths import clusters_dir from kind_k8s_paths import clusters_dir
logger = logging.getLogger("kind_k8s.provision_log") 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_FILENAME = "provision_log.json"
PROVISION_LOG_VERSION = 1 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: def provision_log_file_path(cluster_name: str) -> Path:
"""Путь к JSON с журналом операции для кластера ``cluster_name``.""" """Путь к JSON с журналом операции для кластера ``cluster_name``."""
return clusters_dir() / cluster_name.strip() / PROVISION_LOG_FILENAME 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( def write_cluster_provision_log(
*, *,
cluster_name: str, cluster_name: str,
@@ -72,3 +108,156 @@ def write_cluster_provision_log(
except OSError: except OSError:
pass pass
return None 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
+2 -2
View File
@@ -31,7 +31,7 @@ from core.cluster_lifecycle import (
validate_cluster_name, validate_cluster_name,
) )
from kindest_node_tags import fetch_kindest_node_tags, normalize_tag_v_prefix 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-подсказок; # Имя кластера: поддомен DNS (RFC 1123) — дублируем только для CLI-подсказок;
# основная проверка в core.cluster_lifecycle. # основная проверка в core.cluster_lifecycle.
@@ -42,7 +42,7 @@ def _which(cmd: str) -> str | None:
def _container_cli_bin() -> str: def _container_cli_bin() -> str:
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" return container_cli_name()
def _in_container() -> bool: def _in_container() -> bool:
+95 -17
View File
@@ -19,16 +19,17 @@
| Маршрут | Описание | | Маршрут | Описание |
|---------|----------| |---------|----------|
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | | `GET /` | HTML **Панель**: CTA создания кластера, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | | `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. |
| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение**`PUT /api/v1/clusters/{name}/config`**. | | `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные**`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). |
| `GET /cluster-addons` | HTML **Аддоны**: выбор кластера, **`GET /api/v1/helm/chart-versions`** для выпадающих списков версий чартов (или «Последняя»), установка/удаление Helm-релизов. | | `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. В шапке активна **Кластеры**. |
| `GET /journal` | HTML **Журнал**: **`GET /api/v1/journal/recent`** с пагинацией (**30** записей на страницу), навигация по страницам внизу таблицы. | | `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 /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 /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/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**. **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/` | | 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/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` | | 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 | | 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` | Удалить стек | | 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 | | 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 | | 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`** | | POST | `/api/v1/helm/addons/compose-values` | Собрать YAML для **одного** аддона (тело: `addon`, версии, поля Grafana при необходимости) |
| GET | `/api/v1/clusters/{name}/journal` | Полный файл журнала кластера (массив `entries` или пусто, если файла нет) | | 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` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) | | GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
| DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) | | DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
| POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) | | 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) ### Фоновые задания (jobs)
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется. - Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**. - Снимок заданий сохраняется в 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). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В ответе задания поля **`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**). - В **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, "total_workers_from_meta": 2,
"jobs_total": 3, "jobs_total": 3,
"jobs_recent_failed": 0, "jobs_recent_failed": 0,
"helm_addons_installable_count": 4,
"cluster_resources": [ "cluster_resources": [
{ {
"cluster_name": "dev", "cluster_name": "dev",
@@ -250,6 +312,7 @@ Accept: text/markdown
- `total_workers_from_meta` — целое **≥ 0**; **0**, если ни в одном `meta.json` нет поля `worker_nodes` или оно не число. - `total_workers_from_meta` — целое **≥ 0**; **0**, если ни в одном `meta.json` нет поля `worker_nodes` или оно не число.
- `jobs_total` — число заданий в текущей памяти процесса (не более 200). - `jobs_total` — число заданий в текущей памяти процесса (не более 200).
- `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку). - `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` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение.
- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым, а `aggregate_cluster_resources` — нулевая сводка. - `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым, а `aggregate_cluster_resources` — нулевая сводка.
- `aggregate_cluster_resources` — агрегаты по **запущенным** узлам для донат-диаграмм на главной: средние проценты CPU/RAM, средняя доля RAM из строки `memory_usage`, кольца сети/диска по суммарному I/O (шкала 0–100 относительно порога 8 GiB на полное кольцо). - `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 ## GET /api/v1/journal/recent
Объединение записей из всех **`clusters/*/journal/jobs_history.json`**, сортировка по **`finished_at_utc`** (новые первыми). В каждой записи добавляется **`source_cluster`** — имя каталога кластера. Записи из **`journal/jobs_history.json`**. Без параметра **`cluster`** — объединение по всем кластерам, сортировка по времени (новые первыми). С **`cluster=<имя>`** — только файл выбранного каталога (порядок как в файле, новые сверху). В каждой записи есть **`source_cluster`**.
**Query:** **Query:**
| Параметр | Описание | | Параметр | Описание |
|----------|----------| |----------|----------|
| `limit` | Записей на страницу, **1100**, по умолчанию **30** (как в веб-интерфейсе **`/journal`**) | | `limit` | Записей на страницу, **1100**, по умолчанию **30** |
| `offset` | Смещение от начала списка, **≥ 0**, по умолчанию **0** (первая страница: `offset=0`, вторая при `limit=30`: `offset=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 записей):** **Пример ответа 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 ## GET /api/v1/clusters/{name}/journal
Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**. Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**.
+19
View File
@@ -32,3 +32,22 @@ def data_root() -> Path:
def clusters_dir() -> Path: def clusters_dir() -> Path:
"""``<data_root>/clusters``.""" """``<data_root>/clusters``."""
return data_root() / "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"
+4 -2
View File
@@ -32,6 +32,8 @@ import tempfile
import threading import threading
from pathlib import Path from pathlib import Path
from kind_k8s_paths import container_cli_name
logger = logging.getLogger("kind_k8s.kubeconfig_patch") logger = logging.getLogger("kind_k8s.kubeconfig_patch")
# Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения). # Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения).
@@ -39,8 +41,8 @@ KUBECONFIG_HOST_BASENAME = "kubeconfig.host"
def _container_cli() -> str: def _container_cli() -> str:
"""CLI для `port` (обычно docker к сокету Docker или Podman).""" """CLI для ``port`` к смонтированному сокету (в контейнере веб-UI — ``docker``)."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" return container_cli_name()
def _control_plane_container_name(cluster_name: str) -> str: def _control_plane_container_name(cluster_name: str) -> str:
+18 -3
View File
@@ -60,9 +60,24 @@ if _static_dir.is_dir():
templates = Jinja2Templates(directory=str(_templates_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="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
status_code=500,
)
return templates.TemplateResponse(
request,
"clusters.html",
{"app_title": settings.app_title, "nav_active": "clusters"},
)
@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс") @app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс")
async def dashboard(request: Request) -> HTMLResponse: async def dashboard(request: Request) -> HTMLResponse:
"""Главная страница: обзор, сводка по узлам, таблица кластеров.""" """Главная панель: CTA создания, статистика среды (без таблицы кластеров — см. /clusters)."""
if not _templates_dir.is_dir(): if not _templates_dir.is_dir():
return HTMLResponse( return HTMLResponse(
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>", content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
@@ -92,7 +107,7 @@ async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLRespon
"cluster_detail.html", "cluster_detail.html",
{ {
"app_title": settings.app_title, "app_title": settings.app_title,
"nav_active": "panel", "nav_active": "clusters",
"cluster_name": n, "cluster_name": n,
}, },
) )
@@ -114,7 +129,7 @@ async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse
"cluster_edit.html", "cluster_edit.html",
{ {
"app_title": settings.app_title, "app_title": settings.app_title,
"nav_active": "panel", "nav_active": "clusters",
"cluster_name": n, "cluster_name": n,
}, },
) )
+134 -1
View File
@@ -117,6 +117,11 @@ class StatsResponse(BaseModel):
) )
jobs_total: int jobs_total: int
jobs_recent_failed: int jobs_recent_failed: int
helm_addons_installable_count: int = Field(
0,
ge=0,
description="Число типовых Helm-аддонов в каталоге UI (страница «Аддоны»), доступных для установки",
)
cluster_resources: list[KindClusterResources] = Field( cluster_resources: list[KindClusterResources] = Field(
default_factory=list, default_factory=list,
description="CPU/RAM/I/O узлов kind по данным container CLI", description="CPU/RAM/I/O узлов kind по данным container CLI",
@@ -311,6 +316,9 @@ class HelmAddonApplyResponse(BaseModel):
log: str = Field(default="", description="Сводный вывод helm/kubectl") log: str = Field(default="", description="Сводный вывод helm/kubectl")
_VALUES_YAML_MAX = 131_072
class IngressNginxInstallRequest(BaseModel): class IngressNginxInstallRequest(BaseModel):
"""Тело POST …/addons/ingress-nginx — установка ingress-nginx.""" """Тело POST …/addons/ingress-nginx — установка ingress-nginx."""
@@ -319,6 +327,15 @@ class IngressNginxInstallRequest(BaseModel):
max_length=64, max_length=64,
description="Версия чарта; пусто — последняя из репозитория", 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): class KubePrometheusStackInstallRequest(BaseModel):
@@ -331,6 +348,14 @@ class KubePrometheusStackInstallRequest(BaseModel):
max_length=64, max_length=64,
description="Версия чарта kube-prometheus-stack; пусто — последняя", 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): class MetricsServerInstallRequest(BaseModel):
@@ -341,6 +366,14 @@ class MetricsServerInstallRequest(BaseModel):
max_length=64, max_length=64,
description="Версия чарта metrics-server; пусто — последняя", 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): class IstioKialiInstallRequest(BaseModel):
@@ -358,6 +391,75 @@ class IstioKialiInstallRequest(BaseModel):
max_length=64, max_length=64,
description="Версия чарта kiali/kiali-server; пусто — последняя", 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): class HelmAddonChartVersionsResponse(BaseModel):
@@ -400,7 +502,7 @@ class ClusterJournalResponse(BaseModel):
class JournalRecentResponse(BaseModel): class JournalRecentResponse(BaseModel):
"""Страница агрегированного журнала (все ``clusters/*/journal/jobs_history.json``).""" """Страница журнала заданий из ``journal/jobs_history.json`` (все кластеры или один)."""
limit: int = Field(description="Размер страницы (записей за запрос)") limit: int = Field(description="Размер страницы (записей за запрос)")
offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)") offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)")
@@ -408,3 +510,34 @@ class JournalRecentResponse(BaseModel):
page: int = Field(ge=1, description="Номер текущей страницы с 1") page: int = Field(ge=1, description="Номер текущей страницы с 1")
total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет") total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет")
entries: list[JournalEntryModel] = Field(default_factory=list) 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)
+493 -45
View File
@@ -12,6 +12,115 @@
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); 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<boolean>}
*/
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 {string} path
* @param {RequestInit} [opts] * @param {RequestInit} [opts]
@@ -42,22 +151,80 @@
const sel = document.getElementById("addons-cluster-select"); const sel = document.getElementById("addons-cluster-select");
const statusBar = document.getElementById("addons-status-bar"); const statusBar = document.getElementById("addons-status-bar");
const logPre = document.getElementById("addons-log-pre"); const addonsCardsWrap = document.getElementById("addons-cards-wrap");
const overlay = document.getElementById("addons-busy-overlay"); const helmWrap = document.getElementById("addons-helm-progress-wrap");
const busyLabel = document.getElementById("addons-busy-label"); 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() { function getClusterName() {
if (!sel || !sel.value) return ""; if (!sel || !sel.value) return "";
return String(sel.value).trim(); return String(sel.value).trim();
} }
function setBusy(show, label) { /** Карточки аддонов — только при выбранном кластере в селекте. */
if (!overlay) return; function updateAddonsCardsVisibility() {
overlay.classList.toggle("hidden", !show); if (!addonsCardsWrap) return;
overlay.setAttribute("aria-busy", show ? "true" : "false"); var n = getClusterName();
overlay.setAttribute("aria-hidden", show ? "false" : "true"); addonsCardsWrap.classList.toggle("hidden", !n);
document.body.classList.toggle("cluster-addons-busy", !!show); addonsCardsWrap.setAttribute("aria-hidden", n ? "false" : "true");
if (busyLabel && label) busyLabel.textContent = label; }
/**
* Предупреждения загрузки списка кластеров / версий чартов (не путать с журналом 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"); document.body.classList.remove("cluster-addons-page-loading");
} }
function appendLog(line) { /**
if (!logPre) return; * Текстовое сообщение в полосе статуса (нет кластера / ошибка).
logPre.textContent = (logPre.textContent ? logPre.textContent + "\n" : "") + line; * @param {string} text
logPre.scrollTop = logPre.scrollHeight; */
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) { /** Максимальная длина поля values_yaml на сервере (см. Pydantic max_length). */
if (!st) return ""; var VALUES_YAML_MAX_LEN = 131072;
const parts = [
"ingress-nginx: " + (st.ingress_nginx ? "да" : "нет"), /**
"prometheus-stack: " + (st.kube_prometheus_stack ? "да" : "нет"), * Полоса под селектором кластера: только сообщения (нет кластера / ошибка), без сетки статусов.
"metrics-server: " + (st.metrics_server ? "да" : "нет"), */
"Istio+Kiali: " + (st.istio_mesh_ready ? "да" : "нет"), 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<string, unknown>} 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" },
]; ];
return parts.join(" · "); 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<typeof setTimeout> | 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() { async function loadChartVersions() {
try { try {
const d = await api("/helm/chart-versions"); const d = await api("/helm/chart-versions");
setBootstrapMsg("");
fillVersionSelect(document.getElementById("addons-ing-version-select"), d.ingress_nginx); 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-prom-version-select"), d.kube_prometheus_stack);
fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server); fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server);
fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio); fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio);
fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server); fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server);
await refreshAllComposeValuesFromServer();
} catch (e) { } catch (e) {
appendLog("Список версий чартов недоступен: " + e.message); setBootstrapMsg("Список версий чартов недоступен: " + e.message);
} }
} }
@@ -134,6 +459,7 @@
if (!sel) return; if (!sel) return;
try { try {
const rows = await api("/clusters"); const rows = await api("/clusters");
setBootstrapMsg("");
sel.innerHTML = ""; sel.innerHTML = "";
const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; }); const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; });
if (!withKube.length) { if (!withKube.length) {
@@ -159,23 +485,37 @@
o.value = ""; o.value = "";
o.textContent = "Ошибка загрузки списка"; o.textContent = "Ошибка загрузки списка";
sel.appendChild(o); sel.appendChild(o);
appendLog("Ошибка GET /clusters: " + e.message); setBootstrapMsg("Ошибка GET /clusters: " + e.message);
} }
} }
async function refreshStatus() { async function refreshStatus() {
const n = getClusterName(); const n = getClusterName();
if (!statusBar) return; if (!statusBar) {
updateAddonsCardsVisibility();
return;
}
if (!n) { if (!n) {
statusBar.textContent = "Выберите кластер для просмотра статуса аддонов."; lastAddonsStatus = null;
clearAddonToolbarStatusBar();
updateAddonCardBadges(null);
updateAddonActionButtons();
updateAddonsCardsVisibility();
return; return;
} }
try { try {
const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status"); const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status");
statusBar.textContent = formatStatus(st); lastAddonsStatus = st;
clearAddonToolbarStatusBar();
updateAddonCardBadges(st);
updateAddonActionButtons();
} catch (e) { } catch (e) {
statusBar.textContent = "Статус недоступен: " + e.message; lastAddonsStatus = null;
renderAddonStatusMessage("Статус недоступен: " + e.message);
updateAddonCardBadges(null);
updateAddonActionButtons();
} }
updateAddonsCardsVisibility();
} }
async function runInstall(endpoint, payload) { async function runInstall(endpoint, payload) {
@@ -184,22 +524,63 @@
alert("Выберите кластер."); alert("Выберите кластер.");
return; return;
} }
setBusy(true, "Установка (Helm), подождите…"); const isUpdate = addonEndpointInstalled(endpoint);
if (logPre) logPre.textContent = ""; const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
appendLog("POST …/addons/" + endpoint + " для «" + n + "»"); 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 { try {
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" }, headers: { "Content-Type": "application/json", Accept: "application/json" },
body: payload !== undefined ? JSON.stringify(payload) : "{}", body: payload !== undefined ? JSON.stringify(payload) : "{}",
}); });
appendLog(r.message || "OK"); var okLines = [
if (r.log) appendLog(r.log); "Кластер: «" + 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(); await refreshStatus();
} catch (e) { } 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 { } finally {
setBusy(false); setAddonFormsDisabled(false);
} }
} }
@@ -209,22 +590,66 @@
alert("Выберите кластер."); alert("Выберите кластер.");
return; return;
} }
if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return; const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
setBusy(true, "Удаление релиза, подождите…"); const ok = await openConfirmModal({
if (logPre) logPre.textContent = ""; title: "Удалить аддон?",
appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»"); 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 { try {
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, { const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
method: "DELETE", method: "DELETE",
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}); });
appendLog(r.message || "OK"); var okDel = [
if (r.log) appendLog(r.log); "Кластер: «" + 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(); await refreshStatus();
} catch (e) { } 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 { } finally {
setBusy(false); setAddonFormsDisabled(false);
} }
} }
@@ -234,7 +659,10 @@
if (ep === "ingress-nginx") { if (ep === "ingress-nginx") {
const sel = document.getElementById("addons-ing-version-select"); const sel = document.getElementById("addons-ing-version-select");
const v = sel && sel.value.trim(); 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; return;
} }
if (ep === "kube-prometheus-stack") { if (ep === "kube-prometheus-stack") {
@@ -249,6 +677,7 @@
} }
const payload = { grafana_admin_user: user, grafana_admin_password: pw }; const payload = { grafana_admin_user: user, grafana_admin_password: pw };
if (pv && pv.value.trim()) payload.chart_version = pv.value.trim(); if (pv && pv.value.trim()) payload.chart_version = pv.value.trim();
if (!attachAddonYamlToPayload(ep, payload)) return;
runInstall(ep, payload); runInstall(ep, payload);
return; return;
} }
@@ -256,7 +685,8 @@
const ms = document.getElementById("addons-ms-version-select"); const ms = document.getElementById("addons-ms-version-select");
const payload = {}; const payload = {};
if (ms && ms.value.trim()) payload.chart_version = ms.value.trim(); 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; return;
} }
if (ep === "istio-kiali") { if (ep === "istio-kiali") {
@@ -273,6 +703,7 @@
const payload = { kiali_username: user, kiali_password: pw }; const payload = { kiali_username: user, kiali_password: pw };
if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim(); 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 (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim();
if (!attachAddonYamlToPayload(ep, payload)) return;
runInstall(ep, payload); runInstall(ep, payload);
} }
}); });
@@ -292,6 +723,23 @@
sel.addEventListener("change", function () { refreshStatus(); }); 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() loadClusters()
.then(function () { .then(function () {
return refreshStatus(); return refreshStatus();
+382 -193
View File
@@ -32,9 +32,6 @@
var cachedClusterResourcesByName = {}; var cachedClusterResourcesByName = {};
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */ /** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
var cachedHealthPayload = null; var cachedHealthPayload = null;
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
var cachedLocalClusterDirsCount = null;
/** /**
* Кэш GET /versions для проверки тега перед созданием кластера. * Кэш GET /versions для проверки тега перед созданием кластера.
* null загрузка ещё не завершена; [] каталог пуст или отключён; иначе список тегов. * null загрузка ещё не завершена; [] каталог пуст или отключён; иначе список тегов.
@@ -51,6 +48,11 @@
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home"; return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
} }
/** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */
function isClustersListPage() {
return document.body.getAttribute("data-dashboard-mode") === "clusters";
}
function isCreatePage() { function isCreatePage() {
return document.body.getAttribute("data-dashboard-mode") === "create"; return document.body.getAttribute("data-dashboard-mode") === "create";
} }
@@ -84,6 +86,16 @@
document.body.classList.remove("dashboard-home-loading"); 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). */ /** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
function showClusterLifecycleJobOverlay(labelText) { function showClusterLifecycleJobOverlay(labelText) {
const el = document.getElementById("cluster-lifecycle-job-overlay"); const el = document.getElementById("cluster-lifecycle-job-overlay");
@@ -149,32 +161,6 @@
return d.innerHTML; 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). * Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
* @param {string} clusterName * @param {string} clusterName
@@ -459,6 +445,7 @@
const failureWrap = document.getElementById("stats-failure-wrap"); const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env"); const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid"); const metricsGrid = document.getElementById("stats-grid");
const resCard = document.getElementById("home-resources-summary-card");
if (failureWrap) { if (failureWrap) {
failureWrap.classList.remove("hidden"); failureWrap.classList.remove("hidden");
failureWrap.replaceChildren(); failureWrap.replaceChildren();
@@ -480,6 +467,7 @@
} }
if (env) env.classList.add("hidden"); if (env) env.classList.add("hidden");
if (metricsGrid) metricsGrid.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 failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env"); const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid"); const metricsGrid = document.getElementById("stats-grid");
const resCard = document.getElementById("home-resources-summary-card");
if (failureWrap) { if (failureWrap) {
failureWrap.classList.add("hidden"); failureWrap.classList.add("hidden");
failureWrap.replaceChildren(); failureWrap.replaceChildren();
} }
if (env) env.classList.remove("hidden"); if (env) env.classList.remove("hidden");
if (metricsGrid) metricsGrid.classList.remove("hidden"); if (metricsGrid) metricsGrid.classList.remove("hidden");
} if (resCard) resCard.classList.remove("hidden");
/** Карточка-метрика в ряду среды (без зелёного/красного статуса), напр. «Каталогов». */
function buildStatsEnvMetricCard(label, valueText) {
const tile = document.createElement("div");
tile.className = "stats-tile stats-env-card stats-env-card--metric";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = valueText;
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = label;
tile.appendChild(valEl);
tile.appendChild(labEl);
return tile;
} }
/** /**
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats). * Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»).
*/ */
function paintStatsEnvRow() { function paintStatsEnvRow() {
const el = document.getElementById("stats-env"); const el = document.getElementById("stats-env");
@@ -539,9 +514,6 @@
el.appendChild( el.appendChild(
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null), buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
); );
const dirVal =
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
} }
async function loadHealth() { async function loadHealth() {
@@ -659,7 +631,7 @@
} }
/** /**
* SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``). * SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»).
*/ */
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) { function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
renderAggregateDonutsToRoot( renderAggregateDonutsToRoot(
@@ -1016,6 +988,163 @@
renderRows(root, items); 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-<key>.
* @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) { function k8sNodeReady(item) {
const cond = (item.status && item.status.conditions) || []; const cond = (item.status && item.status.conditions) || [];
for (var i = 0; i < cond.length; i++) { for (var i = 0; i < cond.length; i++) {
@@ -1244,13 +1373,70 @@
clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows); clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows);
} }
/**
* Фабрика функций рендера по ключу фильтра (для подов нужен clusterName).
* @param {string} cname
* @returns {Record<string, function(HTMLElement, object[]): void>}
*/
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. * Таблицы Kubernetes из полей ``k8s_*`` ответа overview.
* @param {object} d * @param {object} d
*/ */
function fillClusterK8sTablesFromOverview(d) { function fillClusterK8sTablesFromOverview(d) {
lastClusterK8sOverview = d;
const kubeErr = d.kubeconfig_error || null; const kubeErr = d.kubeconfig_error || null;
const cname = d.cluster_name || getClusterPageName() || ""; const cname = d.cluster_name || getClusterPageName() || "";
ensureClusterNsFilterListeners();
clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable); clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
clusterK8sRenderBlock( clusterK8sRenderBlock(
d.k8s_namespaces, d.k8s_namespaces,
@@ -1258,53 +1444,20 @@
document.getElementById("cluster-k8s-namespaces-root"), document.getElementById("cluster-k8s-namespaces-root"),
renderK8sNamespacesTable, 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, /* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */
kubeErr, clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d));
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);
} }
/** /**
@@ -1506,19 +1659,28 @@
async function loadStats() { async function loadStats() {
const grid = document.getElementById("stats-grid"); 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 { try {
const s = await api("/stats"); const s = await api("/stats");
if (needGrid) {
revealStatsCardBody(); revealStatsCardBody();
cachedLocalClusterDirsCount = s.local_cluster_dirs_count;
paintStatsEnvRow(); paintStatsEnvRow();
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker"; const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
updateStatsEngineHint(cliForHint); updateStatsEngineHint(cliForHint);
const dirVal =
s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined
? String(s.local_cluster_dirs_count)
: "—";
const rows = [ const rows = [
["Каталогов", dirVal],
["Кластеров в kind", s.kind_clusters_count], ["Кластеров в kind", s.kind_clusters_count],
["Сумма workers (meta)", s.total_workers_from_meta ?? 0], ["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
["Заданий в памяти", s.jobs_total], ["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed], ["Заданий с ошибкой", s.jobs_recent_failed],
["Аддонов", s.helm_addons_installable_count ?? 0],
]; ];
grid.replaceChildren(); grid.replaceChildren();
rows.forEach(function (kv) { rows.forEach(function (kv) {
@@ -1534,15 +1696,20 @@
tile.appendChild(labEl); tile.appendChild(labEl);
grid.appendChild(tile); grid.appendChild(tile);
}); });
}
cachedClusterResourcesByName = {}; cachedClusterResourcesByName = {};
if (needDonuts) {
renderAggregateDonuts( renderAggregateDonuts(
s.aggregate_cluster_resources, s.aggregate_cluster_resources,
s.cluster_resources_error, s.cluster_resources_error,
s.container_cli || "docker", s.container_cli || "docker",
); );
}
} catch (e) { } catch (e) {
if (needGrid) {
coverStatsCardWithError("Статистика", e.message || String(e)); coverStatsCardWithError("Статистика", e.message || String(e));
grid.replaceChildren(); grid.replaceChildren();
}
cachedClusterResourcesByName = {}; cachedClusterResourcesByName = {};
const donuts = document.getElementById("aggregate-donuts"); const donuts = document.getElementById("aggregate-donuts");
if (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() { async function loadClusters() {
const tbody = document.querySelector("#tbl-clusters tbody"); const tbody = document.querySelector("#tbl-clusters tbody");
const msg = document.getElementById("list-msg"); 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() { function formatJournalUtcDisplay(iso) {
const ok = await openConfirmModal({ if (!iso) return "—";
title: "Очистить завершённые задания?",
message:
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
confirmLabel: "Очистить",
danger: false,
});
if (!ok) return;
const msg = document.getElementById("jobs-msg");
try { try {
const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } }); const d = new Date(iso);
const text = await r.text(); if (Number.isNaN(d.getTime())) return iso;
var data = {}; return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
try {
data = text ? JSON.parse(text) : {};
} catch (e) { } catch (e) {
data = {}; return iso;
}
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();
} catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message;
showToast(e.message, true);
} }
} }
/** /**
* Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка). * Класс плашки статуса записи журнала (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"); const tbl = document.getElementById("tbl-jobs");
if (!tbl || tbl.getAttribute("data-jobs-job-log-bound") === "1") return; if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return;
tbl.setAttribute("data-jobs-job-log-bound", "1"); tbl.setAttribute("data-create-jobs-log-bound", "1");
tbl.addEventListener( tbl.addEventListener(
"click", "click",
function (ev) { function (ev) {
const btn = ev.target && ev.target.closest ? ev.target.closest("button.jobs-open-log-btn") : null; const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null;
if (!btn || !tbl.contains(btn)) return; if (!btn || !tbl.contains(btn) || btn.disabled) return;
const jobId = btn.getAttribute("data-job-id"); const tr = btn.closest("tr.journal-data-row");
if (!jobId) return; if (!tr) return;
ev.preventDefault(); const trDetails = tr.nextElementSibling;
openJobLogModal(jobId); 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, false,
); );
} }
/**
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» задания
* (GET /journal/recent без фильтра кластера объединение, новые первые; колонка «Кластер» = каталог на томе).
*/
async function loadJobs() { async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody"); const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg"); const msg = document.getElementById("jobs-msg");
if (!tbody) return; if (!tbody) return;
try { 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(); const frag = document.createDocumentFragment();
rows.forEach(function (j) { entries.forEach(function (e) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const st = escapeHtml(j.status || ""); tr.className = "journal-data-row";
var kindTag = ""; const cluster = e.source_cluster || e.cluster_name || "";
if (j.kind === "start_cluster") kindTag = "[старт] "; const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc);
else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] "; const kind = e.kind || "";
else if (j.kind === "stop_containers") kindTag = "[стоп] "; const message = (e.message || "").trim();
else if (j.kind === "start_containers") kindTag = "[запуск узлов] "; const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
var cellMsg = kindTag + (j.message || "").slice(0, 140); const logText = lines.join("\n");
if ((j.status === "running" || j.status === "queued") && j.progress_stage) { const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
cellMsg =
kindTag +
j.progress_stage +
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
var msgCol =
"<div class=\"jobs-msg-primary\">" + escapeHtml(cellMsg) + "</div>";
if (j.status === "failed" || j.status === "cancelled") {
msgCol +=
"<button type=\"button\" class=\"btn-secondary jobs-open-log-btn\" data-job-id=\"" +
escapeHtml(j.job_id || "") +
"\">Журнал</button>";
}
const iso = j.created_at_utc || "";
const timeLabel = formatJobTimeUtc(iso);
tr.innerHTML = tr.innerHTML =
"<td><time datetime=\"" + "<td class=\"journal-cell-time\">" +
escapeHtml(iso) + escapeHtml(finished) +
"\" title=\"" +
escapeHtml(iso ? "UTC: " + iso : "") +
"\">" +
escapeHtml(timeLabel) +
"</time></td>" +
"<td>" +
escapeHtml(j.cluster_name || "—") +
"</td>" + "</td>" +
"<td><code class=\"journal-code\">" +
escapeHtml(cluster) +
"</code></td>" +
"<td><code class=\"journal-code journal-cell-kind\">" +
escapeHtml(kind) +
"</code></td>" +
"<td><span class=\"" + "<td><span class=\"" +
jobBadgeClass(j.status) + escapeHtml(journalStatusPillClass(e.status)) +
"\">" + "\">" +
st + escapeHtml(e.status || "") +
"</span></td>" + "</span></td>" +
"<td class=\"jobs-msg-cell\">" + "<td class=\"journal-cell-msg\">" +
msgCol + escapeHtml(message || "—") +
"</td>" +
"<td class=\"journal-cell-actions\">" +
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
escapeHtml(detailsId) +
"\">Показать лог</button>" +
"</td>"; "</td>";
frag.appendChild(tr); frag.appendChild(tr);
if (logText) {
const trDetails = document.createElement("tr");
trDetails.className = "journal-details-row hidden";
trDetails.innerHTML =
"<td colspan=\"6\" class=\"journal-details-cell\">" +
"<pre id=\"" +
escapeHtml(detailsId) +
"\" class=\"mono journal-log-pre\" hidden></pre>" +
"</td>";
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); tbody.replaceChildren(frag);
if (msg) { 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) { } catch (e) {
if (msg) msg.textContent = "Задания: " + e.message; if (msg) msg.textContent = "Журнал заданий: " + e.message;
} }
} }
@@ -2298,8 +2478,10 @@
async function refreshLists() { async function refreshLists() {
try { try {
await loadHealth(); await loadHealth();
if (isHomePage()) { if (isHomePage() || isClustersListPage()) {
await loadStats(); await loadStats();
}
if (isClustersListPage()) {
await loadClusters(); await loadClusters();
} }
if (isCreatePage()) { if (isCreatePage()) {
@@ -2390,15 +2572,15 @@
}); });
} }
const btnClearJobs = document.getElementById("btn-jobs-clear"); const btnJobsRefresh = document.getElementById("btn-jobs-refresh");
if (btnClearJobs) { if (btnJobsRefresh) {
btnClearJobs.addEventListener("click", function () { btnJobsRefresh.addEventListener("click", function () {
clearCompletedJobs(); loadJobs();
}); });
} }
if (document.getElementById("tbl-jobs")) { if (document.getElementById("tbl-jobs")) {
bindJobsTableJobLogOpen(); bindCreateJobsJournalToggle();
} }
const mClose = document.getElementById("modal-close"); const mClose = document.getElementById("modal-close");
@@ -2486,6 +2668,9 @@
await loadHealth(); await loadHealth();
if (isHomePage()) { if (isHomePage()) {
await loadStats(); await loadStats();
}
if (isClustersListPage()) {
await loadStats();
await loadClusters(); await loadClusters();
} }
if (isCreatePage()) { if (isCreatePage()) {
@@ -2502,8 +2687,12 @@
if (isHomePage() && lm) { if (isHomePage() && lm) {
lm.textContent = "Ошибка загрузки панели: " + errText; lm.textContent = "Ошибка загрузки панели: " + errText;
} }
if (isClustersListPage() && lm) {
lm.textContent = "Ошибка загрузки: " + errText;
}
} finally { } finally {
hideHomePageLoadingOverlay(); hideHomePageLoadingOverlay();
hideClustersPageLoadingOverlay();
} }
})(); })();
if (isCreatePage()) { if (isCreatePage()) {
+202 -108
View File
@@ -1,5 +1,5 @@
/** /**
* Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу). * Страница «Журнал»: режимы per_cluster / provision / helm_addons, пагинация по 30.
* *
* Автор: Сергей Антропов * Автор: Сергей Антропов
* Сайт: https://devops.org.ru * Сайт: https://devops.org.ru
@@ -11,15 +11,14 @@
if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return; if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Размер страницы (синхронно с default limit в API). */
var PAGE_SIZE = 30; var PAGE_SIZE = 30;
/** Текущая страница с 1 (состояние для пагинации). */ /** @type {'per_cluster' | 'provision' | 'helm_addons'} */
var viewMode = "per_cluster";
var currentPage = 1; var currentPage = 1;
/** Выбранный кластер в режиме per_cluster */
var selectedCluster = "";
/**
* @param {string} path
*/
async function apiGet(path) { async function apiGet(path) {
const url = path.startsWith("http") ? path : API + path; const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, { headers: { Accept: "application/json" } }); const r = await fetch(url, { headers: { Accept: "application/json" } });
@@ -50,8 +49,11 @@
const tableWrap = document.getElementById("journal-table-wrap"); const tableWrap = document.getElementById("journal-table-wrap");
const refreshBtn = document.getElementById("journal-refresh-btn"); const refreshBtn = document.getElementById("journal-refresh-btn");
const pagNav = document.getElementById("journal-pagination"); 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() { function hideJournalPageLoadingOverlay() {
const el = document.getElementById("journal-page-loading-overlay"); const el = document.getElementById("journal-page-loading-overlay");
if (!el) return; if (!el) return;
@@ -86,12 +88,6 @@
return "journal-status-pill"; return "journal-status-pill";
} }
/**
* Список номеров страниц и многоточий для компактной навигации.
* @param {number} current
* @param {number} total
* @returns {(number|string)[]}
*/
function visiblePageItems(current, total) { function visiblePageItems(current, total) {
const delta = 2; const delta = 2;
const range = []; const range = [];
@@ -115,9 +111,6 @@
return out; return out;
} }
/**
* @param {number} itemCount число строк на текущей странице (для подписи «записи NM»)
*/
function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) { function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) {
if (!pagNav) return; if (!pagNav) return;
pagNav.innerHTML = ""; pagNav.innerHTML = "";
@@ -141,20 +134,11 @@
return b; 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( wrap.appendChild(
mkBtn("« Первая", page <= 1, "Первая страница", function () { mkBtn("« Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }),
onSelectPage(1);
}),
); );
wrap.appendChild( wrap.appendChild(
mkBtn(" Назад", page <= 1, "Предыдущая страница", function () { mkBtn(" Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
onSelectPage(page - 1);
}),
); );
const pagesEl = document.createElement("div"); const pagesEl = document.createElement("div");
@@ -180,9 +164,7 @@
if (pNum === page) b.setAttribute("aria-current", "page"); if (pNum === page) b.setAttribute("aria-current", "page");
else b.removeAttribute("aria-current"); else b.removeAttribute("aria-current");
if (pNum !== page) { if (pNum !== page) {
b.addEventListener("click", function () { b.addEventListener("click", function () { onSelectPage(pNum); });
onSelectPage(pNum);
});
} }
pagesEl.appendChild(b); pagesEl.appendChild(b);
}); });
@@ -190,27 +172,13 @@
wrap.appendChild(pagesEl); wrap.appendChild(pagesEl);
wrap.appendChild( wrap.appendChild(
mkBtn("Вперёд ", page >= totalPages, "Следующая страница", function () { mkBtn("Вперёд ", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
onSelectPage(page + 1);
}),
); );
wrap.appendChild( wrap.appendChild(
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
onSelectPage(totalPages);
}),
); );
pagNav.appendChild(wrap); 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) { function goToPage(p) {
@@ -220,79 +188,33 @@
} }
/** /**
* @param {boolean} resetPage сбросить на 1 (после «Обновить») * Построить строки таблицы: jobs (log_lines) или dir-лог (lines).
*/ */
async function loadJournal(resetPage) { function renderTableRows(entries, mode) {
if (!tbody || !statusEl) { if (!tbody) return;
hideJournalPageLoadingOverlay();
return;
}
if (resetPage) currentPage = 1;
const offset = (currentPage - 1) * PAGE_SIZE;
statusEl.textContent = "Загрузка…";
tbody.innerHTML = ""; tbody.innerHTML = "";
if (emptyEl) emptyEl.classList.add("hidden");
if (tableWrap) tableWrap.classList.remove("hidden");
if (pagNav) pagNav.classList.add("hidden");
try {
const data = await apiGet(
"/journal/recent?limit=" +
encodeURIComponent(String(PAGE_SIZE)) +
"&offset=" +
encodeURIComponent(String(offset)),
);
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 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) {
currentPage = totalPages;
await loadJournal(false);
return;
}
if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) {
currentPage = totalPages;
await loadJournal(false);
return;
}
page = currentPage;
if (!entries.length) {
if (emptyEl) emptyEl.classList.remove("hidden");
if (tableWrap) tableWrap.classList.add("hidden");
statusEl.textContent =
total === 0
? "Нет записей в журналах."
: "Страница пуста (всего записей: " + total + ").";
renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage);
return;
}
const fromN = respOffset + 1;
const toN = respOffset + entries.length;
statusEl.textContent =
"Записи " + fromN + "" + toN + " из " + total + " · страница " + page + " из " + (totalPages || 1) + ".";
entries.forEach(function (e) { entries.forEach(function (e) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "journal-data-row"; tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—"; const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatUtc(e.finished_at_utc || e.created_at_utc); const finished = formatUtc(
e.finished_at_utc || e.created_at_utc,
);
const kind = e.kind || "";
const msg = (e.message || "").trim(); const msg = (e.message || "").trim();
const lines = Array.isArray(e.log_lines) ? e.log_lines : []; 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 logText = lines.join("\n");
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11); const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML = tr.innerHTML =
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" + "<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" + "<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
"<td><code class=\"journal-code\">" + esc(e.kind || "") + "</code></td>" + "<td><code class=\"journal-code journal-cell-kind\">" + esc(kind) + "</code></td>" +
"<td><span class=\"" + esc(statusClass(e.status)) + "\">" + esc(e.status || "") + "</span></td>" + "<td><span class=\"" + esc(statusClass(e.status)) + "\">" + esc(e.status || "") + "</span></td>" +
"<td class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" + "<td class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" +
"<td class=\"journal-cell-actions\">" + "<td class=\"journal-cell-actions\">" +
@@ -329,11 +251,120 @@
} }
} }
}); });
}
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;
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");
if (tableWrap) tableWrap.classList.remove("hidden");
if (pagNav) pagNav.classList.add("hidden");
try {
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 : (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 && totalPages > 0) {
currentPage = totalPages;
await loadJournal(false);
return;
}
if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) {
currentPage = totalPages;
await loadJournal(false);
return;
}
page = currentPage;
if (!entries.length) {
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 + ").";
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) + ".";
renderTableRows(entries, viewMode);
renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage); renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage);
} catch (err) { } catch (err) {
statusEl.textContent = "Ошибка: " + err.message; 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 (tableWrap) tableWrap.classList.add("hidden");
if (pagNav) pagNav.classList.add("hidden"); if (pagNav) pagNav.classList.add("hidden");
} finally { } 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 = "";
}
}
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); 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);
})();
})(); })();
+49
View File
@@ -44,10 +44,59 @@
setTheme(getTheme() === "dark" ? "light" : "dark"); 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() { function init() {
var btn = document.getElementById("theme-toggle"); var btn = document.getElementById("theme-toggle");
if (btn) btn.addEventListener("click", toggleTheme); if (btn) btn.addEventListener("click", toggleTheme);
syncToggleButton(); syncToggleButton();
syncNavPillActive();
} }
/* defer: скрипт в <head> выполняется после разбора документа — кнопка уже есть. */ /* defer: скрипт в <head> выполняется после разбора документа — кнопка уже есть. */
+296 -47
View File
@@ -440,6 +440,16 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext)
font-size: 1.05rem; 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) */ /* Пояснение: узлы kind и метрики идут через Docker или Podman (CONTAINER_CLI) */
.stats-engine-hint { .stats-engine-hint {
margin: 0; 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; padding: 0.85rem 0.65rem;
} }
/* Среда: Docker API · Kind · Kubectl · Каталогов — один ряд, 4 колонки */ /* Среда: Docker API · Kind · Kubectl (3 в ряд); число каталогов — в сетке метрик под ними */
.stats-env-cards { .stats-env-cards {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.65rem; gap: 0.65rem;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -513,10 +523,10 @@ html[data-theme="light"] .stats-env-card--metric {
background: rgba(59, 130, 246, 0.07); background: rgba(59, 130, 246, 0.07);
} }
/* Метрики: по 4 карточки в ряд (4 метрики — один ряд) */ /* Метрики панели: по 3 карточки в ряд */
.stats-metrics-grid { .stats-metrics-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.65rem; gap: 0.65rem;
flex: 1 1 auto; flex: 1 1 auto;
align-content: start; align-content: start;
@@ -524,7 +534,7 @@ html[data-theme="light"] .stats-env-card--metric {
@media (max-width: 520px) { @media (max-width: 520px) {
.stats-metrics-grid { .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); 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 { .create-progress-wrap {
margin-top: 1rem; margin-top: 1rem;
@@ -2170,6 +2220,31 @@ html[data-theme="light"] .cluster-kube-hint.cluster-kube-hint--info {
margin: 0 0 0.55rem; margin: 0 0 0.55rem;
font-size: 1.02rem; 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 карточек в ряд */ /* Сетка карточек узлов на странице кластера: не более 3 карточек в ряд */
.cluster-page-node-cards--wide.cluster-nodes-resources__cards-row { .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; flex: 1 1 14rem;
max-width: 28rem; max-width: 28rem;
} }
.cluster-addons-hint {
margin: 0.35rem 0 0;
font-size: 0.82rem;
}
.cluster-addons-status-bar { .cluster-addons-status-bar {
margin-top: 0.65rem; margin-top: 0.85rem;
font-size: 0.85rem; }
.cluster-addons-status-bar--message .cluster-addons-status-msg {
margin: 0;
font-size: 0.88rem;
line-height: 1.45; 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 { .cluster-addons-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem; gap: 1rem;
margin: 1rem 0; margin: 1rem 0;
} }
@media (min-width: 52rem) { /* Шапка карточки аддона: заголовок + бейдж «установлен» справа */
.cluster-addons-grid { .cluster-addon-card__header {
grid-template-columns: repeat(2, minmax(0, 1fr)); display: flex;
} flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
margin-bottom: 0.35rem;
} }
.cluster-addon-card__title { .cluster-addon-card__header .cluster-addon-card__title {
margin: 0 0 0.35rem; margin: 0;
flex: 1 1 auto;
min-width: 0;
font-size: 1.05rem; 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 { .cluster-addon-card__desc {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.45; 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; margin-top: 0.85rem;
gap: 0.5rem; 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; align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
} }
.cluster-addon-card--wide { .cluster-addon-card--wide {
grid-column: 1 / -1; grid-column: 1;
} }
.cluster-addon-istio-grid { .cluster-addon-istio-grid {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.cluster-addons-log-card { /* Карточка хода Helm на странице аддонов (разметка как у create-progress-wrap + job-log-panel) */
.cluster-addons-helm-progress {
margin-bottom: 1.5rem; 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; margin: 0.5rem 0 0;
max-height: min(50vh, 22rem); font-size: 0.88rem;
overflow: auto; line-height: 1.45;
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;
} }
/* Версии чартов на странице аддонов */ /* Версии чартов на странице аддонов */
@@ -2930,8 +3135,58 @@ html[data-theme="light"] .journal-details-cell {
border: 1px solid var(--border); 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-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; font-size: 0.9rem;
user-select: none; user-select: none;
} }
.journal-pagination-summary {
width: 100%;
text-align: center;
margin: 0.85rem 0 0;
font-size: 0.84rem;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.journal-pagination-inner { .journal-pagination-inner {
flex-direction: column; flex-direction: column;
+1
View File
@@ -57,6 +57,7 @@
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #} {# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
<div class="nav-links-scroll"> <div class="nav-links-scroll">
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a> <a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
<a href="/clusters" class="nav-link nav-pill{% if nav_active|default('') == 'clusters' %} nav-pill--active{% endif %}">Кластеры</a>
<a href="/cluster-addons" class="nav-link nav-pill{% if nav_active|default('') == 'cluster_addons' %} nav-pill--active{% endif %}">Аддоны</a> <a href="/cluster-addons" class="nav-link nav-pill{% if nav_active|default('') == 'cluster_addons' %} nav-pill--active{% endif %}">Аддоны</a>
<a href="/journal" class="nav-link nav-pill{% if nav_active|default('') == 'journal' %} nav-pill--active{% endif %}">Журнал</a> <a href="/journal" class="nav-link nav-pill{% if nav_active|default('') == 'journal' %} nav-pill--active{% endif %}">Журнал</a>
<a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a> <a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
+142 -37
View File
@@ -38,46 +38,66 @@
<h1 class="page-title" id="addons-hero-title">Дополнения кластера (Helm)</h1> <h1 class="page-title" id="addons-hero-title">Дополнения кластера (Helm)</h1>
<p class="muted page-lead"> <p class="muted page-lead">
Выберите кластер с сохранённым kubeconfig. Установка и удаление выполняются через <code>helm</code> в контейнере веб-приложения; Выберите кластер с сохранённым kubeconfig. Установка и удаление выполняются через <code>helm</code> в контейнере веб-приложения;
операции могут занимать несколько минут — дождитесь ответа сервера. Для kind у ingress-nginx задан NodePort <strong>30080</strong>. операции могут занимать несколько минут — ход и вывод команд отображаются в журнале ниже (как при развёртывании кластера). История операций сохраняется в каталоге кластера.
</p> </p>
</section> </section>
<section class="card create-cluster-card cluster-addons-toolbar-card"> <section class="card create-cluster-card cluster-addons-toolbar-card">
<h2 class="create-cluster-card__title">Кластер</h2> <h2 class="create-cluster-card__title">Кластер</h2>
<div class="cluster-addons-cluster-row"> <div class="cluster-addons-cluster-row">
<label for="addons-cluster-select" class="cluster-addons-cluster-label">Активный кластер</label> <label for="addons-cluster-select" class="cluster-addons-cluster-label">Выберите кластер для просмотра статуса аддонов.</label>
<select id="addons-cluster-select" class="cluster-addons-cluster-select" aria-describedby="addons-cluster-hint"> <select id="addons-cluster-select" class="cluster-addons-cluster-select" aria-label="Выберите кластер для просмотра статуса аддонов">
<option value="">— загрузка списка —</option> <option value="">— загрузка списка —</option>
</select> </select>
<button type="button" class="btn-secondary" id="addons-refresh-status">Обновить статус</button> <button type="button" class="btn-secondary" id="addons-refresh-status">Обновить статус</button>
</div> </div>
<p id="addons-cluster-hint" class="muted cluster-addons-hint"> <div id="addons-status-bar" class="cluster-addons-status-bar" role="status" aria-live="polite"></div>
Нужен файл <code>clusters/&lt;имя&gt;/kubeconfig</code> и доступ API из контейнера. <p id="addons-bootstrap-msg" class="cluster-addons-bootstrap-msg muted hidden" role="status" aria-live="polite"></p>
</p>
<p id="addons-versions-hint" class="muted cluster-addons-hint">
Список версий чартов подгружается с сервера (<code>GET /api/v1/helm/chart-versions</code>); значение «Последняя» не передаёт <code>--version</code> в Helm.
</p>
<div id="addons-status-bar" class="cluster-addons-status-bar muted" role="status" aria-live="polite"></div>
</section> </section>
<div
id="addons-cards-wrap"
class="cluster-addons-cards-wrap hidden"
aria-hidden="true"
>
<div class="cluster-addons-grid"> <div class="cluster-addons-grid">
<section class="card cluster-addon-card" data-addon="ingress-nginx"> <section class="card cluster-addon-card" data-addon="ingress-nginx">
<header class="cluster-addon-card__header">
<h2 class="cluster-addon-card__title">ingress-nginx</h2> <h2 class="cluster-addon-card__title">ingress-nginx</h2>
<p class="muted cluster-addon-card__desc">Контроллер Ingress; для kind: NodePort HTTP на порту 30080.</p> <span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
</header>
<p class="muted cluster-addon-card__desc">Контроллер Ingress.</p>
<div class="create-form-field"> <div class="create-form-field">
<label for="addons-ing-version-select">Версия чарта</label> <label for="addons-ing-version-select">Версия чарта</label>
<select id="addons-ing-version-select" class="cluster-addons-version-select" aria-describedby="addons-versions-hint"> <select id="addons-ing-version-select" class="cluster-addons-version-select" aria-label="Версия чарта ingress-nginx">
<option value="">Последняя (из репозитория)</option> <option value="">Последняя (из репозитория)</option>
</select> </select>
</div> </div>
<div class="cluster-addon-card__actions row"> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
Содержимое из <code>helm show values</code> плюс сервис для kind; подставляется при смене версии чарта. После <code>-f</code> Helm применяет <code>--set</code> для NodePort.
</p>
<label for="addons-values-yaml-ingress-nginx" class="sr-only">Полные YAML values для ingress-nginx</label>
<textarea
id="addons-values-yaml-ingress-nginx"
class="cluster-addon-card__values-textarea mono"
rows="12"
spellcheck="false"
aria-label="Полные YAML values чарта ingress-nginx"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
<button type="button" class="btn-addon-install" data-endpoint="ingress-nginx">Установить</button> <button type="button" class="btn-addon-install" data-endpoint="ingress-nginx">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="ingress-nginx">Удалить</button> <button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="ingress-nginx" hidden>Удалить</button>
</div> </div>
</section> </section>
<section class="card cluster-addon-card" data-addon="kube-prometheus-stack"> <section class="card cluster-addon-card" data-addon="kube-prometheus-stack">
<header class="cluster-addon-card__header">
<h2 class="cluster-addon-card__title">kube-prometheus-stack</h2> <h2 class="cluster-addon-card__title">kube-prometheus-stack</h2>
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
</header>
<p class="muted cluster-addon-card__desc">Prometheus Operator, Alertmanager, Grafana.</p> <p class="muted cluster-addon-card__desc">Prometheus Operator, Alertmanager, Grafana.</p>
<div class="create-form-field"> <div class="create-form-field">
<label for="addons-grafana-user">Логин администратора Grafana</label> <label for="addons-grafana-user">Логин администратора Grafana</label>
@@ -93,14 +113,31 @@
<option value="">Последняя (из репозитория)</option> <option value="">Последняя (из репозитория)</option>
</select> </select>
</div> </div>
<div class="cluster-addon-card__actions row"> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
Вывод <code>helm show values</code> с подставленными логином и паролем Grafana из полей выше; обновляется при смене версии или учётных данных. Пустое поле при установке — сервер соберёт values сам.
</p>
<label for="addons-values-yaml-kube-prometheus-stack" class="sr-only">Полные YAML values для kube-prometheus-stack</label>
<textarea
id="addons-values-yaml-kube-prometheus-stack"
class="cluster-addon-card__values-textarea mono"
rows="12"
spellcheck="false"
aria-label="Полные YAML values чарта kube-prometheus-stack"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
<button type="button" class="btn-addon-install" data-endpoint="kube-prometheus-stack">Установить</button> <button type="button" class="btn-addon-install" data-endpoint="kube-prometheus-stack">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="kube-prometheus-stack">Удалить</button> <button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="kube-prometheus-stack" hidden>Удалить</button>
</div> </div>
</section> </section>
<section class="card cluster-addon-card" data-addon="metrics-server"> <section class="card cluster-addon-card" data-addon="metrics-server">
<header class="cluster-addon-card__header">
<h2 class="cluster-addon-card__title">metrics-server</h2> <h2 class="cluster-addon-card__title">metrics-server</h2>
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
</header>
<p class="muted cluster-addon-card__desc">Метрики узлов и подов для <code>kubectl top</code>; для kind включён <code>--kubelet-insecure-tls</code>.</p> <p class="muted cluster-addon-card__desc">Метрики узлов и подов для <code>kubectl top</code>; для kind включён <code>--kubelet-insecure-tls</code>.</p>
<div class="create-form-field"> <div class="create-form-field">
<label for="addons-ms-version-select">Версия чарта</label> <label for="addons-ms-version-select">Версия чарта</label>
@@ -108,14 +145,31 @@
<option value="">Последняя (из репозитория)</option> <option value="">Последняя (из репозитория)</option>
</select> </select>
</div> </div>
<div class="cluster-addon-card__actions row"> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
<code>helm show values</code> плюс аргументы для kind (<code>--kubelet-insecure-tls</code> и т.д.). Пустое поле — автосборка на сервере.
</p>
<label for="addons-values-yaml-metrics-server" class="sr-only">Полные YAML values для metrics-server</label>
<textarea
id="addons-values-yaml-metrics-server"
class="cluster-addon-card__values-textarea mono"
rows="12"
spellcheck="false"
aria-label="Полные YAML values чарта metrics-server"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
<button type="button" class="btn-addon-install" data-endpoint="metrics-server">Установить</button> <button type="button" class="btn-addon-install" data-endpoint="metrics-server">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="metrics-server">Удалить</button> <button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="metrics-server" hidden>Удалить</button>
</div> </div>
</section> </section>
<section class="card cluster-addon-card cluster-addon-card--wide" data-addon="istio-kiali"> <section class="card cluster-addon-card cluster-addon-card--wide" data-addon="istio-kiali">
<header class="cluster-addon-card__header">
<h2 class="cluster-addon-card__title">Istio + Kiali</h2> <h2 class="cluster-addon-card__title">Istio + Kiali</h2>
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
</header>
<p class="muted cluster-addon-card__desc"> <p class="muted cluster-addon-card__desc">
Чарты <code>istio/base</code>, <code>istio/istiod</code> и <code>kiali-server</code> в <code>istio-system</code>. Вход в Kiali — стратегия Чарты <code>istio/base</code>, <code>istio/istiod</code> и <code>kiali-server</code> в <code>istio-system</code>. Вход в Kiali — стратегия
<strong>login</strong> (секрет с логином и паролем). <strong>login</strong> (секрет с логином и паролем).
@@ -142,32 +196,83 @@
</select> </select>
</div> </div>
</div> </div>
<div class="cluster-addon-card__actions row"> <details class="cluster-addon-card__values-details">
<summary class="cluster-addon-card__values-summary">Values чартов Istio и Kiali (YAML)</summary>
<p class="muted cluster-addon-card__values-hint">
Три блока из <code>helm show values</code> для выбранных версий. Логин и пароль Kiali вносятся в секрет Kubernetes отдельно; для kiali-server дополнительно задаётся <code>auth.strategy=login</code> через <code>--set</code>.
</p>
<p class="cluster-addon-card__values-subhead">kiali-server</p>
<label for="addons-values-yaml-istio-kiali" class="sr-only">YAML values kiali-server</label>
<textarea
id="addons-values-yaml-istio-kiali"
class="cluster-addon-card__values-textarea mono"
rows="10"
spellcheck="false"
aria-label="Полные YAML values чарта kiali-server"
></textarea>
<p class="cluster-addon-card__values-subhead">istio/base</p>
<label for="addons-values-yaml-istio-base" class="sr-only">YAML values istio-base</label>
<textarea
id="addons-values-yaml-istio-base"
class="cluster-addon-card__values-textarea mono"
rows="10"
spellcheck="false"
aria-label="Полные YAML values чарта istio/base"
></textarea>
<p class="cluster-addon-card__values-subhead">istio/istiod</p>
<label for="addons-values-yaml-istio-istiod" class="sr-only">YAML values istiod</label>
<textarea
id="addons-values-yaml-istio-istiod"
class="cluster-addon-card__values-textarea mono"
rows="10"
spellcheck="false"
aria-label="Полные YAML values чарта istio/istiod"
></textarea>
</details>
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
<button type="button" class="btn-addon-install" data-endpoint="istio-kiali">Установить</button> <button type="button" class="btn-addon-install" data-endpoint="istio-kiali">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="istio-kiali">Удалить</button> <button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="istio-kiali" hidden>Удалить</button>
</div> </div>
</section> </section>
</div> </div>
</div>
<section class="card cluster-addons-log-card"> {# Журнал операции Helm: те же блоки, что на странице создания кластера (прогресс + job-log-panel). Без полноэкранного спиннера. #}
<h2 class="create-cluster-card__title">Журнал последней операции</h2> <section
<pre id="addons-log-pre" class="mono cluster-addons-log-pre" role="log" aria-live="polite"></pre> id="addons-helm-progress-wrap"
class="card create-progress-wrap cluster-addons-helm-progress hidden"
aria-hidden="true"
aria-labelledby="addons-helm-progress-title"
>
<h2 class="create-cluster-card__title" id="addons-helm-progress-title">Ход операции Helm</h2>
<p class="create-progress-hint muted" id="addons-helm-progress-hint">
Выполняется команда в контейнере веб-приложения; полный вывод появится по завершении.
</p>
<div
class="progress-track"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-labelledby="addons-helm-progress-label"
id="addons-helm-progress-track"
>
<div class="progress-bar" id="addons-helm-progress-bar" style="width: 0%"></div>
</div>
<p class="progress-label" id="addons-helm-progress-label"></p>
<div class="job-log-panel-wrap">
<label for="addons-helm-log-panel" class="job-log-label muted">Журнал установки / удаления</label>
<pre
id="addons-helm-log-panel"
class="mono job-log-panel"
role="log"
aria-live="polite"
aria-relevant="additions"
></pre>
</div>
</section> </section>
<div {% include "partials/dashboard_modals.html" %}
id="addons-busy-overlay"
class="page-loading-overlay cluster-addons-busy-overlay hidden"
role="status"
aria-live="polite"
aria-busy="false"
aria-hidden="true"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span id="addons-busy-label" class="page-loading-label">Выполняется операция Helm…</span>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+9 -7
View File
@@ -119,16 +119,18 @@
<section class="card jobs-card"> <section class="card jobs-card">
<div class="row spread jobs-card-toolbar"> <div class="row spread jobs-card-toolbar">
<h2 id="jobs-heading" class="jobs-card-title">Последние задания</h2> <h2 id="jobs-heading" class="jobs-card-title">Последние задания</h2>
<button type="button" id="btn-jobs-clear" class="btn-secondary">Очистить завершённые</button> <button type="button" id="btn-jobs-refresh" class="btn-secondary">Обновить</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap journal-table-wrap">
<table id="tbl-jobs" aria-label="Последние задания создания"> <table id="tbl-jobs" class="journal-table" aria-label="Последние задания из журналов кластеров">
<thead> <thead>
<tr> <tr>
<th>Дата, время <span class="th-hint">(UTC)</span></th> <th scope="col">Завершено (UTC)</th>
<th>Кластер</th> <th scope="col">Кластер</th>
<th>Статус</th> <th scope="col">Тип задания</th>
<th>Сообщение</th> <th scope="col">Статус</th>
<th scope="col">Сообщение</th>
<th scope="col">Лог</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
+134 -2
View File
@@ -35,7 +35,7 @@
</div> </div>
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация"> <p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
<a href="/">Панель</a> <a href="/clusters">Кластеры</a>
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
<span>Кластер <code>{{ cluster_name | e }}</code></span> <span>Кластер <code>{{ cluster_name | e }}</code></span>
</p> </p>
@@ -78,7 +78,9 @@
<h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2> <h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2>
<p class="muted cluster-k8s-scope-hint"> <p class="muted cluster-k8s-scope-hint">
Данные по <strong>всему кластеру</strong>: ресурсы в namespace — со всех пространств имён; узлы и список Данные по <strong>всему кластеру</strong>: ресурсы в namespace — со всех пространств имён; узлы и список
namespace — без ограничения по отдельному ns. namespace — без ограничения по отдельному ns. В карточках ресурсов выпадающий список справа от заголовка
содержит все namespace из ответа API и из фактических объектов в таблицах; при выборе «Все namespace» строки
таблицы упорядочены по namespace, затем по имени ресурса.
</p> </p>
<p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p> <p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p>
</section> </section>
@@ -94,52 +96,182 @@
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2> <h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-pods" class="sr-only">Поды: фильтр по namespace</label>
<select
id="cluster-k8s-filter-pods"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="pods"
aria-label="Поды: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-pods-root" class="cluster-k8s-root" role="region" aria-label="Поды"></div> <div id="cluster-k8s-pods-root" class="cluster-k8s-root" role="region" aria-label="Поды"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2> <h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-deployments" class="sr-only">Deployments: фильтр по namespace</label>
<select
id="cluster-k8s-filter-deployments"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="deployments"
aria-label="Deployments: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-deployments-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-deployments-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2> <h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-statefulsets" class="sr-only">StatefulSets: фильтр по namespace</label>
<select
id="cluster-k8s-filter-statefulsets"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="statefulsets"
aria-label="StatefulSets: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-statefulsets-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-statefulsets-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2> <h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-daemonsets" class="sr-only">DaemonSets: фильтр по namespace</label>
<select
id="cluster-k8s-filter-daemonsets"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="daemonsets"
aria-label="DaemonSets: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-daemonsets-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-daemonsets-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2> <h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-replicasets" class="sr-only">ReplicaSets: фильтр по namespace</label>
<select
id="cluster-k8s-filter-replicasets"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="replicasets"
aria-label="ReplicaSets: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-replicasets-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-replicasets-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2> <h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-jobs" class="sr-only">Jobs: фильтр по namespace</label>
<select
id="cluster-k8s-filter-jobs"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="jobs"
aria-label="Jobs: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-jobs-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-jobs-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2> <h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-cronjobs" class="sr-only">CronJobs: фильтр по namespace</label>
<select
id="cluster-k8s-filter-cronjobs"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="cronjobs"
aria-label="CronJobs: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-cronjobs-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-cronjobs-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2> <h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-services" class="sr-only">Сервисы: фильтр по namespace</label>
<select
id="cluster-k8s-filter-services"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="services"
aria-label="Сервисы: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-services-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-services-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2> <h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-ingresses" class="sr-only">Ingresses: фильтр по namespace</label>
<select
id="cluster-k8s-filter-ingresses"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="ingresses"
aria-label="Ingresses: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-ingresses-root" class="cluster-k8s-root" role="region"></div> <div id="cluster-k8s-ingresses-root" class="cluster-k8s-root" role="region"></div>
</section> </section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title"> <section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title">
<div class="cluster-k8s-card-header">
<h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2> <h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2>
<div class="cluster-k8s-ns-filter-wrap">
<label for="cluster-k8s-filter-pvcs" class="sr-only">PVC: фильтр по namespace</label>
<select
id="cluster-k8s-filter-pvcs"
class="cluster-k8s-ns-select"
data-cluster-k8s-ns-filter="pvcs"
aria-label="PVC: фильтр по namespace"
>
<option value="">Все namespace</option>
</select>
</div>
</div>
<div id="cluster-k8s-pvcs-root" class="cluster-k8s-root" role="region" aria-label="PersistentVolumeClaim"></div> <div id="cluster-k8s-pvcs-root" class="cluster-k8s-root" role="region" aria-label="PersistentVolumeClaim"></div>
</section> </section>
+1 -1
View File
@@ -17,7 +17,7 @@
{% block content %} {% block content %}
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация"> <p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
<a href="/">Панель</a> <a href="/clusters">Кластеры</a>
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
<a href="/cluster/{{ cluster_name }}">Кластер <code>{{ cluster_name | e }}</code></a> <a href="/cluster/{{ cluster_name }}">Кластер <code>{{ cluster_name | e }}</code></a>
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
+81
View File
@@ -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 %}
<div class="footer-inner">
<p class="footer-copyright">
© {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %}
{% block content %}
<div
id="clusters-page-loading-overlay"
class="page-loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Загрузка списка кластеров"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span class="page-loading-label">Загрузка кластеров и метрик узлов…</span>
</div>
</div>
<section class="card hero-panel clusters-page-hero" aria-labelledby="clusters-page-title">
<div class="clusters-page-hero-row">
<div class="clusters-page-hero-text">
<h1 class="page-title" id="clusters-page-title">Кластеры</h1>
<p class="muted page-lead clusters-page-hero-lead">
Запущенные узлы kind, kubeconfig и действия.
</p>
</div>
<div class="clusters-page-hero-actions">
<a href="/cluster-create" class="clusters-page-create-btn">Создать кластер</a>
</div>
</div>
</section>
<section id="aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="donuts-heading">
<h2 id="donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
</section>
<section class="card clusters-card">
<h2>Список кластеров</h2>
<div class="table-wrap">
<table id="tbl-clusters" aria-label="Список кластеров">
<thead>
<tr>
<th class="col-cluster-name">Имя</th>
<th class="col-kind">kind</th>
<th class="col-kubeconfig">kubeconfig</th>
<th class="col-version">Версия</th>
<th class="col-workers">Workers</th>
<th class="col-state">Состояние</th>
<th class="col-actions">Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="list-msg" class="msg muted"></p>
</section>
{% include "partials/dashboard_modals.html" %}
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js" defer></script>
{% endblock %}
+9 -24
View File
@@ -1,4 +1,4 @@
{# Главная: создание (CTA), статистика, донаты, таблица кластеров. {# Главная: CTA, статистика, карточка «Ресурсы узлов» (только донаты); список кластеров на /clusters.
Автор: Сергей Антропов — https://devops.org.ru #} Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %} {% extends "base.html" %}
@@ -36,7 +36,7 @@
<div class="cta-hero-inner"> <div class="cta-hero-inner">
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1> <h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
<p class="muted page-lead cta-hero-lead"> <p class="muted page-lead cta-hero-lead">
Кластеры kind, kubeconfig и kubectl — ниже на странице. Данные обновляются автоматически. Список кластеров, сводка по узлам и действия — на странице <a href="/clusters">Кластеры</a>. Данные там обновляются автоматически.
</p> </p>
<a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a> <a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a>
</div> </div>
@@ -53,30 +53,15 @@
</aside> </aside>
</div> </div>
<section id="aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="donuts-heading"> <section
<h2 id="donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2> id="home-resources-summary-card"
class="card aggregate-donuts-card home-resources-summary-card"
aria-labelledby="home-resources-summary-heading"
>
<div id="home-resources-summary-wrap" class="home-resources-summary-inner">
<h2 id="home-resources-summary-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div> <div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
</section>
<section class="card clusters-card">
<h2>Кластеры</h2>
<div class="table-wrap">
<table id="tbl-clusters" aria-label="Список кластеров">
<thead>
<tr>
<th class="col-cluster-name">Имя</th>
<th class="col-kind">kind</th>
<th class="col-kubeconfig">kubeconfig</th>
<th class="col-version">Версия</th>
<th class="col-workers">Workers</th>
<th class="col-state">Состояние</th>
<th class="col-actions">Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div> </div>
<p id="list-msg" class="msg muted"></p>
</section> </section>
{% include "partials/dashboard_modals.html" %} {% include "partials/dashboard_modals.html" %}
+79 -15
View File
@@ -1,4 +1,5 @@
{# Агрегированный журнал завершённых заданий (JSON в каталогах кластеров). {# Журналы: задания по кластеру, развёртывание (provision_log), Helm (helm_addon_log).
Переключатель режима — как «Простой / Расширенный» на странице редактирования кластера.
Автор: Сергей Антропов — https://devops.org.ru #} Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %} {% extends "base.html" %}
@@ -18,7 +19,6 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{# Полноэкранный спиннер до первой загрузки журнала; скрывается в journal.js #}
<div <div
id="journal-page-loading-overlay" id="journal-page-loading-overlay"
class="page-loading-overlay" class="page-loading-overlay"
@@ -35,36 +35,100 @@
</div> </div>
<section class="card hero-panel cluster-create-hero" aria-labelledby="journal-hero-title"> <section class="card hero-panel cluster-create-hero" aria-labelledby="journal-hero-title">
<h1 class="page-title" id="journal-hero-title">Журнал заданий</h1> <h1 class="page-title" id="journal-hero-title">Журналы</h1>
<p class="muted page-lead">
Записи о завершённых операциях (<strong>create</strong>, <strong>start</strong>, <strong>stop</strong> и др.) хранятся в
<code>clusters/&lt;имя&gt;/journal/jobs_history.json</code> на томе с хоста. Сводка по всем кластерам, по <strong>30</strong> записей на страницу;
детали задания в процессе — по-прежнему в <a href="/cluster-create">создании кластера</a> и <code>GET /api/v1/jobs/{id}</code>.
</p>
</section> </section>
<section class="card create-cluster-card journal-toolbar-card"> <section class="card create-cluster-card journal-toolbar-card">
<h2 class="create-cluster-card__title">Действия</h2> <h2 class="create-cluster-card__title">Вид журнала</h2>
<div class="journal-toolbar-row">
<fieldset class="cluster-edit-mode-fieldset journal-view-fieldset">
<legend class="cluster-edit-mode-legend">Режим просмотра</legend>
<div
class="cluster-edit-segmented"
role="radiogroup"
aria-label="Какой журнал показывать"
>
<div class="cluster-edit-segment-item">
<input
type="radio"
name="journal_view"
id="journal-seg-per-cluster"
class="cluster-edit-segment-native"
value="per_cluster"
checked
/>
<label for="journal-seg-per-cluster" class="cluster-edit-segment-label">
<span class="cluster-edit-segment-title">По кластеру</span>
<span class="cluster-edit-segment-sub-wrap">
<span class="cluster-edit-segment-sub">История заданий</span>
<span class="cluster-edit-segment-sub">create / start / stop</span>
</span>
</label>
</div>
<div class="cluster-edit-segment-item">
<input
type="radio"
name="journal_view"
id="journal-seg-provision"
class="cluster-edit-segment-native"
value="provision"
/>
<label for="journal-seg-provision" class="cluster-edit-segment-label">
<span class="cluster-edit-segment-title">Развёртывание</span>
<span class="cluster-edit-segment-sub-wrap">
<span class="cluster-edit-segment-sub">Последний provision_log</span>
<span class="cluster-edit-segment-sub">по каждому кластеру</span>
</span>
</label>
</div>
<div class="cluster-edit-segment-item">
<input
type="radio"
name="journal_view"
id="journal-seg-helm"
class="cluster-edit-segment-native"
value="helm_addons"
/>
<label for="journal-seg-helm" class="cluster-edit-segment-label">
<span class="cluster-edit-segment-title">Helm-аддоны</span>
<span class="cluster-edit-segment-sub-wrap">
<span class="cluster-edit-segment-sub">История операций Helm</span>
<span class="cluster-edit-segment-sub">install / delete</span>
</span>
</label>
</div>
</div>
</fieldset>
<div id="journal-cluster-picker" class="journal-cluster-picker">
<label for="journal-cluster-select" class="journal-cluster-picker-label">Кластер</label>
<select id="journal-cluster-select" class="journal-cluster-select" aria-describedby="journal-cluster-picker-hint">
<option value="">— загрузка —</option>
</select>
<p id="journal-cluster-picker-hint" class="muted journal-cluster-picker-hint">
Журнал заданий только для выбранного каталога в <code>clusters/</code>.
</p>
</div>
<div class="journal-toolbar-actions">
<button type="button" class="btn-secondary" id="journal-refresh-btn">Обновить</button> <button type="button" class="btn-secondary" id="journal-refresh-btn">Обновить</button>
<span class="muted journal-page-size-hint">На странице по <strong>30</strong> записей.</span>
</div> </div>
<p id="journal-status" class="muted journal-status" role="status" aria-live="polite"></p> <p id="journal-status" class="muted journal-status" role="status" aria-live="polite"></p>
</section> </section>
<section class="card journal-list-card"> <section class="card journal-list-card">
<h2 class="create-cluster-card__title">Последние записи</h2> <h2 class="create-cluster-card__title" id="journal-table-section-title">Записи</h2>
<div id="journal-empty" class="muted journal-empty hidden">Нет записей в журналах кластеров (или каталоги кластеров ещё не созданы).</div> <div id="journal-empty" class="muted journal-empty hidden">Нет записей.</div>
<div id="journal-table-wrap" class="journal-table-wrap"> <div id="journal-table-wrap" class="journal-table-wrap">
<table class="journal-table" id="journal-table" aria-describedby="journal-hero-title"> <table class="journal-table" id="journal-table" aria-describedby="journal-hero-title">
<thead> <thead>
<tr> <tr>
<th scope="col">Завершено (UTC)</th> <th scope="col">Завершено (UTC)</th>
<th scope="col">Кластер</th> <th scope="col">Кластер</th>
<th scope="col">Тип</th> <th scope="col" id="journal-th-kind">Тип / операция</th>
<th scope="col">Статус</th> <th scope="col">Статус</th>
<th scope="col">Сообщение</th> <th scope="col">Сообщение</th>
<th scope="col">Журнал</th> <th scope="col">Лог</th>
</tr> </tr>
</thead> </thead>
<tbody id="journal-tbody"></tbody> <tbody id="journal-tbody"></tbody>
+1 -1
View File
@@ -54,7 +54,7 @@
</div> </div>
</div> </div>
{# Журнал фонового задания (ошибка / отмена) — страница «Последние задания». #} {# Журнал задания по API GET /jobs/{id} (модалка при необходимости из скриптов). #}
<div <div
id="job-log-modal-overlay" id="job-log-modal-overlay"
class="modal-overlay job-log-modal-overlay hidden" class="modal-overlay job-log-modal-overlay hidden"
+19
View File
@@ -0,0 +1,19 @@
# Дополнение только для «podman compose» (подключайте вместе с docker-compose.yml, см. Makefile).
# Сокет хоста подставляет Makefile (detect_podman_socket.py); в контейнере — /run/podman/podman.sock + KIND_K8S_REMOTE_SOCKET_URI.
#
# Rootless Podman: без keep-id внутренний user: 501:501 в контейнере — это другой subuid, не ваш UID
# на хосте; смонтированный podman.sock остаётся недоступен → permission denied на /var/run/docker.sock.
# Режим keep-id выравнивает user namespace с пользователем на хосте.
# «make podman …» по умолчанию подставляет KIND_K8S_CONTAINER_UIDGID=0:0 (см. Makefile): иначе docker-compose
# к сокету Podman часто не применяет keep-id и сокет остаётся недоступен.
#
# Docker Compose с движком Docker этот файл не используйте (цели make docker …).
#
# Автор: Сергей Антропов — https://devops.org.ru
services:
kind-k8s-web:
userns_mode: keep-id
# Не использовать group_add: keep-groups здесь: у Podman это «магическое» имя, а docker-compose
# (в т.ч. /usr/local/bin/docker-compose к сокету Podman) ищет реальную группу в /etc/group и падает
# с «Unable to find group keep-groups». Для сокета полагаемся на userns keep-id и uid:gid владельца сокета (Makefile).
+18 -2
View File
@@ -6,6 +6,14 @@
# export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock" # export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock"
# make podman up # make podman up
# #
# Доступ к сокету: при rootless Podman сокет часто принадлежит вашему UID; процесс в контейнере
# должен совпадать по UID/GID. «make podman …» передаёт KIND_K8S_CONTAINER_UIDGID и HOME (см. Makefile).
# Вручную: KIND_K8S_CONTAINER_UIDGID=$(id -u):$(id -g) KIND_K8S_CONTAINER_HOME=/tmp podman compose up -d
# SELinux (Fedora и т.д.): при «permission denied» попробуйте CONTAINER_SOCKET_VOLUME_OPTS=:Z в .env
# Podman rootless: «make podman …» подключает docker-compose.podman.yml (userns_mode: keep-id), подставляет
# путь к podman.sock и монтирует его в контейнер как /run/podman/podman.sock (см. CONTAINER_SOCKET_MOUNT_TARGET,
# KIND_K8S_REMOTE_SOCKET_URI). В .env нужны KIND_K8S_CONTAINER_UIDGID / KIND_K8S_CONTAINER_HOME (make setup).
#
# Автор: Сергей Антропов — https://devops.org.ru # Автор: Сергей Антропов — https://devops.org.ru
services: services:
@@ -18,9 +26,12 @@ services:
KUBECTL_VERSION: ${KUBECTL_VERSION:-} KUBECTL_VERSION: ${KUBECTL_VERSION:-}
HELM_VERSION: ${HELM_VERSION:-v3.16.3} HELM_VERSION: ${HELM_VERSION:-v3.16.3}
image: kind-k8s-tools:local image: kind-k8s-tools:local
container_name: kind-clusters-dashboard
# 0:0 — как root в образе (Docker Desktop и т.д.). Для rootless Podman задайте UID:GID владельца сокета.
user: "${KIND_K8S_CONTAINER_UIDGID:-0:0}"
volumes: volumes:
- ./clusters:/work/clusters - ./clusters:/work/clusters
- ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock - ${CONTAINER_SOCKET:-/var/run/docker.sock}:${CONTAINER_SOCKET_MOUNT_TARGET:-/var/run/docker.sock}${CONTAINER_SOCKET_VOLUME_OPTS:-}
# Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна). # Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна).
- ./app:/opt/kind-k8s/app - ./app:/opt/kind-k8s/app
# README для /documentation и GET /api/v1/docs/readme (без пересборки образа). # README для /documentation и GET /api/v1/docs/readme (без пересборки образа).
@@ -33,7 +44,10 @@ services:
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: environment:
DOCKER_HOST: unix:///var/run/docker.sock # Не-root в контейнере: домашний каталог (в образе нет /home/<uid>).
HOME: ${KIND_K8S_CONTAINER_HOME:-/root}
# docker-cli и kind читают DOCKER_HOST; путь должен совпадать с правой частью тома сокета выше.
DOCKER_HOST: ${KIND_K8S_REMOTE_SOCKET_URI:-unix:///var/run/docker.sock}
KIND_K8S_IN_CONTAINER: "1" KIND_K8S_IN_CONTAINER: "1"
KIND_K8S_WORKDIR: /work KIND_K8S_WORKDIR: /work
# kubeconfig.host на томе; скачивание в UI патчит копию с KIND_K8S_KUBECONFIG_CLIENT_HOST (по умолчанию localhost). # kubeconfig.host на томе; скачивание в UI патчит копию с KIND_K8S_KUBECONFIG_CLIENT_HOST (по умолчанию localhost).
@@ -64,6 +78,8 @@ services:
# Журнал заданий в clusters/<имя>/journal/jobs_history.json # Журнал заданий в clusters/<имя>/journal/jobs_history.json
KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES: ${KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES:-} KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES: ${KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES:-}
KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES: ${KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES:-} KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES: ${KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES:-}
# Максимум записей в clusters/<имя>/helm_addon_log.json (история Helm).
KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES: ${KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES:-}
KIND_K8S_APP_TITLE: ${KIND_K8S_APP_TITLE:-} KIND_K8S_APP_TITLE: ${KIND_K8S_APP_TITLE:-}
# 1 — uvicorn --reload (изменения в ./app); 0 — один процесс без reload. # 1 — uvicorn --reload (изменения в ./app); 0 — один процесс без reload.
KIND_K8S_UVICORN_RELOAD: ${KIND_K8S_UVICORN_RELOAD:-1} KIND_K8S_UVICORN_RELOAD: ${KIND_K8S_UVICORN_RELOAD:-1}
+8
View File
@@ -5,6 +5,13 @@ KIND_VERSION
KUBECTL_VERSION KUBECTL_VERSION
HELM_VERSION HELM_VERSION
CONTAINER_SOCKET CONTAINER_SOCKET
CONTAINER_SOCKET_MOUNT_TARGET
CONTAINER_SOCKET_VOLUME_OPTS
KIND_K8S_REMOTE_SOCKET_URI
KIND_K8S_CONTAINER_UIDGID
KIND_K8S_CONTAINER_HOME
KIND_K8S_PODMAN_CONTAINER_UIDGID
KIND_K8S_PODMAN_USE_SOCKET_OWNER
KIND_K8S_WEB_PORT KIND_K8S_WEB_PORT
KIND_K8S_PATCH_KUBECONFIG KIND_K8S_PATCH_KUBECONFIG
KIND_K8S_KUBECONFIG_CLIENT_HOST KIND_K8S_KUBECONFIG_CLIENT_HOST
@@ -27,5 +34,6 @@ KIND_K8S_HELM_VERSIONS_CACHE_SEC
KIND_K8S_HELM_VERSIONS_MAX KIND_K8S_HELM_VERSIONS_MAX
KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES
KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES
KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES
KIND_K8S_APP_TITLE KIND_K8S_APP_TITLE
KIND_K8S_UVICORN_RELOAD KIND_K8S_UVICORN_RELOAD
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Печатает в stdout путь к ``podman.sock`` или ``uid:gid`` владельца сокета.
Режимы:
* без аргументов путь (для ``$(shell ...)`` в Makefile);
* ``--print-owner`` ``uid:gid`` по ``stat`` найденного сокета (для ``KIND_K8S_CONTAINER_UIDGID``).
Логика пути: ``podman info`` (схема ``unix://`` снимается), ``podman machine inspect`` (без путей
в ``/var/folders/`` на macOS там API-сокет, **compose не смонтирует**: ``operation not supported``),
стабильные ``*.sock`` под ``~/.local/share/containers/podman/machine/``, затем типичные пути пользователя.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
def _compose_cannot_bind_mount_socket(path_str: str) -> bool:
"""
На macOS сокет в ``/var/folders//T/podman/`` (например ``*-api.sock`` из machine inspect)
существует на диске, но bind-mount в контейнер через compose часто даёт
``mkdir operation not supported`` такой путь в ``CONTAINER_SOCKET`` использовать нельзя.
"""
if sys.platform != "darwin":
return False
norm = os.path.normpath(path_str)
return "/var/folders/" in norm
def _strip_unix_scheme(raw: str) -> str:
"""Путь на диске без префикса ``unix://`` / ``unix:`` (как в выводе ``podman info``)."""
s = (raw or "").strip()
if s.startswith("unix://"):
return s[7:].strip() or s
if s.startswith("unix:"):
rest = s[5:].lstrip("/")
return "/" + rest if rest else s
return s
def _podman_machine_socket_paths(podman_bin: str) -> list[str]:
"""
Пути к сокету на **хосте** из ``podman machine inspect`` (актуально для macOS/Windows).
На Linux без machine обычно возвращается ``[]``.
"""
try:
proc = subprocess.run(
[podman_bin, "machine", "inspect"],
capture_output=True,
text=True,
timeout=20,
)
if proc.returncode != 0 or not (proc.stdout or "").strip():
return []
data = json.loads(proc.stdout)
if not isinstance(data, list):
return []
out: list[str] = []
for m in data:
if not isinstance(m, dict):
continue
ci = m.get("ConnectionInfo") or {}
if not isinstance(ci, dict):
continue
ps = ci.get("PodmanSocket")
pth: str | None = None
if isinstance(ps, dict):
raw = ps.get("Path")
pth = str(raw).strip() if raw else None
elif isinstance(ps, str) and ps.strip():
pth = ps.strip()
if pth:
cleaned = _strip_unix_scheme(pth)
if not _compose_cannot_bind_mount_socket(cleaned):
out.append(cleaned)
return out
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, TypeError, ValueError):
return []
def _darwin_stable_machine_sockets() -> list[str]:
"""Подкаталог machine на macOS: сокеты вне временного ``/var/folders``."""
base = Path.home() / ".local/share/containers/podman/machine"
if sys.platform != "darwin" or not base.is_dir():
return []
out: list[str] = []
try:
for p in sorted(base.rglob("*.sock"), key=lambda x: (len(x.parts), str(x))):
s = str(p)
if _compose_cannot_bind_mount_socket(s):
continue
out.append(s)
except OSError:
return []
return out
def _podman_socket_path_for_user() -> str:
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
if xdg:
return f"{xdg.rstrip('/')}/podman/podman.sock"
try:
return f"/run/user/{os.getuid()}/podman/podman.sock"
except AttributeError:
return "/run/user/1000/podman/podman.sock"
def podman_socket_owner_uidgid() -> str:
"""
UID:GID владельца найденного ``podman.sock`` на диске (``stat``).
Нужен для ``user:`` в compose: при Podman на macOS и в VM владелец сокета часто
**не совпадает** с ``id -u`` / ``id -g`` процесса ``make`` на хосте иначе
``permission denied`` на смонтированный сокет внутри контейнера.
Если сокет недоступен fallback на UID:GID текущего процесса.
"""
path_str = detect_podman_socket_path()
p = Path(path_str)
try:
if p.is_socket():
st = p.stat()
return f"{st.st_uid}:{st.st_gid}"
except OSError:
pass
try:
return f"{os.getuid()}:{os.getgid()}"
except AttributeError:
return "1000:1000"
def detect_podman_socket_path() -> str:
"""Путь к существующему сокету или типичный путь по умолчанию."""
cands: list[str] = []
podman_bin = shutil.which("podman")
if podman_bin:
try:
proc = subprocess.run(
[podman_bin, "info", "-f", "{{.Host.RemoteSocket.Path}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
line = (proc.stdout or "").strip().splitlines()
candidate = (line[0] if line else "").strip()
if candidate and "<no value>" not in candidate.lower():
cands.append(_strip_unix_scheme(candidate))
except (OSError, subprocess.TimeoutExpired):
pass
# Реальный файл на хосте (без macOS temp api.sock — см. _compose_cannot_bind_mount_socket).
cands.extend(_podman_machine_socket_paths(podman_bin))
cands.extend(_darwin_stable_machine_sockets())
cands.append(_podman_socket_path_for_user())
for path in cands:
cleaned = _strip_unix_scheme(path)
if _compose_cannot_bind_mount_socket(cleaned):
continue
p = Path(cleaned).expanduser()
try:
if p.is_socket():
return str(p.resolve())
except OSError:
continue
return str(Path(_strip_unix_scheme(_podman_socket_path_for_user())).expanduser())
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--print-owner":
sys.stdout.write(podman_socket_owner_uidgid())
return
sys.stdout.write(detect_podman_socket_path())
if __name__ == "__main__":
main()
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
Проверка Unix-сокетов Docker и Podman: есть ли доступ без EACCES и отвечает ли API (GET /_ping).
Печатает таблицу и рекомендуемый ``DOCKER_HOST`` для рабочих вариантов.
Запуск на хосте (не внутри контейнера веб-UI).
Важно: вывод ``podman info`` (RemoteSocket.Path) на macOS часто указывает путь **внутри VM**
Podman Machine (например ``/run/podman/...``) на диске macOS такого файла нет. «Настоящий»
сокет для монтирования в compose первый **существующий** ``*.sock`` из той же логики, что
``scripts/detect_podman_socket.py`` (как у ``make podman``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import argparse
import importlib.util
import logging
import os
import shutil
import socket
import stat
import subprocess
import sys
from pathlib import Path
from typing import Iterable
logger = logging.getLogger(__name__)
# Минимальный запрос к Docker-compatible API (Podman отвечает так же).
_PING_REQUEST = b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
def _docker_host_unix_from_env() -> str | None:
raw = (os.environ.get("DOCKER_HOST") or "").strip()
if raw.startswith("unix://"):
return raw[len("unix://") :].strip() or None
return None
def _normalize_socket_path(raw: str) -> str:
"""
Путь к сокету на диске: убираем префикс ``unix://`` / ``unix:`` (как в выводе ``podman info``).
"""
p = (raw or "").strip()
if p.startswith("unix://"):
p = p[7:]
elif p.startswith("unix:"):
rest = p[5:].lstrip("/")
p = "/" + rest if rest else p
return p.strip() or raw.strip()
def _detect_podman_socket_path_via_project_script() -> str | None:
"""
Тот же поиск «живого» сокета на **этой** ФС, что и ``make podman`` / compose.
Возвращает ``None``, если модуль не загрузился.
"""
script_path = Path(__file__).resolve().parent / "detect_podman_socket.py"
try:
spec = importlib.util.spec_from_file_location("_kind_k8s_detect_podman", script_path)
if spec is None or spec.loader is None:
return None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
fn = getattr(mod, "detect_podman_socket_path", None)
if callable(fn):
return str(fn())
except Exception as e:
logger.debug("detect_podman_socket.py: %s", e)
return None
def _podman_default_path() -> str:
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
if xdg:
return f"{xdg.rstrip('/')}/podman/podman.sock"
try:
return f"/run/user/{os.getuid()}/podman/podman.sock"
except AttributeError:
return "/run/user/1000/podman/podman.sock"
def _collect_candidates() -> list[tuple[str, str]]:
"""
Список (метка, путь). Порядок: переменная окружения, CLI, стандартные пути.
Дубликаты путей убираем, метки склеиваем через « + ».
"""
# Ключ — нормализованный путь; для несуществующих файлов не вызываем resolve().
buckets: dict[str, tuple[list[str], str]] = {}
order_keys: list[str] = []
def add(label: str, path: str | None) -> None:
if not path:
return
expanded = str(Path(_normalize_socket_path(path)).expanduser())
key = os.path.normpath(expanded)
if key not in buckets:
order_keys.append(key)
buckets[key] = ([label], expanded)
else:
buckets[key][0].append(label)
env_u = _docker_host_unix_from_env()
if env_u:
add("DOCKER_HOST", env_u)
# Реальный путь к файлу сокета на хосте (не «логический» путь из VM в podman info).
det = _detect_podman_socket_path_via_project_script()
if det:
add("хост: detect_podman_socket.py (как make podman)", det)
podman_bin = shutil.which("podman")
if podman_bin:
try:
proc = subprocess.run(
[podman_bin, "info", "-f", "{{.Host.RemoteSocket.Path}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
line = (proc.stdout or "").strip().splitlines()
cand = (line[0] if line else "").strip()
if cand and "<no value>" not in cand.lower():
add("podman info", cand)
except (OSError, subprocess.TimeoutExpired) as e:
logger.debug("podman info: %s", e)
docker_bin = shutil.which("docker")
if docker_bin:
try:
proc = subprocess.run(
[docker_bin, "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
host = (proc.stdout or "").strip().splitlines()
h = (host[0] if host else "").strip()
if h.startswith("unix://"):
add("docker context", h[len("unix://") :])
except (OSError, subprocess.TimeoutExpired) as e:
logger.debug("docker context: %s", e)
add("стандарт Docker", "/var/run/docker.sock")
home = Path.home()
add("Docker Desktop / Linux", str(home / ".docker/run/docker.sock"))
add("Colima", str(home / ".colima/default/docker.sock"))
add("Podman типичный", _podman_default_path())
return [(" + ".join(buckets[k][0]), buckets[k][1]) for k in order_keys]
def _stat_line(path: Path) -> str:
try:
if not path.exists():
return "нет файла"
if not path.is_socket():
return "не сокет"
st = path.stat()
mode = stat.filemode(st.st_mode)
return f"{mode} uid={st.st_uid} gid={st.st_gid}"
except OSError as e:
return f"stat: {e}"
def _probe_http_ping(sock_path: Path, timeout_sec: float) -> tuple[str, str]:
"""Статус: ok | denied | conn_err | bad_reply | missing."""
try:
if not sock_path.exists():
return "missing", "файл отсутствует"
if not sock_path.is_socket():
return "missing", "не Unix-сокет"
except OSError as e:
return "missing", str(e)
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout_sec)
sock.connect(os.fspath(sock_path))
sock.sendall(_PING_REQUEST)
chunks: list[bytes] = []
while True:
part = sock.recv(4096)
if not part:
break
chunks.append(part)
if len(chunks) > 8: # достаточно для заголовков + OK
break
sock.close()
except PermissionError:
return "denied", "EACCES при connect (как у docker info)"
except OSError as e:
return "conn_err", str(e)
raw = b"".join(chunks)
if b" 200 " in raw and b"OK" in raw:
return "ok", "HTTP 200, тело OK"
head = raw[: min(120, len(raw))].decode("utf-8", errors="replace")
return "bad_reply", head.replace("\r", " ").replace("\n", " ")
def _probe_docker_cli(unix_path: str, timeout_sec: int) -> tuple[str, str]:
docker = shutil.which("docker")
if not docker:
return "skip", "docker CLI нет в PATH"
uri = f"unix://{unix_path}"
try:
proc = subprocess.run(
[docker, "-H", uri, "info"],
capture_output=True,
text=True,
timeout=timeout_sec,
)
if proc.returncode == 0:
return "ok", "docker -H … info → 0"
err = (proc.stderr or proc.stdout or "").strip()[:200]
if "permission denied" in err.lower() or "eacces" in err.lower():
return "denied", err
return "cli_err", err or f"код {proc.returncode}"
except subprocess.TimeoutExpired:
return "cli_err", "timeout"
except OSError as e:
return "cli_err", str(e)
def _run(candidates: Iterable[tuple[str, str]], timeout: float, with_docker_cli: bool) -> int:
rows: list[tuple[str, str, str, str, str]] = []
working: list[str] = []
for label, path_str in candidates:
p = Path(path_str).expanduser()
st = _stat_line(p)
http_st, http_msg = _probe_http_ping(p, timeout_sec=timeout)
cli_st, cli_msg = ("", "")
if with_docker_cli:
cli_st, cli_msg = _probe_docker_cli(path_str, timeout_sec=int(timeout) + 5)
rows.append((label, path_str, st, f"{http_st}: {http_msg}", f"{cli_st}: {cli_msg}"))
if http_st == "ok":
working.append(f"unix://{path_str}")
w = 22
print(
f"{'Источник':<{w}} {'Путь':<40} {'Файл':<28} {'HTTP /_ping':<35} {'docker CLI':<30}",
)
print("-" * 145)
for label, path_str, st, http_col, cli_col in rows:
p_short = path_str if len(path_str) <= 40 else path_str[:37] + "..."
print(f"{label:<{w}} {p_short:<40} {st:<28} {http_col:<35} {cli_col:<30}")
me = ""
try:
me = f"{os.getuid()}:{os.getgid()}"
except AttributeError:
me = "?"
print()
print(f"Текущий процесс: uid:gid = {me}")
if working:
print("\nРабочие URI (HTTP /_ping OK), можно задать:")
for u in working:
print(f" export DOCKER_HOST={u!r}")
print("\nДля compose этого проекта см. CONTAINER_SOCKET / KIND_K8S_REMOTE_SOCKET_URI в .env.")
return 0
print(
"\nНи один сокет не ответил на /_ping без отказа. "
"Проверьте, что Docker Desktop / Podman machine запущены; для Podman на macOS часто помогает "
"запуск веб-UI от 0:0 (см. Makefile, make podman) или нативный «podman compose».",
)
return 1
def main() -> int:
parser = argparse.ArgumentParser(
description="Проверка сокетов Docker/Podman: доступ к API без permission denied.",
)
parser.add_argument(
"-t",
"--timeout",
type=float,
default=5.0,
help="Таймаут connect/read для сокета (сек)",
)
parser.add_argument(
"--no-docker-cli",
action="store_true",
help="Не вызывать «docker -H … info»",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Подробный журнал",
)
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
cands = _collect_candidates()
if not cands:
print("Нет кандидатов путей.", file=sys.stderr)
return 2
return _run(cands, timeout=args.timeout, with_docker_cli=not args.no_docker_cli)
if __name__ == "__main__":
raise SystemExit(main())
+256 -12
View File
@@ -2,6 +2,7 @@
"""Интерактивное создание ``.env`` в корне репозитория Kind Clusters Dashboard. """Интерактивное создание ``.env`` в корне репозитория Kind Clusters Dashboard.
Список переменных и подсказок задаётся в этом файле (не используется env.example). Список переменных и подсказок задаётся в этом файле (не используется env.example).
В начале мастера выбирается только **docker** или **podman** путь ``CONTAINER_SOCKET`` подставляется автоматически.
Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter записать предложенное значение. Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter записать предложенное значение.
Запуск: ``python3 scripts/setup_env_interactive.py`` или ``make setup``. Запуск: ``python3 scripts/setup_env_interactive.py`` или ``make setup``.
@@ -16,7 +17,11 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import os
import secrets import secrets
import shutil
import subprocess
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -85,9 +90,9 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
"дефолт скрипта — закреплённый тег (в Dockerfile без аргумента берётся stable.txt).", "дефолт скрипта — закреплённый тег (в Dockerfile без аргумента берётся stable.txt).",
), ),
_EnvPrompt( _EnvPrompt(
"# --- docker-compose.yml: том сокета (Docker / Podman rootless) ---", "# --- docker-compose.yml: сокет + CONTAINER_CLI + user/HOME (доступ к сокету rootless Podman) ---",
"CONTAINER_SOCKET", "CONTAINER_SOCKET",
"Путь к сокету на хосте для volume (Docker: /var/run/docker.sock; Podman rootless — см. README).", "",
), ),
_EnvPrompt( _EnvPrompt(
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000 внутри) ---", "# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000 внутри) ---",
@@ -105,7 +110,6 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
"KIND_K8S_KUBECONFIG_CLIENT_HOST", "KIND_K8S_KUBECONFIG_CLIENT_HOST",
"Имя хоста в server= для скачиваемого kubeconfig (kubectl на машине пользователя). Пусто — localhost (лучше 127.0.0.1 с TLS kind). Для доступа с другой машины — IP/имя хоста Docker.", "Имя хоста в server= для скачиваемого kubeconfig (kubectl на машине пользователя). Пусто — localhost (лучше 127.0.0.1 с TLS kind). Для доступа с другой машины — IP/имя хоста Docker.",
), ),
_EnvPrompt(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."),
_EnvPrompt( _EnvPrompt(
None, None,
"KIND_K8S_SKIP_VERSION_LIST", "KIND_K8S_SKIP_VERSION_LIST",
@@ -132,7 +136,12 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
_EnvPrompt( _EnvPrompt(
None, None,
"KIND_K8S_JOBS_JSON", "KIND_K8S_JOBS_JSON",
"Путь к JSON с историей заданий (создание/старт/стоп кластера); пусто — файл kind_k8s_jobs.json в каталоге clusters/ на томе данных.", "Только файл глобального снимка заданий (очередь и running; после перезапуска контейнера). "
"Пусто — <корень_данных>/clusters/kind_k8s_jobs.json (корень = KIND_K8S_WORKDIR; в compose внутри контейнера /work, "
"на хосте тот же том — обычно ./clusters/kind_k8s_jobs.json). "
"Архив завершённых операций и страница «Журнал» — clusters/<имя>/journal/jobs_history.json; "
"полный лог create/start/reapply — clusters/<имя>/provision_log.json; история Helm-аддонов — "
"clusters/<имя>/helm_addon_log.json — эти пути KIND_K8S_JOBS_JSON не переопределяет.",
), ),
_EnvPrompt( _EnvPrompt(
None, None,
@@ -161,14 +170,239 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
), ),
) )
_ENV_FILE_HEADER = """# Файл .env для Kind Clusters Dashboard # Путь к сокету Docker в примерах и значение по умолчанию для CONTAINER_SOCKET (не подставляется из UID).
# Создан интерактивно: scripts/setup_env_interactive.py _DOCKER_SOCKET_EXAMPLE = "/var/run/docker.sock"
#
# В docker-compose.yml заданы литералами (не из этого файла):
# DOCKER_HOST, KIND_K8S_IN_CONTAINER, KIND_K8S_WORKDIR=/work в контейнере.
#
"""
def _process_uid() -> int | None:
"""UID процесса (то же, что ``id -u`` для пользователя, запустившего скрипт)."""
try:
return int(os.getuid())
except AttributeError:
return None
def _podman_socket_path_for_user() -> str:
"""
Путь к podman.sock для подсказок: ``$XDG_RUNTIME_DIR/podman/podman.sock`` или
``/run/user/<UID>/podman/podman.sock`` (UID результат :func:`_process_uid`, иначе 1000).
"""
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
if xdg:
return f"{xdg.rstrip('/')}/podman/podman.sock"
uid = _process_uid()
if uid is not None:
return f"/run/user/{uid}/podman/podman.sock"
return "/run/user/1000/podman/podman.sock"
def _unix_url_for_path(absolute_path: str) -> str:
"""Ссылка вида ``unix:///…`` для абсолютного пути к сокету."""
p = absolute_path if absolute_path.startswith("/") else f"/{absolute_path}"
return f"unix://{p}"
def _docker_host_unix_path_from_env() -> str | None:
"""Путь из ``DOCKER_HOST=unix://…``, если задан."""
dh = (os.environ.get("DOCKER_HOST") or "").strip()
if not dh.startswith("unix://"):
return None
path = dh[7:]
if path and not path.startswith("/"):
path = "/" + path.lstrip("/")
return path or None
def _first_existing_socket(candidates: list[str]) -> str | None:
"""Первый существующий unix-socket из списка путей."""
for c in candidates:
if not c:
continue
p = Path(c).expanduser()
try:
if p.is_socket():
return str(p.resolve())
except OSError:
logger.debug("Пропуск кандидата сокета (ошибка доступа): %s", p, exc_info=True)
return None
def _detect_docker_socket_path() -> str:
"""
Авто-поиск сокета Docker на машине, где запущен мастер: ``DOCKER_HOST``, ``/var/run/docker.sock``,
типичные пути Docker Desktop / Colima.
"""
cands: list[str] = []
envp = _docker_host_unix_path_from_env()
if envp:
cands.append(envp)
cands.append(_DOCKER_SOCKET_EXAMPLE)
home = Path.home()
cands.append(str(home / ".docker/run/docker.sock"))
cands.append(str(home / ".colima/default/docker.sock"))
found = _first_existing_socket(cands)
if found:
logger.info("Найден сокет Docker: %s", found)
return found
logger.info("Сокет Docker не найден на диске — подставляю стандартный путь %s", _DOCKER_SOCKET_EXAMPLE)
return _DOCKER_SOCKET_EXAMPLE
def _podman_uidgid_for_env() -> str:
"""
Строка ``uid:gid`` для ``KIND_K8S_CONTAINER_UIDGID`` при Podman.
Берётся владелец файла сокета (как у ``make podman ``), чтобы на macOS и в VM
не расходилось с ``id -u`` / ``id -g`` интерактивного мастера.
"""
script = Path(__file__).resolve().parent / "detect_podman_socket.py"
try:
proc = subprocess.run(
[sys.executable, str(script), "--print-owner"],
capture_output=True,
text=True,
timeout=25,
)
if proc.returncode == 0:
s = (proc.stdout or "").strip()
parts = s.split(":", 1)
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
return s
except (OSError, subprocess.TimeoutExpired):
logger.debug("detect_podman_socket.py --print-owner недоступен", exc_info=True)
try:
return f"{os.getuid()}:{os.getgid()}"
except AttributeError:
return "1000:1000"
def _detect_podman_socket_path() -> str:
"""
Путь к ``podman.sock`` тот же алгоритм, что ``scripts/detect_podman_socket.py``
(``podman info``, ``podman machine inspect`` на macOS, типичные пути).
"""
script = Path(__file__).resolve().parent / "detect_podman_socket.py"
try:
proc = subprocess.run(
[sys.executable, str(script)],
capture_output=True,
text=True,
timeout=35,
)
if proc.returncode == 0:
line = (proc.stdout or "").strip().splitlines()
candidate = (line[0] if line else "").strip()
if candidate:
logger.info("Путь к сокету Podman (detect_podman_socket.py): %s", candidate)
return candidate
except (OSError, subprocess.TimeoutExpired) as e:
logger.debug("detect_podman_socket.py не удалось: %s", e)
fallback = _podman_socket_path_for_user()
logger.info("Сокет Podman не определён скриптом — подставляю типичный путь %s", fallback)
return fallback
def ask_container_engine() -> tuple[str, str]:
"""Спросить только docker или podman; вернуть ``(CONTAINER_CLI, CONTAINER_SOCKET)`` с автоподбором пути."""
print(
"\n--- Движок контейнеров для compose ---\n"
" Укажите, чем пользуетесь на этом компьютере. Путь к сокету (CONTAINER_SOCKET) подставится сам;\n"
" CONTAINER_CLI в .env будет docker или podman (в образе веб-UI к сокету ходит клиент docker).\n",
)
pod_hint = _podman_socket_path_for_user()
print(" 1 или d / docker — Docker")
print(f" 2 или p / podman — Podman (типичный путь при отсутствии сокета: {pod_hint})")
print(" Enter — вариант 1 (Docker)")
raw = input(" Ваш выбор [1]: ").strip().lower()
if raw in ("", "1", "d", "docker"):
cli, sock = "docker", _detect_docker_socket_path()
elif raw in ("2", "p", "podman"):
cli, sock = "podman", _detect_podman_socket_path()
else:
print(" Не распознано — принимаю Docker.")
cli, sock = "docker", _detect_docker_socket_path()
warn = ""
try:
if not Path(sock).is_socket():
warn = " Внимание: по этому пути сейчас нет socket-файла — проверьте, что движок запущен, или поправьте CONTAINER_SOCKET в .env."
except OSError:
warn = " Внимание: нет доступа к пути сокета — при необходимости поправьте CONTAINER_SOCKET в .env."
if cli == "podman":
ug = _podman_uidgid_for_env()
print(
f" Для rootless Podman в .env также будут записаны KIND_K8S_CONTAINER_UIDGID={ug} "
"и KIND_K8S_CONTAINER_HOME=/tmp (иначе в контейнере будет permission denied на сокет).\n",
)
else:
print(
" Для Docker в .env будут KIND_K8S_CONTAINER_UIDGID=0:0 и KIND_K8S_CONTAINER_HOME=/root.\n",
)
print(f" В .env: CONTAINER_CLI={cli}, CONTAINER_SOCKET={sock}.{warn}\n")
return cli, sock
def _compose_user_env_lines(engine_cli: str) -> list[str]:
"""
Строки для .env: путь монтирования сокета и URI для ``DOCKER_HOST``, затем ``user``/``HOME``.
Для Podman сокет в контейнере ``/run/podman/podman.sock``, для Docker ``/var/run/docker.sock``
(должно совпадать с ``KIND_K8S_REMOTE_SOCKET_URI``).
"""
el = engine_cli.strip().lower()
block: list[str] = []
if el == "podman":
block.extend(
[
"# Том сокета внутри контейнера и URI API (не docker.sock — явно путь Podman)\n",
"CONTAINER_SOCKET_MOUNT_TARGET=/run/podman/podman.sock\n",
"KIND_K8S_REMOTE_SOCKET_URI=unix:///run/podman/podman.sock\n",
],
)
else:
block.extend(
[
"CONTAINER_SOCKET_MOUNT_TARGET=/var/run/docker.sock\n",
"KIND_K8S_REMOTE_SOCKET_URI=unix:///var/run/docker.sock\n",
],
)
block.append(
"# KIND_K8S_CONTAINER_UIDGID — user: в compose; KIND_K8S_CONTAINER_HOME — переменная HOME в контейнере\n",
)
if el == "podman":
ug = _podman_uidgid_for_env()
block.append(f"KIND_K8S_CONTAINER_UIDGID={ug}\n")
block.append("KIND_K8S_CONTAINER_HOME=/tmp\n")
else:
block.append("KIND_K8S_CONTAINER_UIDGID=0:0\n")
block.append("KIND_K8S_CONTAINER_HOME=/root\n")
return block
def _build_env_file_header() -> str:
"""Заголовок записываемого .env."""
pod = _podman_socket_path_for_user()
url = _unix_url_for_path(pod)
uid = _process_uid()
uid_line = (
f"# UID пользователя, запустившего мастер (os.getuid / id -u): {uid}\n"
if uid is not None
else "# UID недоступен на этой ОС.\n"
)
return (
"# Файл .env для Kind Clusters Dashboard\n"
"# Создан интерактивно: scripts/setup_env_interactive.py\n"
"#\n"
"# В docker-compose.yml заданы литералами (не из этого файла): KIND_K8S_IN_CONTAINER, KIND_K8S_WORKDIR=/work.\n"
"# DOCKER_HOST в контейнере берётся из KIND_K8S_REMOTE_SOCKET_URI (см. ниже).\n"
"#\n"
"# CONTAINER_SOCKET и CONTAINER_CLI — в мастере (только docker/podman); путь к сокету подбирается автоматически.\n"
"# KIND_K8S_CONTAINER_UIDGID / KIND_K8S_CONTAINER_HOME — для доступа к сокету (Podman rootless: ваш uid:gid).\n"
f"# Справочно, типичные пути: Docker — {_DOCKER_SOCKET_EXAMPLE}; Podman — {pod} (URL: {url}).\n"
f"{uid_line}"
"#\n\n"
)
def _configure_logging() -> None: def _configure_logging() -> None:
@@ -247,13 +481,23 @@ def run(*, output: Path, force: bool = False) -> int:
"Enter без ввода — записать значение по умолчанию из подсказки.\n", "Enter без ввода — записать значение по умолчанию из подсказки.\n",
) )
engine_cli, engine_sock = ask_container_engine()
collected: dict[str, str] = {} collected: dict[str, str] = {}
out_chunks: list[str] = [_ENV_FILE_HEADER] out_chunks: list[str] = [_build_env_file_header()]
for spec in _SETUP_PROMPTS: for spec in _SETUP_PROMPTS:
if spec.section_comment: if spec.section_comment:
out_chunks.append(f"{spec.section_comment}\n") out_chunks.append(f"{spec.section_comment}\n")
if spec.key == "CONTAINER_SOCKET":
collected["CONTAINER_SOCKET"] = engine_sock
collected["CONTAINER_CLI"] = engine_cli
out_chunks.append(f"CONTAINER_SOCKET={engine_sock}\n")
out_chunks.append(f"CONTAINER_CLI={engine_cli}\n")
out_chunks.extend(_compose_user_env_lines(engine_cli))
continue
default = _default_for_key(spec.key) default = _default_for_key(spec.key)
if spec.key == "DATABASE_URL": if spec.key == "DATABASE_URL":
built = _default_database_url(collected) built = _default_database_url(collected)