Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
@@ -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__/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`), пути |
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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` | Записей на страницу, **1–100**, по умолчанию **30** (как в веб-интерфейсе **`/journal`**) |
|
| `limit` | Записей на страницу, **1–100**, по умолчанию **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`: []**.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 число строк на текущей странице (для подписи «записи N–M»)
|
|
||||||
*/
|
|
||||||
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);
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
@@ -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__header .cluster-addon-card__title {
|
||||||
.cluster-addon-card__title {
|
margin: 0;
|
||||||
margin: 0 0 0.35rem;
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/<имя>/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"
|
||||||
</section>
|
class="card create-progress-wrap cluster-addons-helm-progress hidden"
|
||||||
|
|
||||||
<div
|
|
||||||
id="addons-busy-overlay"
|
|
||||||
class="page-loading-overlay cluster-addons-busy-overlay hidden"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-busy="false"
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
aria-labelledby="addons-helm-progress-title"
|
||||||
>
|
>
|
||||||
<div class="page-loading-backdrop" aria-hidden="true"></div>
|
<h2 class="create-cluster-card__title" id="addons-helm-progress-title">Ход операции Helm</h2>
|
||||||
<div class="page-loading-center">
|
<p class="create-progress-hint muted" id="addons-helm-progress-hint">
|
||||||
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
|
Выполняется команда в контейнере веб-приложения; полный вывод появится по завершении.
|
||||||
<span id="addons-busy-label" class="page-loading-label">Выполняется операция Helm…</span>
|
</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>
|
</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% include "partials/dashboard_modals.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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/<имя>/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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user