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

- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
Sergey Antropoff
2026-04-04 13:42:21 +03:00
parent 17f6233fd7
commit eb063aec20
38 changed files with 3990 additions and 734 deletions
+2
View File
@@ -2,6 +2,8 @@
# Содержимое clusters/<имя>/ в репозиторий не попадает (только clusters/.gitkeep).
.DS_Store
.env
# Генерируется «make podman …» — перекрывает CONTAINER_SOCKET из .env (устаревший путь в /var/folders).
.env.podman.override
# Кэш и байткод Python (не коммитить)
__pycache__/
+75 -27
View File
@@ -10,17 +10,50 @@
#
# Автор: Сергей Антропов — https://devops.org.ru
ifneq (,$(filter podman,$(MAKECMDGOALS)))
COMPOSE := podman compose
else ifneq (,$(filter docker,$(MAKECMDGOALS)))
COMPOSE := docker compose
endif
.PHONY: help docker podman _require_runtime up down restart logs ps setup clusters-dir check-docker build rebuild kubectl
KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py
PYTHON ?= python3
ifneq (,$(filter podman,$(MAKECMDGOALS)))
COMPOSE := podman compose
# Сокет Podman на хосте (перекрывает CONTAINER_SOCKET из .env, если там путь Docker).
PODMAN_HOST_SOCK := $(shell $(PYTHON) "$(KIND_K8S_DIR)/scripts/detect_podman_socket.py")
# docker-compose из podman compose часто подставляет том из .env, игнорируя префикс VAR= в командной строке.
# Файл .env.podman.override (см. цель _podman_env_override) задаёт CONTAINER_SOCKET после .env.
PODMAN_COMPOSE_ENV_FILE := --env-file .env.podman.override
# user: в контейнере для доступа к смонтированному podman.sock:
# «podman compose» на macOS часто дергает docker-compose к API Podman — keep-id не срабатывает, uid с сокета даёт EACCES.
# По умолчанию 0:0 (как у типичного docker.sock); переопределение: KIND_K8S_PODMAN_CONTAINER_UIDGID=501:20
# или KIND_K8S_PODMAN_USE_SOCKET_OWNER=1 (владелец по stat, как detect_podman_socket.py --print-owner).
ifneq (,$(KIND_K8S_PODMAN_USE_SOCKET_OWNER))
_PODMAN_UIDGID := $(shell $(PYTHON) "$(KIND_K8S_DIR)/scripts/detect_podman_socket.py" --print-owner)
else
ifneq (,$(KIND_K8S_PODMAN_CONTAINER_UIDGID))
_PODMAN_UIDGID := $(KIND_K8S_PODMAN_CONTAINER_UIDGID)
else
_PODMAN_UIDGID := 0:0
endif
endif
_PODMAN_UID_FIRST := $(word 1,$(subst :, ,$(_PODMAN_UIDGID)))
_PODMAN_HOME := $(if $(filter 0,$(_PODMAN_UID_FIRST)),/root,/tmp)
ifneq (,$(findstring var/folders,$(PODMAN_HOST_SOCK)))
$(error PODMAN_HOST_SOCK содержит /var/folders/ — этот API-сокет нельзя смонтировать в compose. Обновите репозиторий (scripts/detect_podman_socket.py) и удалите такой путь из .env)
endif
# Внутри контейнера — путь podman, не docker.sock; DOCKER_HOST должен указывать на тот же путь (API совместим).
COMPOSE_PODMAN_ENV := KIND_K8S_CONTAINER_UIDGID=$(_PODMAN_UIDGID) KIND_K8S_CONTAINER_HOME=$(_PODMAN_HOME) CONTAINER_SOCKET=$(PODMAN_HOST_SOCK) CONTAINER_SOCKET_MOUNT_TARGET=/run/podman/podman.sock KIND_K8S_REMOTE_SOCKET_URI=unix:///run/podman/podman.sock
COMPOSE_FILE_ARGS := -f docker-compose.yml -f docker-compose.podman.yml
else ifneq (,$(filter docker,$(MAKECMDGOALS)))
COMPOSE := docker compose
# Сброс путей от Podman в .env: сокет в контейнере снова /var/run/docker.sock.
COMPOSE_DOCKER_OVERRIDES := CONTAINER_SOCKET_MOUNT_TARGET=/var/run/docker.sock KIND_K8S_REMOTE_SOCKET_URI=unix:///var/run/docker.sock
endif
COMPOSE_PODMAN_ENV ?=
COMPOSE_DOCKER_OVERRIDES ?=
COMPOSE_FILE_ARGS ?=
PODMAN_COMPOSE_ENV_FILE ?=
.PHONY: help docker podman _require_runtime _podman_env_override up down restart logs ps setup clusters-dir check-docker build rebuild kubectl probe-sockets
# При «exec format error» у kind: make docker build COMPOSE_BUILD_FLAGS=--platform linux/arm64
COMPOSE_BUILD_FLAGS ?=
# Для цели kubectl: имя кластера и аргументы kubectl после --kubeconfig (по умолчанию: get nodes).
@@ -57,20 +90,35 @@ _require_runtime:
exit 1; \
fi
up: _require_runtime clusters-dir build ## (с docker/podman) Поднять веб-UI в фоне
cd "$(KIND_K8S_DIR)" && $(COMPOSE) up -d kind-k8s-web
# Только для make podman …: перекрыть CONTAINER_SOCKET из .env (устаревший /var/folders/… ломает volume).
_podman_env_override:
@if [ -n "$(COMPOSE_PODMAN_ENV)" ]; then \
printf '%s\n' \
"CONTAINER_SOCKET=$(PODMAN_HOST_SOCK)" \
"KIND_K8S_CONTAINER_UIDGID=$(_PODMAN_UIDGID)" \
"KIND_K8S_CONTAINER_HOME=$(_PODMAN_HOME)" \
"CONTAINER_SOCKET_MOUNT_TARGET=/run/podman/podman.sock" \
"KIND_K8S_REMOTE_SOCKET_URI=unix:///run/podman/podman.sock" \
> "$(KIND_K8S_DIR)/.env.podman.override"; \
fi
down: _require_runtime ## (с docker/podman) Остановить compose в этом каталоге
cd "$(KIND_K8S_DIR)" && $(COMPOSE) down
up: _require_runtime _podman_env_override clusters-dir build ## (с docker/podman) Поднять веб-UI в фоне
cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) up -d kind-k8s-web
restart: _require_runtime ## (с docker/podman) Перезапустить контейнер kind-k8s-web
cd "$(KIND_K8S_DIR)" && $(COMPOSE) restart kind-k8s-web
down: _require_runtime _podman_env_override ## (с docker/podman) Остановить compose в этом каталоге
cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) down
logs: _require_runtime ## (с docker/podman) Логи kind-k8s-web (follow -f)
cd "$(KIND_K8S_DIR)" && $(COMPOSE) logs -f kind-k8s-web
restart: _require_runtime _podman_env_override ## (с docker/podman) Перезапустить контейнер kind-k8s-web
cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) restart kind-k8s-web
ps: _require_runtime ## (с docker/podman) Статус контейнеров compose-проекта
cd "$(KIND_K8S_DIR)" && $(COMPOSE) ps
logs: _require_runtime _podman_env_override ## (с docker/podman) Логи kind-k8s-web (follow -f)
cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) logs -f kind-k8s-web
ps: _require_runtime _podman_env_override ## (с docker/podman) Статус контейнеров compose-проекта
cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) ps
probe-sockets: ## Проверить сокеты Docker/Podman (доступ без permission denied)
@$(PYTHON) "$(KIND_K8S_DIR)/scripts/probe_container_sockets.py"
setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте)
@$(PYTHON) "$(SETUP_ENV_SCRIPT)"
@@ -78,27 +126,27 @@ setup: ## Интерактивно создать .env (scripts/setup_env_intera
clusters-dir: ## Каталог clusters/ для тома (если ещё нет)
@mkdir -p "$(KIND_K8S_DIR)/clusters"
check-docker: _require_runtime ## (с docker/podman) Проверить CLI и compose
check-docker: _require_runtime _podman_env_override ## (с docker/podman) Проверить CLI и compose
@case "$(COMPOSE)" in \
docker*) command -v docker >/dev/null 2>&1 || { echo >&2 "docker не найден в PATH."; exit 1; } ;; \
podman*) command -v podman >/dev/null 2>&1 || { echo >&2 "podman не найден в PATH."; exit 1; } ;; \
esac
@$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; }
@$(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; }
@echo "$(COMPOSE): OK"
# kubectl в образе; путь к kubeconfig — как у веб-UI (подстановка server через host.docker.internal, см. kubeconfig_patch.py).
kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"]
kubectl: _require_runtime _podman_env_override ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"]
@if [ -z "$(CLUSTER)" ]; then \
echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \
echo >&2 "Пример: make docker kubectl CLUSTER=dev"; \
echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \
exit 1; \
fi
cd "$(KIND_K8S_DIR)" && KC=$$($(COMPOSE) exec -T kind-k8s-web python3 scripts/effective_kubeconfig_path.py $(CLUSTER) | tr -d '\r') && \
$(COMPOSE) exec kind-k8s-web kubectl --kubeconfig="$$KC" $(KUBECTL_ARGS)
cd "$(KIND_K8S_DIR)" && KC=$$($(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) exec -T kind-k8s-web python3 scripts/effective_kubeconfig_path.py $(CLUSTER) | tr -d '\r') && \
$(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) exec kind-k8s-web kubectl --kubeconfig="$$KC" $(KUBECTL_ARGS)
build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS)
build: _require_runtime _podman_env_override clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local
cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) build $(COMPOSE_BUILD_FLAGS)
rebuild: _require_runtime clusters-dir ## (с docker/podman) Пересобрать образ без кэша и пересоздать контейнер kind-k8s-web
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build --no-cache $(COMPOSE_BUILD_FLAGS) && $(COMPOSE) up -d --force-recreate kind-k8s-web
rebuild: _require_runtime _podman_env_override clusters-dir ## (с docker/podman) Пересобрать образ без кэша и пересоздать контейнер kind-k8s-web
cd "$(KIND_K8S_DIR)" && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) build --no-cache $(COMPOSE_BUILD_FLAGS) && $(COMPOSE_PODMAN_ENV) $(COMPOSE_DOCKER_OVERRIDES) $(COMPOSE) $(COMPOSE_FILE_ARGS) $(PODMAN_COMPOSE_ENV_FILE) up -d --force-recreate kind-k8s-web
+13 -5
View File
@@ -11,7 +11,7 @@
| **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) |
| **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) |
Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`).
Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`; в начале — только выбор **docker** или **podman**, путь **`CONTAINER_SOCKET`** подставляется автоматически).
## Зачем это нужно
@@ -25,8 +25,8 @@
- Верхняя **единая карточка**: заголовок, краткое описание и строка **состояния среды** (`kind` / `kubectl` / Docker или Podman API).
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление.
- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx** (версия чарта, NodePort 30080 для kind), **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (логин/пароль Kiali). Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с).
- **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание kubeconfig, модалки узлов/подов, ссылка на страницу кластера; с **панели** (`/`) — быстрый переход по ссылке в карточке «Создать кластер».
- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx**, **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (логин/пароль Kiali); журнал операции на странице (прогресс + вывод как при создании кластера), история в **`clusters/<имя>/helm_addon_log.json`**. Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с).
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
@@ -145,9 +145,13 @@ docker compose exec kind-k8s-web kubectl --kubeconfig=/work/clusters/<имя>/ku
| **`KIND_K8S_APP_TITLE`** | контейнер / Settings | Заголовок OpenAPI и HTML; пустое значение из compose не ломает приложение (`env_ignore_empty`, fallback) |
| **`KIND_K8S_WAIT_NODES`** | контейнер | `0` — не ждать Ready нод после create |
| **`KIND_K8S_WAIT_NODES_TIMEOUT_SEC`** | контейнер | Таймаут `kubectl wait` (секунды) |
| **`CONTAINER_SOCKET`** | volume | Сокет Docker/Podman на хосте |
| **`CONTAINER_SOCKET`** | volume | Путь к сокету **на хосте**; при **`make podman …`** перед compose пишется **`.env.podman.override`** (в **`.gitignore`**) с актуальным путём из **`scripts/detect_podman_socket.py`**, чтобы перекрыть **`.env`**: иначе старый **`docker-compose`** часто подставляет том из **`.env`** (в т.ч. неверный сокет **`/var/folders/…/podman-machine-default-api.sock`**) и падает с **operation not supported** |
| **`CONTAINER_SOCKET_MOUNT_TARGET`** | volume | Путь **внутри** контейнера: Docker — `/var/run/docker.sock`; Podman — **`/run/podman/podman.sock`** |
| **`KIND_K8S_REMOTE_SOCKET_URI`** | контейнер `DOCKER_HOST` | URI API (совпадает с точкой монтирования), например **`unix:///run/podman/podman.sock`** для Podman |
| **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig для хоста; по умолчанию **включено** (`1` в compose и в `make setup`) |
| **`CONTAINER_CLI`** | контейнер | CLI для `docker port` / `podman port` (`docker` или `podman`) |
| **`KIND_K8S_CONTAINER_UIDGID`** | compose `user` | **`uid:gid`** процесса в контейнере; для Docker обычно **`0:0`**; для rootless Podman — **`$(id -u):$(id -g)`** (пишет **`make setup`**) |
| **`KIND_K8S_CONTAINER_HOME`** | контейнер `HOME` | Для не-root в образе без `/home/<uid>`: **`/tmp`** (Podman); для root — **`/root`** |
| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами |
| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк показывать в **интерактивном CLI** при выборе версии (веб-UI выводит полный список из API) |
| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Сколько страниц Docker Hub обходить при сборе тегов (старые 1.19.x часто на поздних страницах; в коде по умолчанию **120**, максимум **500**) |
@@ -168,16 +172,20 @@ export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock"
make podman up
```
**Доступ к сокету (`permission denied` на `/var/run/docker.sock` внутри контейнера):** у rootless Podman сокет обычно принадлежит вашему пользователю, а не «root» из контейнера. В **`.env`** для Podman нужны **`KIND_K8S_CONTAINER_UIDGID=$(id -u):$(id -g)`** и **`KIND_K8S_CONTAINER_HOME=/tmp`** — при **`make setup`** с выбором **Podman** скрипт записывает их сам. Команды **`make podman …`** подмешивают **`docker-compose.podman.yml`** (`userns_mode: keep-id`) и дублируют uid/gid в окружении: без **keep-id** rootless Podman часто даёт **permission denied** на смонтированный сокет даже при верном `user:`. Вручную: **`podman compose -f docker-compose.yml -f docker-compose.podman.yml up -d`** и те же переменные в **`.env`** или в shell. На системах с SELinux при отказе доступа к сокету попробуйте **`CONTAINER_SOCKET_VOLUME_OPTS=:Z`** в `.env`.
## Структура репозитория (основное)
| Путь | Назначение |
|------|------------|
| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен; цели `up`, `down`, `restart`, `logs`, `rebuild`, `build` и др. |
| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен; цели `up`, `down`, `restart`, `logs`, `rebuild`, `build` и др. Для Podman: **`--env-file .env.podman.override`** (нужен compose с поддержкой **`--env-file`**, обычно ≥ 1.28). |
| `.env.podman.override` | Создаётся целью **`make podman …`**: `CONTAINER_SOCKET`, `KIND_K8S_*` для сокета; перекрывает устаревшие значения в **`.env`**. |
| `scripts/setup_env_interactive.py` | Интерактивное создание `.env` (все ключи и дефолты внутри скрипта) |
| `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` |
| `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI |
| `requirements.txt` | pip-зависимости веб-приложения |
| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет |
| `docker-compose.podman.yml` | Только для **Podman**: `userns_mode: keep-id` (подмешивается в **make podman …**) |
| `app/main.py` | FastAPI: главная `/`, создание кластера `/cluster-create`, `/documentation`, редирект `/ui`, монтирование `/static` |
| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) |
| `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
+187 -46
View File
@@ -1,6 +1,8 @@
"""Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali.
Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``).
После каждой операции в ``clusters/<имя>/helm_addon_log.json`` **добавляется** новая запись в массив ``entries``
(история сохраняется, лимит ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -10,7 +12,11 @@ from __future__ import annotations
import asyncio
import logging
import uuid
from collections.abc import Callable
from typing import Any
import yaml
from fastapi import APIRouter, Body, HTTPException
from core.cluster_lifecycle import validate_cluster_name
@@ -28,6 +34,7 @@ from core.helm_addons import (
uninstall_metrics_server,
)
from core.kind_guard import kind_cluster_lock
from core.provision_log import write_cluster_helm_addon_log
from models.schemas import (
ClusterAddonsStatusResponse,
HelmAddonApplyResponse,
@@ -57,6 +64,79 @@ def _map_helm_error(e: HelmAddonError) -> HTTPException:
return HTTPException(status_code=500, detail=msg)
def _parse_optional_values_yaml(raw: str | None) -> dict[str, Any] | None:
"""
Разбор опционального пользовательского YAML для Helm values.
Пустая строка и ``null`` → ``None``. Корень документа должен быть mapping (объект YAML),
иначе 400. Используется :func:`yaml.safe_load`.
"""
if raw is None:
return None
s = raw.strip()
if not s:
return None
try:
data = yaml.safe_load(s)
except yaml.YAMLError as e:
raise HTTPException(
status_code=400,
detail=f"Некорректный YAML в values_yaml: {e}",
) from e
if data is None:
return None
if not isinstance(data, dict):
raise HTTPException(
status_code=400,
detail="values_yaml: в корне ожидается объект YAML (mapping), не список и не скаляр.",
)
return data
async def _run_helm_with_journal(
cluster_name: str,
job_kind: str,
result_meta: dict[str, Any],
sync_call: Callable[[], tuple[bool, str]],
) -> str:
"""
Выполнить операцию под блокировкой kind, добавить запись в историю ``helm_addon_log.json``.
При ошибке запись всё равно добавляется со статусом ``failed``.
"""
log = ""
try:
async with kind_cluster_lock:
_, log = await asyncio.to_thread(sync_call)
await asyncio.to_thread(
write_cluster_helm_addon_log,
cluster_name=cluster_name,
job_id=str(uuid.uuid4()),
job_kind=job_kind,
status="success",
message=None,
lines=log.splitlines() if log else [],
result=result_meta,
)
return log
except HelmAddonError as e:
msg = str(e)
lines = log.splitlines() if log else []
if msg and msg not in lines:
lines = [msg] + lines
await asyncio.to_thread(
write_cluster_helm_addon_log,
cluster_name=cluster_name,
job_id=str(uuid.uuid4()),
job_kind=job_kind,
status="failed",
message=msg,
lines=lines,
result=result_meta,
)
raise
@router.get(
"/clusters/{name}/addons/status",
response_model=ClusterAddonsStatusResponse,
@@ -102,11 +182,17 @@ async def post_install_ingress_nginx(
ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_ingress_nginx, n, ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "ingress-nginx", "action": "install", "chart_version": ver}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_ingress_nginx",
meta,
lambda: install_ingress_nginx(n, ver, user_v),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен ingress-nginx для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="ingress-nginx установлен.", log=_trim_log(log))
@@ -120,11 +206,16 @@ async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_ingress_nginx, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
meta = {"addon": "ingress-nginx", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_ingress_nginx",
meta,
lambda: uninstall_ingress_nginx(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log))
@@ -143,17 +234,29 @@ async def post_install_kube_prometheus_stack(
prom_ver: str | None = None
if body.chart_version and body.chart_version.strip():
prom_ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_kube_prometheus_stack,
u = body.grafana_admin_user.strip()
user_v = _parse_optional_values_yaml(body.values_yaml)
meta = {
"addon": "kube-prometheus-stack",
"action": "install",
"chart_version": prom_ver,
"grafana_admin_user": u,
}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_kube_prometheus_stack",
meta,
lambda: install_kube_prometheus_stack(
n,
grafana_admin_user=body.grafana_admin_user.strip(),
grafana_admin_user=u,
grafana_admin_password=body.grafana_admin_password,
chart_version=prom_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
user_values=user_v,
),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен kube-prometheus-stack для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack установлен.", log=_trim_log(log))
@@ -167,11 +270,16 @@ async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_kube_prometheus_stack, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
meta = {"addon": "kube-prometheus-stack", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_kube_prometheus_stack",
meta,
lambda: uninstall_kube_prometheus_stack(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log))
@@ -190,11 +298,17 @@ async def post_install_metrics_server(
ms_ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ms_ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_metrics_server, n, ms_ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
meta = {"addon": "metrics-server", "action": "install", "chart_version": ms_ver}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_metrics_server",
meta,
lambda: install_metrics_server(n, ms_ver, user_v),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен metrics-server для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="metrics-server установлен.", log=_trim_log(log))
@@ -208,11 +322,16 @@ async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_metrics_server, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
meta = {"addon": "metrics-server", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_metrics_server",
meta,
lambda: uninstall_metrics_server(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log))
@@ -234,18 +353,35 @@ async def post_install_istio_kiali(
kiali_ver: str | None = None
if body.kiali_chart_version and body.kiali_chart_version.strip():
kiali_ver = body.kiali_chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_istio_and_kiali,
ku = body.kiali_username.strip()
kiali_uv = _parse_optional_values_yaml(body.values_yaml)
istio_base_v = _parse_optional_values_yaml(body.istio_base_values_yaml)
istiod_v = _parse_optional_values_yaml(body.istio_istiod_values_yaml)
meta = {
"addon": "istio-kiali",
"action": "install",
"istio_chart_version": istio_ver,
"kiali_chart_version": kiali_ver,
"kiali_username": ku,
}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_install_istio_kiali",
meta,
lambda: install_istio_and_kiali(
n,
kiali_username=body.kiali_username.strip(),
kiali_username=ku,
kiali_password=body.kiali_password,
istio_chart_version=istio_ver,
kiali_chart_version=kiali_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
istio_base_values=istio_base_v,
istiod_values=istiod_v,
kiali_user_values=kiali_uv,
),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлены Istio+Kiali для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="Istio и Kiali установлены.", log=_trim_log(log))
@@ -259,9 +395,14 @@ async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_istio_and_kiali, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
meta = {"addon": "istio-kiali", "action": "delete"}
try:
log = await _run_helm_with_journal(
n,
"helm_addon_delete_istio_kiali",
meta,
lambda: uninstall_istio_and_kiali(n),
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log))
+2
View File
@@ -52,6 +52,7 @@ from core.container_resource_stats import (
collect_single_cluster_resource_stats,
running_kind_clusters_mask,
)
from core.helm_addons import HELM_INSTALLABLE_ADDON_IDS
from core.job_store import (
JobRecord,
end_job_tracking,
@@ -198,6 +199,7 @@ def _stats_sync() -> StatsResponse:
total_workers_from_meta=total_workers,
jobs_total=len(jobs),
jobs_recent_failed=failed,
helm_addons_installable_count=len(HELM_INSTALLABLE_ADDON_IDS),
cluster_resources=res_blocks,
cluster_resources_error=res_err,
aggregate_cluster_resources=agg,
+116 -3
View File
@@ -1,4 +1,4 @@
"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``).
"""Справочник версий Helm-чартов и сборка YAML values для редактора на странице аддонов.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -11,14 +11,76 @@ import logging
from fastapi import APIRouter, HTTPException
from core.helm_addons import HelmAddonError, helm_addon_chart_versions_all, require_helm_binary
from models.schemas import HelmAddonChartVersionsResponse
from core.helm_addons import (
HelmAddonError,
compose_ingress_effective_values,
compose_istio_mesh_preview_values,
compose_kube_prometheus_effective_values,
compose_metrics_server_effective_values,
dump_helm_values_yaml,
helm_addon_chart_versions_all,
helm_repo_ensure,
require_helm_binary,
)
from models.schemas import (
HelmAddonChartVersionsResponse,
HelmAddonComposeBatchRequest,
HelmAddonComposeBatchResponse,
HelmAddonComposeValuesRequest,
HelmAddonComposeValuesResponse,
)
logger = logging.getLogger("kind_k8s.api.helm_charts")
router = APIRouter(tags=["helm-charts"])
def _compose_values_sync(req: HelmAddonComposeValuesRequest) -> HelmAddonComposeValuesResponse:
"""Синхронная сборка YAML (вызов из ``asyncio.to_thread``): ``helm show values`` + оверлеи."""
helm_repo_ensure()
addon = req.addon
if addon == "ingress-nginx":
data = compose_ingress_effective_values(req.chart_version)
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
if addon == "kube-prometheus-stack":
u = (req.grafana_admin_user or "admin").strip() or "admin"
pw = (req.grafana_admin_password or "").strip() or "ChangeMe12345"
data = compose_kube_prometheus_effective_values(req.chart_version, u, pw)
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
if addon == "metrics-server":
data = compose_metrics_server_effective_values(req.chart_version)
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
if addon == "istio-kiali":
k, ib, ir = compose_istio_mesh_preview_values(req.istio_chart_version, req.kiali_chart_version)
return HelmAddonComposeValuesResponse(
values_yaml=dump_helm_values_yaml(k),
istio_base_values_yaml=dump_helm_values_yaml(ib),
istio_istiod_values_yaml=dump_helm_values_yaml(ir),
)
raise RuntimeError(f"неизвестный addon: {addon}")
def _compose_batch_sync(body: HelmAddonComposeBatchRequest) -> HelmAddonComposeBatchResponse:
"""Один ``helm repo update`` и шесть выводов ``helm show values`` (с оверлеями где нужно)."""
helm_repo_ensure()
u = (body.grafana_admin_user or "admin").strip() or "admin"
pw = (body.grafana_admin_password or "").strip() or "ChangeMe12345"
ing = dump_helm_values_yaml(compose_ingress_effective_values(body.ingress_chart_version))
prom = dump_helm_values_yaml(
compose_kube_prometheus_effective_values(body.kube_prometheus_chart_version, u, pw),
)
ms = dump_helm_values_yaml(compose_metrics_server_effective_values(body.metrics_server_chart_version))
k, ib, ir = compose_istio_mesh_preview_values(body.istio_chart_version, body.kiali_chart_version)
return HelmAddonComposeBatchResponse(
ingress_nginx_values_yaml=ing,
kube_prometheus_values_yaml=prom,
metrics_server_values_yaml=ms,
kiali_values_yaml=dump_helm_values_yaml(k),
istio_base_values_yaml=dump_helm_values_yaml(ib),
istio_istiod_values_yaml=dump_helm_values_yaml(ir),
)
@router.get(
"/helm/chart-versions",
response_model=HelmAddonChartVersionsResponse,
@@ -42,3 +104,54 @@ async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse:
logger.exception("Ошибка получения версий чартов")
raise HTTPException(status_code=500, detail=str(e)) from e
return HelmAddonChartVersionsResponse(**data)
@router.post(
"/helm/addons/compose-values",
response_model=HelmAddonComposeValuesResponse,
summary="Собрать YAML values для редактора аддонов (helm show values + форма)",
)
async def post_helm_addon_compose_values(
body: HelmAddonComposeValuesRequest,
) -> HelmAddonComposeValuesResponse:
"""
Выполняет ``helm repo update`` (внутри compose-функций) и ``helm show values`` для выбранного аддона.
Для **kube-prometheus-stack** в YAML подставляются логин и пароль Grafana из тела запроса;
если пароль пуст, в preview используется временная строка ``ChangeMe12345`` (замените перед установкой).
Для **istio-kiali** возвращаются три блока YAML: **kiali-server**, **istio/base**, **istio/istiod**.
"""
try:
require_helm_binary()
except HelmAddonError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
try:
return await asyncio.to_thread(_compose_values_sync, body)
except HelmAddonError as e:
logger.exception("compose-values: ошибка helm")
raise HTTPException(status_code=500, detail=str(e)) from e
@router.post(
"/helm/addons/compose-values-batch",
response_model=HelmAddonComposeBatchResponse,
summary="Собрать все YAML values для страницы аддонов одним запросом",
)
async def post_helm_addon_compose_values_batch(
body: HelmAddonComposeBatchRequest,
) -> HelmAddonComposeBatchResponse:
"""
Один проход **``helm repo update``** и подстановка версий из селектов / полей формы на странице аддонов.
Удобнее, чем четыре отдельных **compose-values**, когда UI обновляет все карточки сразу.
"""
try:
require_helm_binary()
except HelmAddonError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
try:
return await asyncio.to_thread(_compose_batch_sync, body)
except HelmAddonError as e:
logger.exception("compose-values-batch: ошибка helm")
raise HTTPException(status_code=500, detail=str(e)) from e
+85 -4
View File
@@ -1,4 +1,4 @@
"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``.
"""Чтение журналов: задания (jobs_history), развёртывание (provision_log), Helm-аддоны (helm_addon_log).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -13,7 +13,18 @@ from fastapi import APIRouter, HTTPException, Query
from core.cluster_lifecycle import validate_cluster_name
from core.job_journal import collect_recent_journal_entries_page_sync, read_cluster_journal_sync
from models.schemas import ClusterJournalResponse, JournalEntryModel, JournalRecentResponse
from core.provision_log import (
HELM_ADDON_LOG_FILENAME,
PROVISION_LOG_FILENAME,
collect_cluster_dir_logs_page_sync,
)
from models.schemas import (
ClusterDirLogEntryModel,
ClusterJournalResponse,
JournalEntryModel,
JournalPagedDirLogsResponse,
JournalRecentResponse,
)
logger = logging.getLogger("kind_k8s.api.journal")
@@ -26,20 +37,52 @@ def _journal_total_pages(total: int, limit: int) -> int:
return (total + limit - 1) // limit
def _build_paged_dir_response(
rows: list[dict],
*,
limit: int,
offset: int,
total: int,
) -> JournalPagedDirLogsResponse:
total_pages = _journal_total_pages(total, limit)
if total <= 0:
page = 1
else:
page = min((offset // limit) + 1, total_pages)
page = max(1, page)
entries = [ClusterDirLogEntryModel.model_validate(r) for r in rows]
return JournalPagedDirLogsResponse(
limit=limit,
offset=offset,
total=total,
page=page,
total_pages=total_pages,
entries=entries,
)
@router.get(
"/journal/recent",
response_model=JournalRecentResponse,
summary="Страница журнала по всем кластерам (пагинация)",
summary="Страница журнала заданий (jobs_history), опционально один кластер",
)
async def get_journal_recent(
limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"),
offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"),
cluster: str | None = Query(
default=None,
description="Имя кластера: только записи из clusters/<имя>/journal/jobs_history.json",
),
) -> JournalRecentResponse:
"""Сборка из ``clusters/*/journal/jobs_history.json``, новые первыми; ``total`` и ``total_pages`` для UI."""
"""Сборка из ``journal/jobs_history.json``; при ``cluster`` — только выбранный каталог."""
c = cluster.strip() if cluster else None
if c and not validate_cluster_name(c):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
rows, total = await asyncio.to_thread(
collect_recent_journal_entries_page_sync,
limit=limit,
offset=offset,
cluster_name=c,
)
entries = [JournalEntryModel.model_validate(r) for r in rows]
total_pages = _journal_total_pages(total, limit)
@@ -58,6 +101,44 @@ async def get_journal_recent(
)
@router.get(
"/journal/provision",
response_model=JournalPagedDirLogsResponse,
summary="Журналы развёртывания по кластерам (provision_log.json)",
)
async def get_journal_provision_logs(
limit: int = Query(default=30, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> JournalPagedDirLogsResponse:
"""По одному последнему ``provision_log.json`` на кластер, новые первыми."""
rows, total = await asyncio.to_thread(
collect_cluster_dir_logs_page_sync,
filename=PROVISION_LOG_FILENAME,
limit=limit,
offset=offset,
)
return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total)
@router.get(
"/journal/helm-addons",
response_model=JournalPagedDirLogsResponse,
summary="Журналы установки Helm-аддонов (helm_addon_log.json)",
)
async def get_journal_helm_addon_logs(
limit: int = Query(default=30, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> JournalPagedDirLogsResponse:
"""Все записи из ``helm_addon_log.json`` по кластерам (история операций), новые первыми."""
rows, total = await asyncio.to_thread(
collect_cluster_dir_logs_page_sync,
filename=HELM_ADDON_LOG_FILENAME,
limit=limit,
offset=offset,
)
return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total)
@router.get(
"/clusters/{name}/journal",
response_model=ClusterJournalResponse,
+10 -8
View File
@@ -22,7 +22,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from kind_k8s_paths import clusters_dir, data_root
from kind_k8s_paths import clusters_dir, container_cli_name, data_root
from kindest_node_tags import normalize_tag_v_prefix
from kubeconfig_patch import (
kubeconfig_host_file,
@@ -504,13 +504,15 @@ def create_cluster_non_interactive(
if not use_existing_config and (workers < 0 or workers > 20):
raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
node_image = f"kindest/node:{ver_tag}"
# Каталог данных кластера до долгих шагов — чтобы при сбое журнал в journal/jobs_history.json
# и provision_log могли быть привязаны к существующему clusters/<имя>/.
root = data_root()
cdir = clusters_dir()
out_dir = cdir / name
out_dir.mkdir(parents=True, exist_ok=True)
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
node_image = f"kindest/node:{ver_tag}"
cfg_path = out_dir / "kind-config.yaml"
kube_path = out_dir / "kubeconfig"
meta_path = out_dir / "meta.json"
@@ -912,14 +914,14 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None:
def _container_cli_bin() -> str:
"""Имя CLI к сокету (docker / podman), как в kubeconfig_patch."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
"""Имя CLI к сокету: см. :func:`kind_k8s_paths.container_cli_name`."""
return container_cli_name()
def configured_container_cli() -> str:
"""Активное имя CLI из ``CONTAINER_CLI`` (docker/podman).
"""Имя CLI, которое реально вызывает процесс (в контейнере веб-UI — всегда ``docker``).
Единый источник для проверки движка, сбора ``docker stats``/``podman stats`` и поля ``container_cli`` в GET /stats.
Единый источник для ``docker stats``/``podman stats``, ``kind``, health и поля ``container_cli`` в API.
"""
return _container_cli_bin()
+2 -2
View File
@@ -9,12 +9,12 @@
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters
from kind_k8s_paths import container_cli_name
from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat
logger = logging.getLogger("kind_k8s.container_resource_stats")
@@ -24,7 +24,7 @@ _STAT_FORMAT = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}
def _container_cli_bin() -> str:
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
return container_cli_name()
def _list_running_container_names(cli: str) -> set[str]:
+259 -58
View File
@@ -37,6 +37,15 @@ HELM_REPOS: dict[str, str] = {
"kiali": "https://kiali.org/helm-charts",
}
# Идентификаторы аддонов, доступных для установки на странице «Аддоны» и через API ``/clusters/{name}/addons/…``.
# Должен совпадать с набором в ``HelmComposeValuesRequest.addon`` и кнопками в ``cluster_addons.html``.
HELM_INSTALLABLE_ADDON_IDS: tuple[str, ...] = (
"ingress-nginx",
"kube-prometheus-stack",
"metrics-server",
"istio-kiali",
)
# Ссылки для ``helm search repo <ref> --versions`` (ключи совпадают с полями API версий чартов).
HELM_ADDON_CHART_REFS: dict[str, str] = {
"ingress_nginx": "ingress-nginx/ingress-nginx",
@@ -59,6 +68,121 @@ class HelmAddonError(Exception):
self.exit_code = exit_code
def dump_helm_values_yaml(data: dict[str, Any]) -> str:
"""Сериализация mapping в YAML для UI и временных файлов Helm."""
return yaml.safe_dump(
data,
allow_unicode=True,
default_flow_style=False,
sort_keys=False,
)
def helm_show_values_dict(chart_ref: str, chart_version: str | None = None) -> dict[str, Any]:
"""
Словарь дефолтных values чарта: ``helm show values <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:
raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip()
try:
@@ -283,6 +407,7 @@ def _helm_upgrade_install(
chart_version: str | None = None,
extra_args: list[str] | None = None,
values_file: Path | None = None,
extra_values_files: list[Path] | None = None,
) -> tuple[int, str, str]:
kcfg = str(_kubeconfig_runtime_path(cluster_name))
cmd = [
@@ -305,6 +430,8 @@ def _helm_upgrade_install(
cmd.extend(["--version", ver])
if values_file is not None:
cmd.extend(["-f", str(values_file)])
for p in extra_values_files or []:
cmd.extend(["-f", str(p)])
if extra_args:
cmd.extend(extra_args)
return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60)
@@ -321,23 +448,41 @@ def _helm_uninstall(cluster_name: str, release: str, namespace: str) -> tuple[in
# --- Установка / удаление по типам ---
def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple[bool, str]:
"""ingress-nginx с NodePort для kind."""
def install_ingress_nginx(
cluster_name: str,
chart_version: str | None,
user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""
ingress-nginx: полный YAML values из UI (``helm show values`` + NodePort) или переданный словарь;
после файла values остаются ``--set`` для NodePort (приоритет как у Helm).
"""
helm_repo_ensure()
eff = user_values if user_values is not None else compose_ingress_effective_values(chart_version)
extra: list[str] = [
"--set",
"controller.service.type=NodePort",
"--set",
"controller.service.nodePorts.http=30080",
]
rc, out, err = _helm_upgrade_install(
cluster_name,
"ingress-nginx",
"ingress-nginx/ingress-nginx",
"ingress-nginx",
chart_version=chart_version,
extra_args=extra,
)
user_tmp: Path | None = None
try:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-ing-values-")
os.close(fd)
user_tmp = Path(name)
user_tmp.write_text(dump_helm_values_yaml(eff), encoding="utf-8")
rc, out, err = _helm_upgrade_install(
cluster_name,
"ingress-nginx",
"ingress-nginx/ingress-nginx",
"ingress-nginx",
chart_version=chart_version,
extra_args=extra,
extra_values_files=[user_tmp],
)
finally:
if user_tmp is not None:
user_tmp.unlink(missing_ok=True)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
@@ -358,18 +503,23 @@ def install_kube_prometheus_stack(
grafana_admin_user: str,
grafana_admin_password: str,
chart_version: str | None = None,
user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""Полный values из UI или автосборка (``helm show values`` + Grafana из формы)."""
helm_repo_ensure()
values = {
"grafana": {
"adminUser": grafana_admin_user,
"adminPassword": grafana_admin_password,
}
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
if user_values is not None:
values = user_values
else:
values = compose_kube_prometheus_effective_values(
chart_version,
grafana_admin_user,
grafana_admin_password,
)
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="helm-prom-stack-")
os.close(fd)
tmp_path = Path(tmp_name)
try:
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
rc, out, err = _helm_upgrade_install(
cluster_name,
"kube-prometheus-stack",
@@ -394,18 +544,22 @@ def uninstall_kube_prometheus_stack(cluster_name: str) -> tuple[bool, str]:
return True, text or "kube-prometheus-stack удалён (или отсутствовал)."
def install_metrics_server(cluster_name: str, chart_version: str | None = None) -> tuple[bool, str]:
def install_metrics_server(
cluster_name: str,
chart_version: str | None = None,
user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""Полный values из UI или автосборка (чарт + args для kind)."""
helm_repo_ensure()
values = {
"args": [
"--kubelet-insecure-tls",
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP",
]
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
if user_values is not None:
values = user_values
else:
values = compose_metrics_server_effective_values(chart_version)
fd_m, tmp_ms = tempfile.mkstemp(suffix=".yaml", prefix="helm-metrics-")
os.close(fd_m)
tmp_path = Path(tmp_ms)
try:
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
rc, out, err = _helm_upgrade_install(
cluster_name,
"metrics-server",
@@ -437,8 +591,16 @@ def install_istio_and_kiali(
kiali_password: str,
istio_chart_version: str | None = None,
kiali_chart_version: str | None = None,
istio_base_values: dict[str, Any] | None = None,
istiod_values: dict[str, Any] | None = None,
kiali_user_values: dict[str, Any] | None = None,
) -> tuple[bool, str]:
"""Istio base + istiod + секрет входа Kiali + chart kiali-server (strategy=login)."""
"""
Istio base + istiod (опциональные values из UI) + секрет Kiali + kiali-server.
Для каждого чарта: если словарь ``None`` — без файла ``-f`` (дефолты чарта);
если непустой dict — полный values как в редакторе.
"""
helm_repo_ensure()
_ensure_namespace(cluster_name, "istio-system")
@@ -446,27 +608,53 @@ def install_istio_and_kiali(
istio_ver = (istio_chart_version or "").strip() or None
kiali_ver = (kiali_chart_version or "").strip() or None
rc, out, err = _helm_upgrade_install(
cluster_name,
"istio-base",
"istio/base",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(out or err)
if rc != 0:
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
base_tmp: Path | None = None
try:
base_file: Path | None = None
if istio_base_values:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istio-base-")
os.close(fd)
base_tmp = Path(name)
base_tmp.write_text(dump_helm_values_yaml(istio_base_values), encoding="utf-8")
base_file = base_tmp
rc, out, err = _helm_upgrade_install(
cluster_name,
"istio-base",
"istio/base",
"istio-system",
chart_version=istio_ver,
values_file=base_file,
)
log_parts.append(out or err)
if rc != 0:
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
finally:
if base_tmp is not None:
base_tmp.unlink(missing_ok=True)
rc2, o2, e2 = _helm_upgrade_install(
cluster_name,
"istiod",
"istio/istiod",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(o2 or e2)
if rc2 != 0:
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
istiod_tmp: Path | None = None
try:
istiod_file: Path | None = None
if istiod_values:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istiod-")
os.close(fd)
istiod_tmp = Path(name)
istiod_tmp.write_text(dump_helm_values_yaml(istiod_values), encoding="utf-8")
istiod_file = istiod_tmp
rc2, o2, e2 = _helm_upgrade_install(
cluster_name,
"istiod",
"istio/istiod",
"istio-system",
chart_version=istio_ver,
values_file=istiod_file,
)
log_parts.append(o2 or e2)
if rc2 != 0:
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
finally:
if istiod_tmp is not None:
istiod_tmp.unlink(missing_ok=True)
# Секрет для стратегии login (ключи username / passphrase — ожидание Kiali).
secret = {
@@ -484,14 +672,27 @@ def install_istio_and_kiali(
raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs)
log_parts.append(msgs)
rc3, o3, e3 = _helm_upgrade_install(
cluster_name,
"kiali-server",
"kiali/kiali-server",
"istio-system",
chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"],
)
kiali_extra: list[Path] = []
kiali_tmp: Path | None = None
try:
if kiali_user_values:
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-kiali-user-")
os.close(fd)
kiali_tmp = Path(name)
kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8")
kiali_extra.append(kiali_tmp)
rc3, o3, e3 = _helm_upgrade_install(
cluster_name,
"kiali-server",
"kiali/kiali-server",
"istio-system",
chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"],
extra_values_files=kiali_extra or None,
)
finally:
if kiali_tmp is not None:
kiali_tmp.unlink(missing_ok=True)
log_parts.append(o3 or e3)
if rc3 != 0:
raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3)
+46 -9
View File
@@ -82,9 +82,18 @@ def append_job_from_record_sync(
return
cdir = clusters_dir() / name
# Страница «Журнал» читает clusters/<имя>/journal/jobs_history.json — нужен каталог кластера.
# После отмены create_cluster каталог мог быть удалён (rmtree) — не создаём пустой каталог заново.
if not cdir.is_dir():
logger.debug("Журнал кластера: каталог %s отсутствует", cdir)
return
if (status or "").lower() == "cancelled":
logger.debug("Журнал кластера: каталог %s удалён при отмене — запись jobs_history пропускаем", cdir)
return
try:
cdir.mkdir(parents=True, exist_ok=True)
logger.info("Журнал: создан каталог кластера для записи jobs_history: %s", cdir)
except OSError as e:
logger.warning("Журнал кластера: не удалось создать %s: %s", cdir, e)
return
jdir = cdir / JOURNAL_SUBDIR
finished = datetime.now(timezone.utc).isoformat()
@@ -185,21 +194,49 @@ def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]:
return flat
def collect_recent_journal_entries_page_sync(*, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
def collect_recent_journal_entries_page_sync(
*,
limit: int,
offset: int,
cluster_name: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
"""
Страница записей из всех ``clusters/*/journal/jobs_history.json``, новые первыми.
Страница записей из ``journal/jobs_history.json``, новые первыми.
В каждую запись добавляется поле ``source_cluster`` (имя каталога).
Возвращает ``(страница записей, общее число записей)``.
Если ``cluster_name`` задан — только этот кластер (файл на диске), иначе объединение по всем кластерам.
В каждую запись добавляется поле ``source_cluster``.
"""
lim = max(1, min(limit, 100))
off = max(0, offset)
if cluster_name and str(cluster_name).strip():
n = str(cluster_name).strip()
if not validate_cluster_name(n):
return [], 0
raw = read_cluster_journal_sync(n)
if not raw or not isinstance(raw.get("entries"), list):
return [], 0
entries = [e for e in raw["entries"] if isinstance(e, dict)]
def _entry_time_key(e: dict[str, Any]) -> str:
return str(e.get("finished_at_utc") or e.get("created_at_utc") or "")
entries.sort(key=_entry_time_key, reverse=True)
total = len(entries)
chunk = entries[off : off + lim]
out: list[dict[str, Any]] = []
for e in chunk:
row = dict(e)
row["source_cluster"] = n
out.append(row)
return out, total
flat = _collect_all_journal_rows_sorted_sync()
total = len(flat)
chunk = flat[off : off + lim]
out: list[dict[str, Any]] = []
out2: list[dict[str, Any]] = []
for cluster_dir_name, e in chunk:
row = dict(e)
row["source_cluster"] = cluster_dir_name
out.append(row)
return out, total
out2.append(row)
return out2, total
+189
View File
@@ -1,5 +1,8 @@
"""Сохранение полного журнала развёртывания кластера в ``clusters/<имя>/provision_log.json``.
Журнал операций Helm-аддонов — ``helm_addon_log.json``: **история** операций (массив ``entries``),
старый формат «один объект в файле» при чтении мигрируется автоматически.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
@@ -8,10 +11,13 @@ from __future__ import annotations
import json
import logging
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from core.cluster_lifecycle import validate_cluster_name
from kind_k8s_paths import clusters_dir
logger = logging.getLogger("kind_k8s.provision_log")
@@ -19,12 +25,42 @@ logger = logging.getLogger("kind_k8s.provision_log")
PROVISION_LOG_FILENAME = "provision_log.json"
PROVISION_LOG_VERSION = 1
HELM_ADDON_LOG_FILENAME = "helm_addon_log.json"
"""Имя файла истории Helm; корневой объект с полем ``entries`` (версия файла 2)."""
HELM_ADDON_LOG_VERSION = 1
"""Версия схемы одной записи внутри ``entries``."""
HELM_ADDON_LOG_FILE_VERSION = 2
"""Версия обёртки файла (список ``entries``)."""
_locks_guard = threading.Lock()
_helm_log_locks: dict[str, threading.Lock] = {}
def _helm_cluster_lock(cluster_name: str) -> threading.Lock:
with _locks_guard:
if cluster_name not in _helm_log_locks:
_helm_log_locks[cluster_name] = threading.Lock()
return _helm_log_locks[cluster_name]
def _max_helm_addon_entries() -> int:
raw = (os.environ.get("KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES") or "500").strip()
try:
return max(20, min(int(raw), 5000))
except ValueError:
return 500
def provision_log_file_path(cluster_name: str) -> Path:
"""Путь к JSON с журналом операции для кластера ``cluster_name``."""
return clusters_dir() / cluster_name.strip() / PROVISION_LOG_FILENAME
def helm_addon_log_file_path(cluster_name: str) -> Path:
"""Путь к JSON с историей журналов установки/удаления Helm-аддонов."""
return clusters_dir() / cluster_name.strip() / HELM_ADDON_LOG_FILENAME
def write_cluster_provision_log(
*,
cluster_name: str,
@@ -72,3 +108,156 @@ def write_cluster_provision_log(
except OSError:
pass
return None
def _helm_addon_rows_from_parsed_json(data: dict[str, Any], cname: str) -> list[dict[str, Any]]:
"""Разобрать содержимое ``helm_addon_log.json`` в плоский список строк для UI/API."""
raw_entries = data.get("entries")
if isinstance(raw_entries, list) and raw_entries:
out: list[dict[str, Any]] = []
for e in raw_entries:
if isinstance(e, dict):
row = dict(e)
row["source_cluster"] = cname
out.append(row)
return out
# Легаси: один объект операции в корне файла
if data.get("job_id") is not None or data.get("finished_at_utc") is not None:
row = dict(data)
row["source_cluster"] = cname
return [row]
return []
def write_cluster_helm_addon_log(
*,
cluster_name: str,
job_id: str,
job_kind: str,
status: str,
message: str | None,
lines: list[str],
result: dict[str, Any] | None,
) -> Path | None:
"""
Добавить запись в историю ``helm_addon_log.json`` (новые записи в начале списка).
Файл хранит ``{"file_version": 2, "entries": [ ... ]}``; лимит длины — ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``.
"""
name = cluster_name.strip()
cdir = clusters_dir() / name
if not cdir.is_dir():
logger.debug("Каталог кластера отсутствует, helm_addon_log не пишем: %s", cdir)
return None
path = cdir / HELM_ADDON_LOG_FILENAME
new_entry: dict[str, Any] = {
"version": HELM_ADDON_LOG_VERSION,
"job_id": job_id,
"kind": job_kind,
"cluster_name": name,
"finished_at_utc": datetime.now(timezone.utc).isoformat(),
"status": status,
"message": message,
"lines": list(lines),
"result": result,
}
lock = _helm_cluster_lock(name)
with lock:
entries: list[dict[str, Any]] = []
if path.is_file():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
ex = raw.get("entries")
if isinstance(ex, list):
entries = [e for e in ex if isinstance(e, dict)]
elif raw.get("job_id") is not None or raw.get("finished_at_utc") is not None:
legacy = {k: v for k, v in raw.items() if k != "source_cluster"}
entries = [legacy]
except (OSError, json.JSONDecodeError) as e:
logger.warning("helm_addon_log %s не прочитан, создаём заново: %s", path, e)
entries = []
entries.insert(0, new_entry)
cap = _max_helm_addon_entries()
if len(entries) > cap:
entries = entries[:cap]
payload: dict[str, Any] = {
"file_version": HELM_ADDON_LOG_FILE_VERSION,
"entries": entries,
}
tmp = path.with_suffix(path.suffix + ".tmp")
try:
tmp.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
tmp.replace(path)
logger.info(
"Сохранён журнал Helm-аддонов: %s (запись %s, всего записей в файле: %s)",
path,
job_id,
len(entries),
)
return path
except OSError as e:
logger.warning("Не удалось записать %s: %s", path, e)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
return None
def collect_cluster_dir_logs_page_sync(
*,
filename: str,
limit: int,
offset: int,
) -> tuple[list[dict[str, Any]], int]:
"""
Собрать JSON с диска по кластерам.
- ``provision_log.json`` — по одной строке на кластер (как раньше).
- ``helm_addon_log.json`` — по одной строке на **каждую** запись в ``entries`` (или одна для легаси-файла).
Сортировка по ``finished_at_utc`` (новые первыми), затем пагинация.
"""
lim = max(1, min(limit, 100))
off = max(0, offset)
root = clusters_dir()
if not root.is_dir():
return [], 0
rows: list[dict[str, Any]] = []
for sub in sorted(root.iterdir()):
if not sub.is_dir():
continue
cname = sub.name
if not validate_cluster_name(cname):
continue
path = sub / filename
if not path.is_file():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(data, dict):
continue
if filename == HELM_ADDON_LOG_FILENAME:
rows.extend(_helm_addon_rows_from_parsed_json(data, cname))
else:
row = dict(data)
row["source_cluster"] = cname
rows.append(row)
def sort_key(r: dict[str, Any]) -> str:
return str(r.get("finished_at_utc") or "")
rows.sort(key=sort_key, reverse=True)
total = len(rows)
chunk = rows[off : off + lim]
return chunk, total
+2 -2
View File
@@ -31,7 +31,7 @@ from core.cluster_lifecycle import (
validate_cluster_name,
)
from kindest_node_tags import fetch_kindest_node_tags, normalize_tag_v_prefix
from kind_k8s_paths import clusters_dir
from kind_k8s_paths import clusters_dir, container_cli_name
# Имя кластера: поддомен DNS (RFC 1123) — дублируем только для CLI-подсказок;
# основная проверка в core.cluster_lifecycle.
@@ -42,7 +42,7 @@ def _which(cmd: str) -> str | None:
def _container_cli_bin() -> str:
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
return container_cli_name()
def _in_container() -> bool:
+95 -17
View File
@@ -19,16 +19,17 @@
| Маршрут | Описание |
|---------|----------|
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). |
| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение**`PUT /api/v1/clusters/{name}/config`**. |
| `GET /cluster-addons` | HTML **Аддоны**: выбор кластера, **`GET /api/v1/helm/chart-versions`** для выпадающих списков версий чартов (или «Последняя»), установка/удаление Helm-релизов. |
| `GET /journal` | HTML **Журнал**: **`GET /api/v1/journal/recent`** с пагинацией (**30** записей на страницу), навигация по страницам внизу таблицы. |
| `GET /` | HTML **Панель**: CTA создания кластера, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. |
| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные**`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). |
| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. В шапке активна **Кластеры**. |
| `GET /cluster-addons` | HTML **Аддоны**: **`GET /helm/chart-versions`**, **`POST /helm/addons/compose-values`** (подстановка **реальных** `helm show values` + логины в редактор), три YAML для Istio, установка/удаление релизов. |
| `GET /journal` | HTML **Журналы**: переключатель (как «Простой/Расширенный» в редактировании кластера) — **по кластеру** (`journal/recent?cluster=`), **развёртывание** (`/journal/provision`), **Helm-аддоны** (`/journal/helm-addons`); пагинация по **30** записей. |
| `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path`**`GET /api/v1/docs/readme`**, с `?path=app/docs/…`**`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2****одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. |
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<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**.
@@ -58,27 +59,87 @@
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| GET | `/api/v1/clusters/{name}/addons/status` | Статус Helm-релизов (ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali); нужен **helm** в образе |
| GET | `/api/v1/helm/chart-versions` | Версии чартов для UI (после `helm repo update`); кэш **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`**; поля: `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio`, `kiali_server` |
| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx** (NodePort 30080); тело: `{ "chart_version": "опционально" }` (пусто = последняя) |
| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx**; тело: опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта, см. **compose-values** и раздел ниже) |
| DELETE | `/api/v1/clusters/{name}/addons/ingress-nginx` | Удалить ingress-nginx |
| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально **`chart_version`** |
| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта; пусто — автосборка) |
| DELETE | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Удалить стек |
| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server** (kind: `--kubelet-insecure-tls`); тело опционально: `{ "chart_version": "…" }` или `{}` |
| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server**; тело опционально: `chart_version`, **`values_yaml`** (полный YAML; пусто — автосборка с args для kind) |
| DELETE | `/api/v1/clusters/{name}/addons/metrics-server` | Удалить metrics-server |
| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: `kiali_username`, `kiali_password` (≥8), опционально **`istio_chart_version`**, **`kiali_chart_version`** |
| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: учётные данные Kiali, версии, опционально **`values_yaml`** (kiali-server), **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`** |
| DELETE | `/api/v1/clusters/{name}/addons/istio-kiali` | Удалить kiali-server, istiod, istio-base |
| GET | `/api/v1/journal/recent` | Пагинация журнала по всем кластерам: query **`limit`** (по умолчанию **30**, макс. 100), **`offset`** (по умолчанию **0**); в ответе **`total`**, **`total_pages`**, **`page`** |
| GET | `/api/v1/clusters/{name}/journal` | Полный файл журнала кластера (массив `entries` или пусто, если файла нет) |
| POST | `/api/v1/helm/addons/compose-values` | Собрать YAML для **одного** аддона (тело: `addon`, версии, поля Grafana при необходимости) |
| POST | `/api/v1/helm/addons/compose-values-batch` | Собрать **все** шесть YAML для страницы аддонов **одним** запросом (один **`helm repo update`**); предпочтительно для UI |
| GET | `/api/v1/journal/recent` | Журнал заданий из **`journal/jobs_history.json`**: query **`limit`**, **`offset`**, опционально **`cluster`** (только выбранный каталог); ответ **`total`**, **`total_pages`**, **`page`** |
| GET | `/api/v1/journal/provision` | Сводка **`provision_log.json`** по кластерам (по одному последнему файлу на каталог), пагинация |
| GET | `/api/v1/journal/helm-addons` | Сводка **`helm_addon_log.json`**: все записи из **`entries`** по кластерам (история), тот же формат строки, что у provision |
| GET | `/api/v1/clusters/{name}/journal` | Полный файл **`jobs_history.json`** кластера (массив `entries` или пусто) |
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
| DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
| POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) |
После каждого **POST/DELETE …/addons/…** в каталоге кластера в **`helm_addon_log.json`** **добавляется** новая запись в массив **`entries`** (история сохраняется, лимит **`KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES`**). Формат записи как у **`provision_log.json`**: `lines`, `kind`, `status`, `result`; пароли в файл не пишутся.
#### POST `/api/v1/helm/addons/compose-values-batch`
Тело **`HelmAddonComposeBatchRequest`**: версии чартов из селектов (`ingress_chart_version`, `kube_prometheus_chart_version`, `metrics_server_chart_version`, `istio_chart_version`, `kiali_chart_version`) и опционально **`grafana_admin_user`** / **`grafana_admin_password`**.
Ответ **`HelmAddonComposeBatchResponse`**: поля **`ingress_nginx_values_yaml`**, **`kube_prometheus_values_yaml`**, **`metrics_server_values_yaml`**, **`kiali_values_yaml`**, **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`**.
#### POST `/api/v1/helm/addons/compose-values`
Тело JSON (**`HelmAddonComposeValuesRequest`**):
- **`addon`**: `ingress-nginx` | `kube-prometheus-stack` | `metrics-server` | `istio-kiali`
- **`chart_version`** — для ingress / kube-prometheus-stack / metrics-server (пусто = последняя версия в `helm search`)
- **`grafana_admin_user`**, **`grafana_admin_password`** — для `kube-prometheus-stack` (если пароль пуст, в preview подставляется временная строка **`ChangeMe12345`** — замените в YAML или в форме перед установкой)
- **`istio_chart_version`**, **`kiali_chart_version`** — для `istio-kiali`
Ответ (**`HelmAddonComposeValuesResponse`**): поле **`values_yaml`** — полный текст для основного чарта; для **`istio-kiali`** дополнительно **`istio_base_values_yaml`** и **`istio_istiod_values_yaml`**. Выполняется **`helm repo update`** (внутри цепочки) и **`helm show values`**.
#### Поля YAML в POST …/addons/… (установка)
Строки **`values_yaml`** / **`istio_base_values_yaml`** / **`istio_istiod_values_yaml`**: корень YAML — **object**. Пустая строка или отсутствие поля — сервер подставляет значения как при **compose-values** (кроме Istio: пустой блок = без файла **`-f`** для соответствующего чарта). Максимум **131072** символа на поле.
- **ingress-nginx** — один файл **`-f`** с полным values; затем **`--set`** NodePort (сильнее файла).
- **kube-prometheus-stack** — один файл: либо из **`values_yaml`**, либо автосборка из версии и полей Grafana.
- **metrics-server** — аналогично.
- **istio-kiali** — до трёх опциональных файлов: **istio/base**, **istio/istiod**, **kiali-server**; секрет логина Kiali и **`auth.strategy=login`** отдельно.
**Пример POST compose-values (`kube-prometheus-stack`):**
```json
{
"addon": "kube-prometheus-stack",
"chart_version": "65.1.1",
"grafana_admin_user": "admin",
"grafana_admin_password": "MySecurePwd12"
}
```
**Пример ответа 200 (фрагмент):** поле **`values_yaml`** — многострочная строка с полным YAML чарта.
**Пример POST установки istio-kiali с тремя YAML:**
```json
{
"kiali_username": "kiali-admin",
"kiali_password": "MySecurePwd12",
"istio_chart_version": "1.22.0",
"kiali_chart_version": "1.89.0",
"istio_base_values_yaml": "defaultRevision: default\n",
"istio_istiod_values_yaml": "pilot:\\n resources: {}\\n",
"values_yaml": "auth:\\n strategy: token\\n"
}
```
### Фоновые задания (jobs)
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**.
- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`**.
- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent?cluster=…`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`** (режим «По кластеру»).
- Операции **Helm-аддонов** (install/delete) **дописывают** запись в **`clusters/<имя>/helm_addon_log.json`** (массив **`entries`**, новые сверху), тот же набор полей, что у **`provision_log.json`**. Сводка по всем кластерам: **`GET /api/v1/journal/helm-addons`**.
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
@@ -203,6 +264,7 @@ Accept: text/markdown
"total_workers_from_meta": 2,
"jobs_total": 3,
"jobs_recent_failed": 0,
"helm_addons_installable_count": 4,
"cluster_resources": [
{
"cluster_name": "dev",
@@ -250,6 +312,7 @@ Accept: text/markdown
- `total_workers_from_meta` — целое **≥ 0**; **0**, если ни в одном `meta.json` нет поля `worker_nodes` или оно не число.
- `jobs_total` — число заданий в текущей памяти процесса (не более 200).
- `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку).
- `helm_addons_installable_count` — число типовых Helm-аддонов в каталоге UI (**Аддоны**, `HELM_INSTALLABLE_ADDON_IDS` в `core/helm_addons.py`); совпадает с кнопками установки на `/cluster-addons`.
- `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение.
- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым, а `aggregate_cluster_resources` — нулевая сводка.
- `aggregate_cluster_resources` — агрегаты по **запущенным** узлам для донат-диаграмм на главной: средние проценты CPU/RAM, средняя доля RAM из строки `memory_usage`, кольца сети/диска по суммарному I/O (шкала 0–100 относительно порога 8 GiB на полное кольцо).
@@ -308,16 +371,17 @@ Accept: text/markdown
## GET /api/v1/journal/recent
Объединение записей из всех **`clusters/*/journal/jobs_history.json`**, сортировка по **`finished_at_utc`** (новые первыми). В каждой записи добавляется **`source_cluster`** — имя каталога кластера.
Записи из **`journal/jobs_history.json`**. Без параметра **`cluster`** — объединение по всем кластерам, сортировка по времени (новые первыми). С **`cluster=<имя>`** — только файл выбранного каталога (порядок как в файле, новые сверху). В каждой записи есть **`source_cluster`**.
**Query:**
| Параметр | Описание |
|----------|----------|
| `limit` | Записей на страницу, **1100**, по умолчанию **30** (как в веб-интерфейсе **`/journal`**) |
| `offset` | Смещение от начала списка, **≥ 0**, по умолчанию **0** (первая страница: `offset=0`, вторая при `limit=30`: `offset=30`) |
| `limit` | Записей на страницу, **1100**, по умолчанию **30** |
| `offset` | Смещение, **≥ 0**, по умолчанию **0** |
| `cluster` | Необязательно: DNS-имя кластера — только его **`jobs_history.json`** |
**Поля ответа:** **`limit`**, **`offset`**, **`total`** (всего записей), **`page`** (текущая страница с 1), **`total_pages`**, **`entries`** (фрагмент текущей страницы).
**Поля ответа:** **`limit`**, **`offset`**, **`total`**, **`page`**, **`total_pages`**, **`entries`** (элементы **`JournalEntryModel`** — в т.ч. **`log_lines`**).
**Пример ответа 200 (первая страница, до 30 записей):**
@@ -347,6 +411,20 @@ Accept: text/markdown
---
## GET /api/v1/journal/provision
Для каждого каталога **`clusters/<имя>/`**, где есть **`provision_log.json`**, читается один объект; список сортируется по **`finished_at_utc`** (новые первыми). Пагинация: **`limit`**, **`offset`** (те же ограничения, что у **`/journal/recent`**).
**Ответ:** **`JournalPagedDirLogsResponse`** — в каждом элементе **`entries`** поля как у файла provision: **`kind`**, **`lines`**, **`status`**, **`message`**, **`result`**, плюс **`source_cluster`**.
---
## GET /api/v1/journal/helm-addons
Аналогично **`/journal/provision`**, но источник — **`helm_addon_log.json`**: для каждого кластера разворачиваются **все** элементы массива **`entries`** (история операций Helm), строки сортируются по **`finished_at_utc`** (новые первыми). Формат одной записи совпадает с **`provision_log.json`**.
---
## GET /api/v1/clusters/{name}/journal
Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**.
+19
View File
@@ -32,3 +32,22 @@ def data_root() -> Path:
def clusters_dir() -> Path:
"""``<data_root>/clusters``."""
return data_root() / "clusters"
def container_cli_name() -> str:
"""
Имя исполняемого файла для вызовов к движку контейнеров (``docker`` / ``podman``).
В образе веб-UI ставится только **docker-cli** (Alpine ``apk add docker-cli``); бинарника
``podman`` в контейнере нет. На хост при этом может быть смонтирован сокет **Podman**
(см. ``CONTAINER_SOCKET``): API совместим с Docker, клиент ``docker`` обращается к нему
через ``DOCKER_HOST`` и смонтированный ``/var/run/docker.sock``.
Поэтому при ``KIND_K8S_IN_CONTAINER`` всегда возвращаем ``"docker"`` независимо от
``CONTAINER_CLI`` в ``.env`` (его имеет смысл задавать ``podman`` только при локальном
запуске приложения на хосте без контейнера).
"""
raw_in = (os.environ.get("KIND_K8S_IN_CONTAINER") or "").strip().lower()
if raw_in in ("1", "true", "yes", "on"):
return "docker"
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
+4 -2
View File
@@ -32,6 +32,8 @@ import tempfile
import threading
from pathlib import Path
from kind_k8s_paths import container_cli_name
logger = logging.getLogger("kind_k8s.kubeconfig_patch")
# Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения).
@@ -39,8 +41,8 @@ KUBECONFIG_HOST_BASENAME = "kubeconfig.host"
def _container_cli() -> str:
"""CLI для `port` (обычно docker к сокету Docker или Podman)."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
"""CLI для ``port`` к смонтированному сокету (в контейнере веб-UI — ``docker``)."""
return container_cli_name()
def _control_plane_container_name(cluster_name: str) -> str:
+18 -3
View File
@@ -60,9 +60,24 @@ if _static_dir.is_dir():
templates = Jinja2Templates(directory=str(_templates_dir))
@app.get("/clusters", response_class=HTMLResponse, summary="Список кластеров и сводка узлов")
async def clusters_page(request: Request) -> HTMLResponse:
"""Страница «Кластеры»: донаты ресурсов узлов и таблица (как раньше на панели)."""
if not _templates_dir.is_dir():
return HTMLResponse(
content="<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="Веб-интерфейс")
async def dashboard(request: Request) -> HTMLResponse:
"""Главная страница: обзор, сводка по узлам, таблица кластеров."""
"""Главная панель: CTA создания, статистика среды (без таблицы кластеров — см. /clusters)."""
if not _templates_dir.is_dir():
return HTMLResponse(
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
@@ -92,7 +107,7 @@ async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLRespon
"cluster_detail.html",
{
"app_title": settings.app_title,
"nav_active": "panel",
"nav_active": "clusters",
"cluster_name": n,
},
)
@@ -114,7 +129,7 @@ async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse
"cluster_edit.html",
{
"app_title": settings.app_title,
"nav_active": "panel",
"nav_active": "clusters",
"cluster_name": n,
},
)
+134 -1
View File
@@ -117,6 +117,11 @@ class StatsResponse(BaseModel):
)
jobs_total: int
jobs_recent_failed: int
helm_addons_installable_count: int = Field(
0,
ge=0,
description="Число типовых Helm-аддонов в каталоге UI (страница «Аддоны»), доступных для установки",
)
cluster_resources: list[KindClusterResources] = Field(
default_factory=list,
description="CPU/RAM/I/O узлов kind по данным container CLI",
@@ -311,6 +316,9 @@ class HelmAddonApplyResponse(BaseModel):
log: str = Field(default="", description="Сводный вывод helm/kubectl")
_VALUES_YAML_MAX = 131_072
class IngressNginxInstallRequest(BaseModel):
"""Тело POST …/addons/ingress-nginx — установка ingress-nginx."""
@@ -319,6 +327,15 @@ class IngressNginxInstallRequest(BaseModel):
max_length=64,
description="Версия чарта; пусто — последняя из репозитория",
)
values_yaml: str | None = Field(
default=None,
max_length=_VALUES_YAML_MAX,
description=(
"Полный YAML values для чарта (корень — объект), как в редакторе на странице аддонов "
"(``helm show values`` + сервис для kind). Пусто — собрать автоматически на сервере. "
"После ``-f`` применяются ``--set`` NodePort (приоритет Helm)."
),
)
class KubePrometheusStackInstallRequest(BaseModel):
@@ -331,6 +348,14 @@ class KubePrometheusStackInstallRequest(BaseModel):
max_length=64,
description="Версия чарта kube-prometheus-stack; пусто — последняя",
)
values_yaml: str | None = Field(
default=None,
max_length=_VALUES_YAML_MAX,
description=(
"Полный YAML values чарта (``helm show values`` + логин/пароль Grafana из формы). "
"Пусто — собрать на сервере из версии чарта и полей формы."
),
)
class MetricsServerInstallRequest(BaseModel):
@@ -341,6 +366,14 @@ class MetricsServerInstallRequest(BaseModel):
max_length=64,
description="Версия чарта metrics-server; пусто — последняя",
)
values_yaml: str | None = Field(
default=None,
max_length=_VALUES_YAML_MAX,
description=(
"Полный YAML values чарта (``helm show values`` + args для kind). "
"Пусто — собрать на сервере."
),
)
class IstioKialiInstallRequest(BaseModel):
@@ -358,6 +391,75 @@ class IstioKialiInstallRequest(BaseModel):
max_length=64,
description="Версия чарта kiali/kiali-server; пусто — последняя",
)
values_yaml: str | None = Field(
default=None,
max_length=_VALUES_YAML_MAX,
description=(
"Полный YAML values чарта **kiali-server** (``helm show values``). "
"Пусто — без файла ``-f`` (дефолты чарта). Секрет логина/пароля из формы отдельно; "
"``auth.strategy=login`` через ``--set``."
),
)
istio_base_values_yaml: str | None = Field(
default=None,
max_length=_VALUES_YAML_MAX,
description="Полный YAML values чарта **istio/base**; пусто — без ``-f``.",
)
istio_istiod_values_yaml: str | None = Field(
default=None,
max_length=_VALUES_YAML_MAX,
description="Полный YAML values чарта **istio/istiod**; пусто — без ``-f``.",
)
class HelmAddonComposeValuesRequest(BaseModel):
"""Тело POST /helm/addons/compose-values — собрать YAML для редактора (helm show values + форма)."""
addon: Literal["ingress-nginx", "kube-prometheus-stack", "metrics-server", "istio-kiali"]
chart_version: str | None = Field(
default=None,
max_length=64,
description="Версия чарта: ingress-nginx, kube-prometheus-stack или metrics-server",
)
grafana_admin_user: str | None = Field(default=None, max_length=256)
grafana_admin_password: str | None = Field(
default=None,
max_length=256,
description="Для preview kube-prometheus-stack; если пусто — подставляется временный пароль в YAML",
)
istio_chart_version: str | None = Field(default=None, max_length=64)
kiali_chart_version: str | None = Field(default=None, max_length=64)
class HelmAddonComposeValuesResponse(BaseModel):
"""Ответ compose-values: строки YAML для текстовых полей UI."""
values_yaml: str = Field(..., description="Основной чарт; для istio-kiali — kiali-server")
istio_base_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/base")
istio_istiod_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/istiod")
class HelmAddonComposeBatchRequest(BaseModel):
"""Тело POST /helm/addons/compose-values-batch — одним запросом обновить все редакторы на странице аддонов."""
ingress_chart_version: str | None = Field(default=None, max_length=64)
grafana_admin_user: str | None = Field(default=None, max_length=256)
grafana_admin_password: str | None = Field(default=None, max_length=256)
kube_prometheus_chart_version: str | None = Field(default=None, max_length=64)
metrics_server_chart_version: str | None = Field(default=None, max_length=64)
istio_chart_version: str | None = Field(default=None, max_length=64)
kiali_chart_version: str | None = Field(default=None, max_length=64)
class HelmAddonComposeBatchResponse(BaseModel):
"""Шесть строк YAML за один вызов (один ``helm repo update``)."""
ingress_nginx_values_yaml: str
kube_prometheus_values_yaml: str
metrics_server_values_yaml: str
kiali_values_yaml: str
istio_base_values_yaml: str
istio_istiod_values_yaml: str
class HelmAddonChartVersionsResponse(BaseModel):
@@ -400,7 +502,7 @@ class ClusterJournalResponse(BaseModel):
class JournalRecentResponse(BaseModel):
"""Страница агрегированного журнала (все ``clusters/*/journal/jobs_history.json``)."""
"""Страница журнала заданий из ``journal/jobs_history.json`` (все кластеры или один)."""
limit: int = Field(description="Размер страницы (записей за запрос)")
offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)")
@@ -408,3 +510,34 @@ class JournalRecentResponse(BaseModel):
page: int = Field(ge=1, description="Номер текущей страницы с 1")
total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет")
entries: list[JournalEntryModel] = Field(default_factory=list)
class ClusterDirLogEntryModel(BaseModel):
"""Запись из ``provision_log.json`` или ``helm_addon_log.json`` (формат как у развёртывания)."""
model_config = {"extra": "allow"}
version: int | None = None
job_id: str = ""
kind: str = ""
cluster_name: str = ""
finished_at_utc: str = ""
status: str = ""
message: str | None = None
lines: list[str] = Field(default_factory=list)
result: dict[str, Any] | None = None
source_cluster: str | None = Field(
default=None,
description="Имя каталога кластера в агрегированных ответах /journal/provision и /journal/helm-addons",
)
class JournalPagedDirLogsResponse(BaseModel):
"""Пагинация по одному последнему логу на кластер (развёртывание или Helm-аддоны)."""
limit: int
offset: int
total: int
page: int
total_pages: int
entries: list[ClusterDirLogEntryModel] = Field(default_factory=list)
+494 -46
View File
@@ -12,6 +12,115 @@
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** data-endpoint кнопки → поле ответа GET …/addons/status */
var ENDPOINT_TO_STATUS_KEY = {
"ingress-nginx": "ingress_nginx",
"kube-prometheus-stack": "kube_prometheus_stack",
"metrics-server": "metrics_server",
"istio-kiali": "istio_mesh_ready",
};
var ADDON_HUMAN_LABEL = {
"ingress-nginx": "ingress-nginx",
"kube-prometheus-stack": "kube-prometheus-stack",
"metrics-server": "metrics-server",
"istio-kiali": "Istio + Kiali",
};
/** Последний успешный снимок статуса (для подписи «Установить» / «Обновить»). */
var lastAddonsStatus = null;
/** @type {((value: boolean) => void) | null} */
var confirmModalResolver = null;
var confirmModalIsAlert = false;
function isConfirmModalOpen() {
const el = document.getElementById("confirm-modal-overlay");
return !!(el && !el.classList.contains("hidden"));
}
function closeConfirmModal(confirmed) {
const ov = document.getElementById("confirm-modal-overlay");
const cancelBtn = document.getElementById("confirm-modal-cancel");
if (ov) ov.classList.add("hidden");
document.body.classList.remove("modal-open");
if (cancelBtn) cancelBtn.classList.remove("hidden");
if (confirmModalResolver) {
var fn = confirmModalResolver;
confirmModalResolver = null;
var out = confirmModalIsAlert ? true : !!confirmed;
confirmModalIsAlert = false;
fn(out);
} else {
confirmModalIsAlert = false;
}
}
/**
* Модальное подтверждение (разметка из partials/dashboard_modals.html).
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: boolean }} opts
* @returns {Promise<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 {RequestInit} [opts]
@@ -42,22 +151,80 @@
const sel = document.getElementById("addons-cluster-select");
const statusBar = document.getElementById("addons-status-bar");
const logPre = document.getElementById("addons-log-pre");
const overlay = document.getElementById("addons-busy-overlay");
const busyLabel = document.getElementById("addons-busy-label");
const addonsCardsWrap = document.getElementById("addons-cards-wrap");
const helmWrap = document.getElementById("addons-helm-progress-wrap");
const helmHint = document.getElementById("addons-helm-progress-hint");
const helmBar = document.getElementById("addons-helm-progress-bar");
const helmTrack = document.getElementById("addons-helm-progress-track");
const helmLabel = document.getElementById("addons-helm-progress-label");
const helmLogPanel = document.getElementById("addons-helm-log-panel");
const bootstrapMsg = document.getElementById("addons-bootstrap-msg");
function getClusterName() {
if (!sel || !sel.value) return "";
return String(sel.value).trim();
}
function setBusy(show, label) {
if (!overlay) return;
overlay.classList.toggle("hidden", !show);
overlay.setAttribute("aria-busy", show ? "true" : "false");
overlay.setAttribute("aria-hidden", show ? "false" : "true");
document.body.classList.toggle("cluster-addons-busy", !!show);
if (busyLabel && label) busyLabel.textContent = label;
/** Карточки аддонов — только при выбранном кластере в селекте. */
function updateAddonsCardsVisibility() {
if (!addonsCardsWrap) return;
var n = getClusterName();
addonsCardsWrap.classList.toggle("hidden", !n);
addonsCardsWrap.setAttribute("aria-hidden", n ? "false" : "true");
}
/**
* Предупреждения загрузки списка кластеров / версий чартов (не путать с журналом Helm).
* @param {string} text пустая строка скрыть блок
*/
function setBootstrapMsg(text) {
if (!bootstrapMsg) return;
var s = (text || "").trim();
if (!s) {
bootstrapMsg.classList.add("hidden");
bootstrapMsg.textContent = "";
return;
}
bootstrapMsg.classList.remove("hidden");
bootstrapMsg.textContent = s;
}
/** Показать карточку «Ход операции Helm» (как create-progress-wrap на странице создания кластера). */
function revealHelmProgressSection() {
if (!helmWrap) return;
helmWrap.classList.remove("hidden");
helmWrap.setAttribute("aria-hidden", "false");
}
function updateHelmProgressBar(pct, stage) {
var p = Math.max(0, Math.min(100, Number(pct) || 0));
if (helmBar) helmBar.style.width = p + "%";
if (helmTrack) helmTrack.setAttribute("aria-valuenow", String(p));
if (helmLabel) helmLabel.textContent = stage || "—";
}
/** Полный текст журнала в панели с теми же классами, что «Журнал задания» при создании кластера. */
function setHelmLogText(text) {
if (!helmLogPanel) return;
helmLogPanel.textContent = text || "";
helmLogPanel.scrollTop = helmLogPanel.scrollHeight;
}
/**
* Блокировка форм на время синхронного запроса Helm (без полноэкранного спиннера).
* @param {boolean} disabled
*/
function setAddonFormsDisabled(disabled) {
document
.querySelectorAll(
"#addons-cards-wrap .cluster-addon-card button, #addons-cards-wrap .cluster-addon-card input, #addons-cards-wrap .cluster-addon-card select, #addons-cards-wrap .cluster-addon-card textarea",
)
.forEach(function (el) {
el.disabled = !!disabled;
});
if (sel) sel.disabled = !!disabled;
var refBtn = document.getElementById("addons-refresh-status");
if (refBtn) refBtn.disabled = !!disabled;
}
/** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */
@@ -70,21 +237,177 @@
document.body.classList.remove("cluster-addons-page-loading");
}
function appendLog(line) {
if (!logPre) return;
logPre.textContent = (logPre.textContent ? logPre.textContent + "\n" : "") + line;
logPre.scrollTop = logPre.scrollHeight;
/**
* Текстовое сообщение в полосе статуса (нет кластера / ошибка).
* @param {string} text
*/
function renderAddonStatusMessage(text) {
if (!statusBar) return;
statusBar.className = "cluster-addons-status-bar cluster-addons-status-bar--message muted";
statusBar.replaceChildren();
const p = document.createElement("p");
p.className = "cluster-addons-status-msg";
p.textContent = text;
statusBar.appendChild(p);
}
function formatStatus(st) {
if (!st) return "";
const parts = [
"ingress-nginx: " + (st.ingress_nginx ? "да" : "нет"),
"prometheus-stack: " + (st.kube_prometheus_stack ? "да" : "нет"),
"metrics-server: " + (st.metrics_server ? "да" : "нет"),
"Istio+Kiali: " + (st.istio_mesh_ready ? "да" : "нет"),
];
return parts.join(" · ");
/** Максимальная длина поля values_yaml на сервере (см. Pydantic max_length). */
var VALUES_YAML_MAX_LEN = 131072;
/**
* Полоса под селектором кластера: только сообщения (нет кластера / ошибка), без сетки статусов.
*/
function clearAddonToolbarStatusBar() {
if (!statusBar) return;
statusBar.className = "cluster-addons-status-bar";
statusBar.replaceChildren();
}
/**
* Бейджи «Установлен» / «Не установлен» в правом верхнем углу карточки аддона.
* @param {object | null | undefined} st ответ GET /addons/status или null при сбросе
*/
function updateAddonCardBadges(st) {
document.querySelectorAll(".cluster-addon-card[data-addon]").forEach(function (card) {
var da = card.getAttribute("data-addon") || "";
var key = ENDPOINT_TO_STATUS_KEY[da];
var on = !!(st && key && st[key]);
var badge = card.querySelector(".cluster-addon-card__badge");
if (!badge) return;
badge.textContent = on ? "Установлен" : "Не установлен";
badge.className =
"cluster-addon-card__badge" + (on ? " cluster-addon-card__badge--on" : " cluster-addon-card__badge--off");
var hum = ADDON_HUMAN_LABEL[da] || da;
badge.setAttribute("aria-label", hum + ": " + (on ? "установлен" : "не установлен"));
});
}
/**
* Прикрепить к телу POST YAML из textarea (полные values чарта). Для istio-kiali три поля.
* Пустой textarea поле не отправляется (сервер соберёт defaults или пропустит -f).
* @param {string} ep data-endpoint
* @param {Record<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" },
];
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() {
try {
const d = await api("/helm/chart-versions");
setBootstrapMsg("");
fillVersionSelect(document.getElementById("addons-ing-version-select"), d.ingress_nginx);
fillVersionSelect(document.getElementById("addons-prom-version-select"), d.kube_prometheus_stack);
fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server);
fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio);
fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server);
await refreshAllComposeValuesFromServer();
} catch (e) {
appendLog("Список версий чартов недоступен: " + e.message);
setBootstrapMsg("Список версий чартов недоступен: " + e.message);
}
}
@@ -134,6 +459,7 @@
if (!sel) return;
try {
const rows = await api("/clusters");
setBootstrapMsg("");
sel.innerHTML = "";
const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; });
if (!withKube.length) {
@@ -159,23 +485,37 @@
o.value = "";
o.textContent = "Ошибка загрузки списка";
sel.appendChild(o);
appendLog("Ошибка GET /clusters: " + e.message);
setBootstrapMsg("Ошибка GET /clusters: " + e.message);
}
}
async function refreshStatus() {
const n = getClusterName();
if (!statusBar) return;
if (!statusBar) {
updateAddonsCardsVisibility();
return;
}
if (!n) {
statusBar.textContent = "Выберите кластер для просмотра статуса аддонов.";
lastAddonsStatus = null;
clearAddonToolbarStatusBar();
updateAddonCardBadges(null);
updateAddonActionButtons();
updateAddonsCardsVisibility();
return;
}
try {
const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status");
statusBar.textContent = formatStatus(st);
lastAddonsStatus = st;
clearAddonToolbarStatusBar();
updateAddonCardBadges(st);
updateAddonActionButtons();
} catch (e) {
statusBar.textContent = "Статус недоступен: " + e.message;
lastAddonsStatus = null;
renderAddonStatusMessage("Статус недоступен: " + e.message);
updateAddonCardBadges(null);
updateAddonActionButtons();
}
updateAddonsCardsVisibility();
}
async function runInstall(endpoint, payload) {
@@ -184,22 +524,63 @@
alert("Выберите кластер.");
return;
}
setBusy(true, "Установка (Helm), подождите…");
if (logPre) logPre.textContent = "";
appendLog("POST …/addons/" + endpoint + " для «" + n + "»");
const isUpdate = addonEndpointInstalled(endpoint);
const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
revealHelmProgressSection();
setAddonFormsDisabled(true);
if (helmHint) {
helmHint.textContent = isUpdate
? "Обновление релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении."
: "Установка релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении.";
}
updateHelmProgressBar(8, isUpdate ? "Обновление Helm…" : "Установка Helm…");
setHelmLogText(
"Кластер: «" +
n +
"»\n" +
(isUpdate ? "POST (обновление)" : "POST (установка)") +
" /api/v1/clusters/…/addons/" +
endpoint +
"\n\nОжидаем завершение команды (несколько минут — норма)…",
);
try {
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: payload !== undefined ? JSON.stringify(payload) : "{}",
});
appendLog(r.message || "OK");
if (r.log) appendLog(r.log);
var okLines = [
"Кластер: «" + n + "»",
(isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint,
"",
r.message || "OK",
];
if (r.log) {
okLines.push("");
okLines.push(String(r.log));
}
setHelmLogText(okLines.join("\n"));
updateHelmProgressBar(100, "Готово");
if (helmHint) {
helmHint.textContent =
"Операция завершена. Полный журнал добавлен в файл helm_addon_log.json в каталоге кластера (история записей).";
}
await refreshStatus();
} catch (e) {
appendLog("Ошибка: " + e.message);
var failLines = [
"Кластер: «" + n + "»",
(isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint,
"",
"Ошибка: " + e.message,
];
setHelmLogText(failLines.join("\n"));
updateHelmProgressBar(100, "Ошибка");
if (helmHint) {
helmHint.textContent =
"Запрос не удался. При ответе сервера об ошибке Helm в журнале кластера могла появиться запись со статусом failed.";
}
} finally {
setBusy(false);
setAddonFormsDisabled(false);
}
}
@@ -209,22 +590,66 @@
alert("Выберите кластер.");
return;
}
if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return;
setBusy(true, "Удаление релиза, подождите…");
if (logPre) logPre.textContent = "";
appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»");
const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
const ok = await openConfirmModal({
title: "Удалить аддон?",
message:
"Кластер «" +
n +
"»: снять релиз «" +
hum +
с кластера (helm uninstall). Действие необратимо для установленных компонентов.",
confirmLabel: "Удалить",
danger: true,
});
if (!ok) return;
revealHelmProgressSection();
setAddonFormsDisabled(true);
if (helmHint) {
helmHint.textContent =
"Удаление релиза «" + hum + "» (helm uninstall); ниже — полный вывод по завершении.";
}
updateHelmProgressBar(8, "Удаление Helm…");
setHelmLogText(
"Кластер: «" + n + "»\nDELETE /api/v1/clusters/…/addons/" + endpoint + "\n\nОжидаем завершение команды…",
);
try {
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
method: "DELETE",
headers: { Accept: "application/json" },
});
appendLog(r.message || "OK");
if (r.log) appendLog(r.log);
var okDel = [
"Кластер: «" + n + "»",
"DELETE /addons/" + endpoint,
"",
r.message || "OK",
];
if (r.log) {
okDel.push("");
okDel.push(String(r.log));
}
setHelmLogText(okDel.join("\n"));
updateHelmProgressBar(100, "Готово");
if (helmHint) {
helmHint.textContent =
"Операция завершена. Запись добавлена в helm_addon_log.json в каталоге кластера (история).";
}
await refreshStatus();
} catch (e) {
appendLog("Ошибка: " + e.message);
var failDel = [
"Кластер: «" + n + "»",
"DELETE /addons/" + endpoint,
"",
"Ошибка: " + e.message,
];
setHelmLogText(failDel.join("\n"));
updateHelmProgressBar(100, "Ошибка");
if (helmHint) {
helmHint.textContent =
"Запрос не удался. При ошибке Helm на сервере в журнале кластера могла появиться запись со статусом failed.";
}
} finally {
setBusy(false);
setAddonFormsDisabled(false);
}
}
@@ -234,7 +659,10 @@
if (ep === "ingress-nginx") {
const sel = document.getElementById("addons-ing-version-select");
const v = sel && sel.value.trim();
runInstall(ep, v ? { chart_version: v } : {});
const payload = {};
if (v) payload.chart_version = v;
if (!attachAddonYamlToPayload(ep, payload)) return;
runInstall(ep, payload);
return;
}
if (ep === "kube-prometheus-stack") {
@@ -249,6 +677,7 @@
}
const payload = { grafana_admin_user: user, grafana_admin_password: pw };
if (pv && pv.value.trim()) payload.chart_version = pv.value.trim();
if (!attachAddonYamlToPayload(ep, payload)) return;
runInstall(ep, payload);
return;
}
@@ -256,7 +685,8 @@
const ms = document.getElementById("addons-ms-version-select");
const payload = {};
if (ms && ms.value.trim()) payload.chart_version = ms.value.trim();
runInstall(ep, Object.keys(payload).length ? payload : {});
if (!attachAddonYamlToPayload(ep, payload)) return;
runInstall(ep, payload);
return;
}
if (ep === "istio-kiali") {
@@ -273,6 +703,7 @@
const payload = { kiali_username: user, kiali_password: pw };
if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim();
if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim();
if (!attachAddonYamlToPayload(ep, payload)) return;
runInstall(ep, payload);
}
});
@@ -292,6 +723,23 @@
sel.addEventListener("change", function () { refreshStatus(); });
}
bindConfirmModalControls();
[
"addons-ing-version-select",
"addons-prom-version-select",
"addons-ms-version-select",
"addons-istio-version-select",
"addons-kiali-version-select",
].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.addEventListener("change", function () { scheduleComposeValuesRefresh(); });
});
var grafanaUserEl = document.getElementById("addons-grafana-user");
var grafanaPassEl = document.getElementById("addons-grafana-pass");
if (grafanaUserEl) grafanaUserEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
if (grafanaPassEl) grafanaPassEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
loadClusters()
.then(function () {
return refreshStatus();
+413 -224
View File
@@ -32,9 +32,6 @@
var cachedClusterResourcesByName = {};
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
var cachedHealthPayload = null;
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
var cachedLocalClusterDirsCount = null;
/**
* Кэш GET /versions для проверки тега перед созданием кластера.
* null загрузка ещё не завершена; [] каталог пуст или отключён; иначе список тегов.
@@ -51,6 +48,11 @@
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
}
/** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */
function isClustersListPage() {
return document.body.getAttribute("data-dashboard-mode") === "clusters";
}
function isCreatePage() {
return document.body.getAttribute("data-dashboard-mode") === "create";
}
@@ -84,6 +86,16 @@
document.body.classList.remove("dashboard-home-loading");
}
/** Скрыть спиннер первой загрузки страницы «Кластеры». */
function hideClustersPageLoadingOverlay() {
const el = document.getElementById("clusters-page-loading-overlay");
if (!el) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("dashboard-clusters-loading");
}
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
function showClusterLifecycleJobOverlay(labelText) {
const el = document.getElementById("cluster-lifecycle-job-overlay");
@@ -149,32 +161,6 @@
return d.innerHTML;
}
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
/**
* Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC).
* @param {string} iso
*/
function formatJobTimeUtc(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return (
pad2(d.getUTCDate()) +
"." +
pad2(d.getUTCMonth() + 1) +
"." +
String(d.getUTCFullYear()).slice(-2) +
" " +
pad2(d.getUTCHours()) +
":" +
pad2(d.getUTCMinutes()) +
" UTC"
);
}
/**
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
* @param {string} clusterName
@@ -459,6 +445,7 @@
const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid");
const resCard = document.getElementById("home-resources-summary-card");
if (failureWrap) {
failureWrap.classList.remove("hidden");
failureWrap.replaceChildren();
@@ -480,6 +467,7 @@
}
if (env) env.classList.add("hidden");
if (metricsGrid) metricsGrid.classList.add("hidden");
if (resCard) resCard.classList.add("hidden");
}
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
@@ -487,31 +475,18 @@
const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid");
const resCard = document.getElementById("home-resources-summary-card");
if (failureWrap) {
failureWrap.classList.add("hidden");
failureWrap.replaceChildren();
}
if (env) env.classList.remove("hidden");
if (metricsGrid) metricsGrid.classList.remove("hidden");
}
/** Карточка-метрика в ряду среды (без зелёного/красного статуса), напр. «Каталогов». */
function buildStatsEnvMetricCard(label, valueText) {
const tile = document.createElement("div");
tile.className = "stats-tile stats-env-card stats-env-card--metric";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = valueText;
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = label;
tile.appendChild(valEl);
tile.appendChild(labEl);
return tile;
if (resCard) resCard.classList.remove("hidden");
}
/**
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats).
* Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»).
*/
function paintStatsEnvRow() {
const el = document.getElementById("stats-env");
@@ -539,9 +514,6 @@
el.appendChild(
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
);
const dirVal =
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
}
async function loadHealth() {
@@ -659,7 +631,7 @@
}
/**
* SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``).
* SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»).
*/
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
renderAggregateDonutsToRoot(
@@ -1016,6 +988,163 @@
renderRows(root, items);
}
/**
* Блоки K8s на странице кластера с фильтром по namespace (ключ поле overview id корня таблицы).
* Автор: Сергей Антропов https://devops.org.ru
*/
var CLUSTER_K8S_NS_BLOCKS = [
{ key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" },
{ key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" },
{ key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" },
{ key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" },
{ key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" },
{ key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" },
{ key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" },
{ key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" },
{ key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" },
{ key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" },
];
/** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */
var lastClusterK8sOverview = null;
/** Однократная привязка делегирования change на фильтры namespace. */
var clusterNsFiltersBound = false;
/**
* Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview).
* @param {object|undefined|null} block
* @returns {object[]}
*/
function k8sBlockItemsArray(block) {
if (!block || typeof block !== "object") return [];
var items = block.items;
return Array.isArray(items) ? items : [];
}
/**
* Отбор объектов по выбранному namespace (пустая строка все).
* @param {object[]|undefined} items
* @param {string} ns
* @returns {object[]}
*/
function k8sFilterItemsByNamespace(items, ns) {
if (!ns) return items || [];
return (items || []).filter(function (it) {
return ((it.metadata && it.metadata.namespace) || "") === ns;
});
}
/**
* Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта.
* Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк).
* @param {object[]|undefined} items
* @returns {object[]}
*/
function k8sSortNamespacedItems(items) {
if (!items || !items.length) return items || [];
return items.slice().sort(function (a, b) {
var nsA = (a.metadata && a.metadata.namespace) || "";
var nsB = (b.metadata && b.metadata.namespace) || "";
var c = String(nsA).localeCompare(String(nsB), "ru");
if (c !== 0) return c;
var nA = (a.metadata && a.metadata.name) || "";
var nB = (b.metadata && b.metadata.name) || "";
return String(nA).localeCompare(String(nB), "ru");
});
}
/**
* Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace``
* из таблиц ресурсов (pods/deployments/); без дублей, сортировка по ru.
* Автор: Сергей Антропов https://devops.org.ru
* @param {object} d ответ cluster overview
* @returns {string[]}
*/
function k8sNamespaceNamesFromOverview(d) {
var seen = Object.create(null);
var ordered = [];
function addName(raw) {
if (raw == null) return;
var s = String(raw).trim();
if (!s || seen[s]) return;
seen[s] = true;
ordered.push(s);
}
var nsBlock = d.k8s_namespaces;
if (nsBlock && nsBlock.ok) {
k8sBlockItemsArray(nsBlock).forEach(function (it) {
var meta = it && it.metadata;
addName(meta && meta.name);
});
}
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop];
if (!blk || !blk.ok) continue;
k8sBlockItemsArray(blk).forEach(function (it) {
var meta = it && it.metadata;
addName(meta && meta.namespace);
});
}
ordered.sort(function (a, b) {
return a.localeCompare(b, "ru");
});
return ordered;
}
/**
* Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-<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) {
const cond = (item.status && item.status.conditions) || [];
for (var i = 0; i < cond.length; i++) {
@@ -1244,13 +1373,70 @@
clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows);
}
/**
* Фабрика функций рендера по ключу фильтра (для подов нужен clusterName).
* @param {string} cname
* @returns {Record<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.
* @param {object} d
*/
function fillClusterK8sTablesFromOverview(d) {
lastClusterK8sOverview = d;
const kubeErr = d.kubeconfig_error || null;
const cname = d.cluster_name || getClusterPageName() || "";
ensureClusterNsFilterListeners();
clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
clusterK8sRenderBlock(
d.k8s_namespaces,
@@ -1258,53 +1444,20 @@
document.getElementById("cluster-k8s-namespaces-root"),
renderK8sNamespacesTable,
);
clusterK8sRenderBlock(d.k8s_pods, kubeErr, document.getElementById("cluster-k8s-pods-root"), function (root, items) {
renderK8sPodsTable(root, items, cname);
const renderByKey = buildClusterK8sNsFilterRenderMap(cname);
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
clusterK8sRenderBlockNs(
d[row.prop],
kubeErr,
document.getElementById(row.rootId),
row.key,
renderByKey[row.key],
);
});
clusterK8sRenderBlock(
d.k8s_deployments,
kubeErr,
document.getElementById("cluster-k8s-deployments-root"),
renderK8sDeploymentsTable,
);
clusterK8sRenderBlock(
d.k8s_statefulsets,
kubeErr,
document.getElementById("cluster-k8s-statefulsets-root"),
renderK8sStsDsTable,
);
clusterK8sRenderBlock(
d.k8s_daemonsets,
kubeErr,
document.getElementById("cluster-k8s-daemonsets-root"),
renderK8sStsDsTable,
);
clusterK8sRenderBlock(
d.k8s_replicasets,
kubeErr,
document.getElementById("cluster-k8s-replicasets-root"),
renderK8sReplicaSetsTable,
);
clusterK8sRenderBlock(d.k8s_jobs, kubeErr, document.getElementById("cluster-k8s-jobs-root"), renderK8sJobsTable);
clusterK8sRenderBlock(
d.k8s_cronjobs,
kubeErr,
document.getElementById("cluster-k8s-cronjobs-root"),
renderK8sCronJobsTable,
);
clusterK8sRenderBlock(
d.k8s_services,
kubeErr,
document.getElementById("cluster-k8s-services-root"),
renderK8sServicesTable,
);
clusterK8sRenderBlock(
d.k8s_ingresses,
kubeErr,
document.getElementById("cluster-k8s-ingresses-root"),
renderK8sIngressesTable,
);
clusterK8sRenderBlock(d.k8s_pvcs, kubeErr, document.getElementById("cluster-k8s-pvcs-root"), renderK8sPvcsTable);
/* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */
clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d));
}
/**
@@ -1506,43 +1659,57 @@
async function loadStats() {
const grid = document.getElementById("stats-grid");
if (!grid) return;
const donutsRoot = document.getElementById("aggregate-donuts");
const needGrid = !!grid;
const needDonuts = !!donutsRoot;
if (!needGrid && !needDonuts) return;
try {
const s = await api("/stats");
revealStatsCardBody();
cachedLocalClusterDirsCount = s.local_cluster_dirs_count;
paintStatsEnvRow();
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
updateStatsEngineHint(cliForHint);
const rows = [
["Кластеров в kind", s.kind_clusters_count],
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
];
grid.replaceChildren();
rows.forEach(function (kv) {
const tile = document.createElement("div");
tile.className = "stats-tile";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = String(kv[1]);
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = kv[0];
tile.appendChild(valEl);
tile.appendChild(labEl);
grid.appendChild(tile);
});
if (needGrid) {
revealStatsCardBody();
paintStatsEnvRow();
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
updateStatsEngineHint(cliForHint);
const dirVal =
s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined
? String(s.local_cluster_dirs_count)
: "—";
const rows = [
["Каталогов", dirVal],
["Кластеров в kind", s.kind_clusters_count],
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
["Аддонов", s.helm_addons_installable_count ?? 0],
];
grid.replaceChildren();
rows.forEach(function (kv) {
const tile = document.createElement("div");
tile.className = "stats-tile";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = String(kv[1]);
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = kv[0];
tile.appendChild(valEl);
tile.appendChild(labEl);
grid.appendChild(tile);
});
}
cachedClusterResourcesByName = {};
renderAggregateDonuts(
s.aggregate_cluster_resources,
s.cluster_resources_error,
s.container_cli || "docker",
);
if (needDonuts) {
renderAggregateDonuts(
s.aggregate_cluster_resources,
s.cluster_resources_error,
s.container_cli || "docker",
);
}
} catch (e) {
coverStatsCardWithError("Статистика", e.message || String(e));
grid.replaceChildren();
if (needGrid) {
coverStatsCardWithError("Статистика", e.message || String(e));
grid.replaceChildren();
}
cachedClusterResourcesByName = {};
const donuts = document.getElementById("aggregate-donuts");
if (donuts) {
@@ -1692,14 +1859,6 @@
};
}
function jobBadgeClass(status) {
if (status === "success") return "badge badge-ok";
if (status === "failed") return "badge badge-err";
if (status === "running") return "badge badge-run";
if (status === "cancelled") return "badge badge-cancelled";
return "badge";
}
async function loadClusters() {
const tbody = document.querySelector("#tbl-clusters tbody");
const msg = document.getElementById("list-msg");
@@ -1768,124 +1927,145 @@
}
/**
* Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена).
* Формат времени как на странице «Журнал» (ISO «YYYY-MM-DD HH:MM:SS.sss Z»).
* @param {string} iso
*/
async function clearCompletedJobs() {
const ok = await openConfirmModal({
title: "Очистить завершённые задания?",
message:
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
confirmLabel: "Очистить",
danger: false,
});
if (!ok) return;
const msg = document.getElementById("jobs-msg");
function formatJournalUtcDisplay(iso) {
if (!iso) return "—";
try {
const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } });
const text = await r.text();
var data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
var n = data.removed != null ? data.removed : 0;
if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать.";
showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false);
await loadJobs();
await loadStats();
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
} catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message;
showToast(e.message, true);
return iso;
}
}
/**
* Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка).
* Класс плашки статуса записи журнала (success / failed / cancelled).
* @param {string} st
*/
function bindJobsTableJobLogOpen() {
function journalStatusPillClass(st) {
const s = (st || "").toLowerCase();
if (s === "success") return "journal-status-pill journal-status-pill--ok";
if (s === "failed") return "journal-status-pill journal-status-pill--err";
if (s === "cancelled") return "journal-status-pill journal-status-pill--warn";
return "journal-status-pill";
}
/**
* Раскрытие строки лога как на /journal (режим заданий по кластеру).
* Автор: Сергей Антропов https://devops.org.ru
*/
function bindCreateJobsJournalToggle() {
const tbl = document.getElementById("tbl-jobs");
if (!tbl || tbl.getAttribute("data-jobs-job-log-bound") === "1") return;
tbl.setAttribute("data-jobs-job-log-bound", "1");
if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return;
tbl.setAttribute("data-create-jobs-log-bound", "1");
tbl.addEventListener(
"click",
function (ev) {
const btn = ev.target && ev.target.closest ? ev.target.closest("button.jobs-open-log-btn") : null;
if (!btn || !tbl.contains(btn)) return;
const jobId = btn.getAttribute("data-job-id");
if (!jobId) return;
ev.preventDefault();
openJobLogModal(jobId);
const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null;
if (!btn || !tbl.contains(btn) || btn.disabled) return;
const tr = btn.closest("tr.journal-data-row");
if (!tr) return;
const trDetails = tr.nextElementSibling;
if (!trDetails || !trDetails.classList.contains("journal-details-row")) return;
const pre = trDetails.querySelector("pre.journal-log-pre");
if (!pre) return;
const open = trDetails.classList.contains("hidden");
trDetails.classList.toggle("hidden", !open);
pre.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.textContent = open ? "Скрыть лог" : "Показать лог";
},
false,
);
}
/**
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» задания
* (GET /journal/recent без фильтра кластера объединение, новые первые; колонка «Кластер» = каталог на томе).
*/
async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg");
if (!tbody) return;
try {
const rows = await api("/jobs?limit=30");
const data = await api("/journal/recent?limit=30&offset=0");
const entries = Array.isArray(data.entries) ? data.entries : [];
const frag = document.createDocumentFragment();
rows.forEach(function (j) {
entries.forEach(function (e) {
const tr = document.createElement("tr");
const st = escapeHtml(j.status || "");
var kindTag = "";
if (j.kind === "start_cluster") kindTag = "[старт] ";
else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] ";
else if (j.kind === "stop_containers") kindTag = "[стоп] ";
else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
var cellMsg = kindTag + (j.message || "").slice(0, 140);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
cellMsg =
kindTag +
j.progress_stage +
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
var msgCol =
"<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.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "";
const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc);
const kind = e.kind || "";
const message = (e.message || "").trim();
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
const logText = lines.join("\n");
const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML =
"<td><time datetime=\"" +
escapeHtml(iso) +
"\" title=\"" +
escapeHtml(iso ? "UTC: " + iso : "") +
"\">" +
escapeHtml(timeLabel) +
"</time></td>" +
"<td>" +
escapeHtml(j.cluster_name || "—") +
"<td class=\"journal-cell-time\">" +
escapeHtml(finished) +
"</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=\"" +
jobBadgeClass(j.status) +
escapeHtml(journalStatusPillClass(e.status)) +
"\">" +
st +
escapeHtml(e.status || "") +
"</span></td>" +
"<td class=\"jobs-msg-cell\">" +
msgCol +
"<td class=\"journal-cell-msg\">" +
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>";
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);
if (msg) {
msg.textContent = rows.length
? ""
: "Заданий ещё не было (или контейнер перезапускали).";
if (!entries.length) {
msg.textContent =
"Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами).";
} else {
const total = typeof data.total === "number" ? data.total : entries.length;
msg.textContent =
total > entries.length
? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах."
: "";
}
}
} catch (e) {
if (msg) msg.textContent = "Задания: " + e.message;
if (msg) msg.textContent = "Журнал заданий: " + e.message;
}
}
@@ -2298,8 +2478,10 @@
async function refreshLists() {
try {
await loadHealth();
if (isHomePage()) {
if (isHomePage() || isClustersListPage()) {
await loadStats();
}
if (isClustersListPage()) {
await loadClusters();
}
if (isCreatePage()) {
@@ -2390,15 +2572,15 @@
});
}
const btnClearJobs = document.getElementById("btn-jobs-clear");
if (btnClearJobs) {
btnClearJobs.addEventListener("click", function () {
clearCompletedJobs();
const btnJobsRefresh = document.getElementById("btn-jobs-refresh");
if (btnJobsRefresh) {
btnJobsRefresh.addEventListener("click", function () {
loadJobs();
});
}
if (document.getElementById("tbl-jobs")) {
bindJobsTableJobLogOpen();
bindCreateJobsJournalToggle();
}
const mClose = document.getElementById("modal-close");
@@ -2486,6 +2668,9 @@
await loadHealth();
if (isHomePage()) {
await loadStats();
}
if (isClustersListPage()) {
await loadStats();
await loadClusters();
}
if (isCreatePage()) {
@@ -2502,8 +2687,12 @@
if (isHomePage() && lm) {
lm.textContent = "Ошибка загрузки панели: " + errText;
}
if (isClustersListPage() && lm) {
lm.textContent = "Ошибка загрузки: " + errText;
}
} finally {
hideHomePageLoadingOverlay();
hideClustersPageLoadingOverlay();
}
})();
if (isCreatePage()) {
+208 -114
View File
@@ -1,5 +1,5 @@
/**
* Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу).
* Страница «Журнал»: режимы per_cluster / provision / helm_addons, пагинация по 30.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
@@ -11,15 +11,14 @@
if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Размер страницы (синхронно с default limit в API). */
var PAGE_SIZE = 30;
/** Текущая страница с 1 (состояние для пагинации). */
/** @type {'per_cluster' | 'provision' | 'helm_addons'} */
var viewMode = "per_cluster";
var currentPage = 1;
/** Выбранный кластер в режиме per_cluster */
var selectedCluster = "";
/**
* @param {string} path
*/
async function apiGet(path) {
const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, { headers: { Accept: "application/json" } });
@@ -50,8 +49,11 @@
const tableWrap = document.getElementById("journal-table-wrap");
const refreshBtn = document.getElementById("journal-refresh-btn");
const pagNav = document.getElementById("journal-pagination");
const clusterSelect = document.getElementById("journal-cluster-select");
const clusterPicker = document.getElementById("journal-cluster-picker");
const thKind = document.getElementById("journal-th-kind");
const sectionTitle = document.getElementById("journal-table-section-title");
/** Скрыть полноэкранный спиннер первой загрузки (повторные вызовы безопасны). */
function hideJournalPageLoadingOverlay() {
const el = document.getElementById("journal-page-loading-overlay");
if (!el) return;
@@ -86,12 +88,6 @@
return "journal-status-pill";
}
/**
* Список номеров страниц и многоточий для компактной навигации.
* @param {number} current
* @param {number} total
* @returns {(number|string)[]}
*/
function visiblePageItems(current, total) {
const delta = 2;
const range = [];
@@ -115,9 +111,6 @@
return out;
}
/**
* @param {number} itemCount число строк на текущей странице (для подписи «записи NM»)
*/
function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) {
if (!pagNav) return;
pagNav.innerHTML = "";
@@ -141,20 +134,11 @@
return b;
}
const firstOff = 0;
const prevOff = Math.max(0, offset - limit);
const nextOff = Math.min(Math.max(0, total - 1), offset + limit);
const lastOff = totalPages > 0 ? (totalPages - 1) * limit : 0;
wrap.appendChild(
mkBtn("« Первая", page <= 1, "Первая страница", function () {
onSelectPage(1);
}),
mkBtn("« Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }),
);
wrap.appendChild(
mkBtn(" Назад", page <= 1, "Предыдущая страница", function () {
onSelectPage(page - 1);
}),
mkBtn(" Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
);
const pagesEl = document.createElement("div");
@@ -180,9 +164,7 @@
if (pNum === page) b.setAttribute("aria-current", "page");
else b.removeAttribute("aria-current");
if (pNum !== page) {
b.addEventListener("click", function () {
onSelectPage(pNum);
});
b.addEventListener("click", function () { onSelectPage(pNum); });
}
pagesEl.appendChild(b);
});
@@ -190,27 +172,13 @@
wrap.appendChild(pagesEl);
wrap.appendChild(
mkBtn("Вперёд ", page >= totalPages, "Следующая страница", function () {
onSelectPage(page + 1);
}),
mkBtn("Вперёд ", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
);
wrap.appendChild(
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () {
onSelectPage(totalPages);
}),
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
);
pagNav.appendChild(wrap);
const from = total === 0 || itemCount === 0 ? 0 : offset + 1;
const to = total === 0 || itemCount === 0 ? 0 : offset + itemCount;
const hint = document.createElement("p");
hint.className = "muted journal-pagination-summary";
hint.textContent =
from && to
? "Записи " + from + "" + to + " из " + total + " · страница " + page + " из " + totalPages
: "Всего записей: " + total + (totalPages ? " · страниц: " + totalPages : "");
pagNav.appendChild(hint);
}
function goToPage(p) {
@@ -220,15 +188,120 @@
}
/**
* @param {boolean} resetPage сбросить на 1 (после «Обновить»)
* Построить строки таблицы: jobs (log_lines) или dir-лог (lines).
*/
function renderTableRows(entries, mode) {
if (!tbody) return;
tbody.innerHTML = "";
entries.forEach(function (e) {
const tr = document.createElement("tr");
tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatUtc(
e.finished_at_utc || e.created_at_utc,
);
const kind = e.kind || "";
const msg = (e.message || "").trim();
let lines = [];
if (mode === "per_cluster") {
lines = Array.isArray(e.log_lines) ? e.log_lines : [];
} else {
lines = Array.isArray(e.lines) ? e.lines : [];
}
const logText = lines.join("\n");
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML =
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
"<td><code class=\"journal-code\">" + esc(cluster) + "</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 class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" +
"<td class=\"journal-cell-actions\">" +
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
detailsId + "\">Показать лог</button>" +
"</td>";
tbody.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=\"" + detailsId + "\" class=\"mono journal-log-pre\" hidden></pre>" +
"</td>";
tbody.appendChild(trDetails);
const pre = trDetails.querySelector("pre");
if (pre) pre.textContent = logText;
const btn = tr.querySelector(".btn-journal-toggle");
if (btn && trDetails && pre) {
btn.addEventListener("click", function () {
const open = trDetails.classList.contains("hidden");
trDetails.classList.toggle("hidden", !open);
pre.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.textContent = open ? "Скрыть лог" : "Показать лог";
});
}
} else {
const btn = tr.querySelector(".btn-journal-toggle");
if (btn) {
btn.disabled = true;
btn.textContent = "Нет строк";
}
}
});
}
function applyViewLabels() {
if (thKind) {
if (viewMode === "per_cluster") thKind.textContent = "Тип задания";
else thKind.textContent = "Вид операции";
}
if (sectionTitle) {
if (viewMode === "per_cluster") sectionTitle.textContent = "Задания выбранного кластера";
else if (viewMode === "provision") sectionTitle.textContent = "Развёртывание по кластерам";
else sectionTitle.textContent = "Helm-аддоны по кластерам";
}
if (clusterPicker) {
clusterPicker.classList.toggle("hidden", viewMode !== "per_cluster");
}
}
function buildListUrl() {
const off = (currentPage - 1) * PAGE_SIZE;
const q =
"?limit=" + encodeURIComponent(String(PAGE_SIZE)) +
"&offset=" + encodeURIComponent(String(off));
if (viewMode === "per_cluster") {
return "/journal/recent" + q + "&cluster=" + encodeURIComponent(selectedCluster);
}
if (viewMode === "provision") return "/journal/provision" + q;
return "/journal/helm-addons" + q;
}
async function loadJournal(resetPage) {
if (!tbody || !statusEl) {
hideJournalPageLoadingOverlay();
return;
}
applyViewLabels();
if (resetPage) currentPage = 1;
const offset = (currentPage - 1) * PAGE_SIZE;
if (viewMode === "per_cluster" && !selectedCluster) {
statusEl.textContent = "Выберите кластер.";
tbody.innerHTML = "";
if (emptyEl) {
emptyEl.classList.remove("hidden");
emptyEl.textContent = "Укажите кластер в списке выше.";
}
if (tableWrap) tableWrap.classList.add("hidden");
if (pagNav) pagNav.classList.add("hidden");
hideJournalPageLoadingOverlay();
return;
}
statusEl.textContent = "Загрузка…";
tbody.innerHTML = "";
if (emptyEl) emptyEl.classList.add("hidden");
@@ -236,25 +309,19 @@
if (pagNav) pagNav.classList.add("hidden");
try {
const data = await apiGet(
"/journal/recent?limit=" +
encodeURIComponent(String(PAGE_SIZE)) +
"&offset=" +
encodeURIComponent(String(offset)),
);
const data = await apiGet(buildListUrl());
const entries = data.entries || [];
const total = typeof data.total === "number" ? data.total : entries.length;
const limit = typeof data.limit === "number" ? data.limit : PAGE_SIZE;
const respOffset = typeof data.offset === "number" ? data.offset : offset;
const respOffset = typeof data.offset === "number" ? data.offset : (currentPage - 1) * PAGE_SIZE;
const totalPages = typeof data.total_pages === "number" ? data.total_pages : Math.ceil(total / limit) || 0;
let page = typeof data.page === "number" ? data.page : currentPage;
if (total > 0 && currentPage > totalPages) {
if (total > 0 && currentPage > totalPages && totalPages > 0) {
currentPage = totalPages;
await loadJournal(false);
return;
}
if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) {
currentPage = totalPages;
await loadJournal(false);
@@ -264,76 +331,40 @@
page = currentPage;
if (!entries.length) {
if (emptyEl) emptyEl.classList.remove("hidden");
if (emptyEl) {
emptyEl.classList.remove("hidden");
emptyEl.textContent =
viewMode === "per_cluster"
? "Нет записей в journal/jobs_history.json для этого кластера."
: viewMode === "provision"
? "Нет файлов provision_log.json (или каталогов кластеров)."
: "Нет файлов helm_addon_log.json — установите аддон со страницы «Аддоны».";
}
if (tableWrap) tableWrap.classList.add("hidden");
statusEl.textContent =
total === 0
? "Нет записей в журналах."
: "Страница пуста (всего записей: " + total + ").";
? "Записей нет."
: "Страница пуста (всего: " + total + ").";
renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage);
return;
}
if (emptyEl) emptyEl.classList.add("hidden");
if (tableWrap) tableWrap.classList.remove("hidden");
const fromN = respOffset + 1;
const toN = respOffset + entries.length;
statusEl.textContent =
"Записи " + fromN + "" + toN + " из " + total + " · страница " + page + " из " + (totalPages || 1) + ".";
entries.forEach(function (e) {
const tr = document.createElement("tr");
tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatUtc(e.finished_at_utc || e.created_at_utc);
const msg = (e.message || "").trim();
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
const logText = lines.join("\n");
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML =
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
"<td><code class=\"journal-code\">" + esc(e.kind || "") + "</code></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-actions\">" +
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
detailsId + "\">Показать лог</button>" +
"</td>";
tbody.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=\"" + detailsId + "\" class=\"mono journal-log-pre\" hidden></pre>" +
"</td>";
tbody.appendChild(trDetails);
const pre = trDetails.querySelector("pre");
if (pre) pre.textContent = logText;
const btn = tr.querySelector(".btn-journal-toggle");
if (btn && trDetails && pre) {
btn.addEventListener("click", function () {
const open = trDetails.classList.contains("hidden");
trDetails.classList.toggle("hidden", !open);
pre.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.textContent = open ? "Скрыть лог" : "Показать лог";
});
}
} else {
const btn = tr.querySelector(".btn-journal-toggle");
if (btn) {
btn.disabled = true;
btn.textContent = "Нет строк";
}
}
});
renderTableRows(entries, viewMode);
renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage);
} catch (err) {
statusEl.textContent = "Ошибка: " + err.message;
if (emptyEl) emptyEl.classList.remove("hidden");
if (emptyEl) {
emptyEl.classList.remove("hidden");
emptyEl.textContent = "Не удалось загрузить данные.";
}
if (tableWrap) tableWrap.classList.add("hidden");
if (pagNav) pagNav.classList.add("hidden");
} finally {
@@ -341,7 +372,70 @@
}
}
if (refreshBtn) refreshBtn.addEventListener("click", function () { loadJournal(true); });
async function loadClusterList() {
if (!clusterSelect) return;
try {
const rows = await apiGet("/clusters");
clusterSelect.innerHTML = "";
const names = rows
.map(function (c) { return c.name; })
.filter(Boolean)
.sort();
if (!names.length) {
const o = document.createElement("option");
o.value = "";
o.textContent = "Нет каталогов кластеров";
clusterSelect.appendChild(o);
selectedCluster = "";
return;
}
names.forEach(function (name) {
const o = document.createElement("option");
o.value = name;
o.textContent = name;
clusterSelect.appendChild(o);
});
if (!selectedCluster || names.indexOf(selectedCluster) < 0) {
selectedCluster = names[0];
}
clusterSelect.value = selectedCluster;
} catch (e) {
clusterSelect.innerHTML = "";
const o = document.createElement("option");
o.value = "";
o.textContent = "Ошибка загрузки списка";
clusterSelect.appendChild(o);
selectedCluster = "";
}
}
loadJournal(false);
function bindViewRadios() {
document.querySelectorAll('input[name="journal_view"]').forEach(function (radio) {
radio.addEventListener("change", function () {
if (!radio.checked) return;
viewMode = radio.value;
currentPage = 1;
loadJournal(false);
});
});
}
if (clusterSelect) {
clusterSelect.addEventListener("change", function () {
selectedCluster = String(clusterSelect.value || "").trim();
currentPage = 1;
if (viewMode === "per_cluster") loadJournal(false);
});
}
if (refreshBtn) {
refreshBtn.addEventListener("click", function () { loadJournal(true); });
}
bindViewRadios();
(async function init() {
await loadClusterList();
await loadJournal(false);
})();
})();
+49
View File
@@ -44,10 +44,59 @@
setTheme(getTheme() === "dark" ? "light" : "dark");
}
/**
* Нормализует pathname: убирает хвостовые слэши (кроме корня «/»).
* Нужен для сопоставления с маршрутами FastAPI без зависимости от редиректов.
*/
function normalizePathname(path) {
var p = path || "/";
if (p.length > 1 && p.charAt(p.length - 1) === "/") {
p = p.replace(/\/+$/, "");
}
return p || "/";
}
/**
* Какой пункт шапки (href пилюли) должен быть активен по текущему URL.
* Дублирует логику nav_active из шаблонов: страницы кластера и /cluster//edit
* относятся к разделу «Кластеры», чтобы подсветка не зависела только от SSR
* (кэш образа, устаревший контейнер и т.п.).
*/
function desiredNavHrefFromPath() {
var path = normalizePathname(window.location.pathname);
if (path === "/") return "/";
if (path === "/clusters") return "/clusters";
if (/^\/cluster\/[^/]+\/edit$/.test(path)) return "/clusters";
if (/^\/cluster\/[^/]+$/.test(path)) return "/clusters";
if (path.indexOf("/cluster-addons") === 0) return "/cluster-addons";
if (path.indexOf("/journal") === 0) return "/journal";
if (path.indexOf("/documentation") === 0) return "/documentation";
if (path.indexOf("/cluster-create") === 0) return "/clusters";
return null;
}
/** Снимает активность со всех пилюль в прокручиваемой полосе и включает одну по href. */
function syncNavPillActive() {
var want = desiredNavHrefFromPath();
if (!want) return;
var scroll = document.querySelector(".nav-links-scroll");
if (!scroll) return;
var pills = scroll.querySelectorAll("a.nav-link.nav-pill[href]");
pills.forEach(function (a) {
a.classList.remove("nav-pill--active");
});
pills.forEach(function (a) {
if (a.getAttribute("href") === want) {
a.classList.add("nav-pill--active");
}
});
}
function init() {
var btn = document.getElementById("theme-toggle");
if (btn) btn.addEventListener("click", toggleTheme);
syncToggleButton();
syncNavPillActive();
}
/* defer: скрипт в <head> выполняется после разбора документа — кнопка уже есть. */
+296 -47
View File
@@ -440,6 +440,16 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext)
font-size: 1.05rem;
}
/* Отдельная карточка на панели: донаты + список кластеров (GET /stats) */
.home-resources-summary-inner {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.home-resources-summary-card .donuts-card-title {
margin-bottom: 0.5rem;
}
/* Пояснение: узлы kind и метрики идут через Docker или Podman (CONTAINER_CLI) */
.stats-engine-hint {
margin: 0;
@@ -460,10 +470,10 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext)
padding: 0.85rem 0.65rem;
}
/* Среда: Docker API · Kind · Kubectl · Каталогов — один ряд, 4 колонки */
/* Среда: Docker API · Kind · Kubectl (3 в ряд); число каталогов — в сетке метрик под ними */
.stats-env-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 0.65rem;
flex-shrink: 0;
}
@@ -513,10 +523,10 @@ html[data-theme="light"] .stats-env-card--metric {
background: rgba(59, 130, 246, 0.07);
}
/* Метрики: по 4 карточки в ряд (4 метрики — один ряд) */
/* Метрики панели: по 3 карточки в ряд */
.stats-metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 0.65rem;
flex: 1 1 auto;
align-content: start;
@@ -524,7 +534,7 @@ html[data-theme="light"] .stats-env-card--metric {
@media (max-width: 520px) {
.stats-metrics-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: 1fr;
}
}
@@ -909,6 +919,46 @@ html[data-theme="light"] .cluster-node-res-card__bar-track {
background: rgba(239, 68, 68, 0.12);
}
/* Страница «Кластеры»: заголовок слева, кнопка «Создать кластер» справа */
.clusters-page-hero-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem 1.5rem;
}
.clusters-page-hero-text {
flex: 1 1 14rem;
min-width: 0;
}
.clusters-page-hero-lead {
margin: 0;
}
.clusters-page-hero-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
padding-top: 0.15rem;
}
.clusters-page-create-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.6rem 1.35rem;
font-size: 0.95rem;
font-weight: 650;
border-radius: 10px;
text-decoration: none;
border: 1px solid transparent;
background: var(--accent);
color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.14);
white-space: nowrap;
}
.clusters-page-create-btn:hover {
filter: brightness(1.08);
}
/* Прогресс создания кластера */
.create-progress-wrap {
margin-top: 1rem;
@@ -2170,6 +2220,31 @@ html[data-theme="light"] .cluster-kube-hint.cluster-kube-hint--info {
margin: 0 0 0.55rem;
font-size: 1.02rem;
}
/* Заголовок карточки K8s + выпадающий фильтр namespace справа */
.cluster-k8s-card-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
margin-bottom: 0.55rem;
}
.cluster-k8s-card-header .cluster-detail-section-title {
margin: 0;
flex: 1 1 auto;
min-width: 0;
}
.cluster-k8s-ns-filter-wrap {
flex: 0 0 auto;
}
.cluster-k8s-ns-select {
width: auto;
min-width: 10rem;
max-width: min(18rem, 100%);
margin: 0;
padding: 0.35rem 0.5rem;
font-size: 0.88rem;
}
/* Сетка карточек узлов на странице кластера: не более 3 карточек в ряд */
.cluster-page-node-cards--wide.cluster-nodes-resources__cards-row {
@@ -2769,69 +2844,199 @@ html[data-theme="light"] .cluster-edit-yaml-preview-head {
flex: 1 1 14rem;
max-width: 28rem;
}
.cluster-addons-hint {
margin: 0.35rem 0 0;
font-size: 0.82rem;
}
.cluster-addons-status-bar {
margin-top: 0.65rem;
font-size: 0.85rem;
margin-top: 0.85rem;
}
.cluster-addons-status-bar--message .cluster-addons-status-msg {
margin: 0;
font-size: 0.88rem;
line-height: 1.45;
}
.cluster-addons-status-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.65rem;
}
.cluster-addons-status-card {
border-radius: 10px;
border: 1px solid var(--border);
padding: 0.65rem 0.75rem;
background: rgba(0, 0, 0, 0.14);
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 3.5rem;
justify-content: center;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
html[data-theme="light"] .cluster-addons-status-card {
background: rgba(15, 23, 42, 0.04);
}
.cluster-addons-status-card--on {
border-color: rgba(34, 197, 94, 0.45);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12);
}
.cluster-addons-status-card--off {
border-color: var(--border);
opacity: 0.92;
}
.cluster-addons-status-card__label {
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
line-height: 1.25;
word-break: break-word;
}
.cluster-addons-status-card__value {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.cluster-addons-status-card--on .cluster-addons-status-card__value {
color: rgb(34, 197, 94);
}
.cluster-addons-status-card--off .cluster-addons-status-card__value {
color: var(--muted);
}
.cluster-addons-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
margin: 1rem 0;
}
@media (min-width: 52rem) {
.cluster-addons-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* Шапка карточки аддона: заголовок + бейдж «установлен» справа */
.cluster-addon-card__header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: 0.65rem;
margin-bottom: 0.35rem;
}
.cluster-addon-card__title {
margin: 0 0 0.35rem;
.cluster-addon-card__header .cluster-addon-card__title {
margin: 0;
flex: 1 1 auto;
min-width: 0;
font-size: 1.05rem;
line-height: 1.3;
}
.cluster-addon-card__badge {
flex: 0 0 auto;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 0.28rem 0.5rem;
border-radius: 6px;
border: 1px solid var(--border);
line-height: 1.2;
white-space: nowrap;
}
.cluster-addon-card__badge--on {
border-color: rgba(34, 197, 94, 0.5);
color: rgb(34, 197, 94);
background: rgba(34, 197, 94, 0.1);
}
.cluster-addon-card__badge--off {
color: var(--muted);
background: rgba(0, 0, 0, 0.12);
}
html[data-theme="light"] .cluster-addon-card__badge--off {
background: rgba(15, 23, 42, 0.06);
}
.cluster-addon-card__values-details {
margin: 0.75rem 0 0;
border-radius: 8px;
border: 1px solid var(--border);
padding: 0.5rem 0.65rem;
background: rgba(0, 0, 0, 0.08);
}
html[data-theme="light"] .cluster-addon-card__values-details {
background: rgba(15, 23, 42, 0.03);
}
.cluster-addon-card__values-summary {
cursor: pointer;
font-weight: 600;
font-size: 0.88rem;
list-style-position: outside;
padding: 0.15rem 0;
}
.cluster-addon-card__values-hint {
margin: 0.5rem 0 0.45rem;
font-size: 0.78rem;
line-height: 1.45;
}
.cluster-addon-card__values-subhead {
margin: 0.65rem 0 0.35rem;
font-size: 0.8rem;
font-weight: 700;
color: var(--text);
}
.cluster-addon-card__values-details .cluster-addon-card__values-subhead:first-of-type {
margin-top: 0.35rem;
}
.cluster-addon-card__values-textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin: 0;
min-height: 6rem;
padding: 0.5rem 0.6rem;
font-size: 0.78rem;
line-height: 1.4;
border-radius: 6px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.18);
color: inherit;
resize: vertical;
}
html[data-theme="light"] .cluster-addon-card__values-textarea {
background: rgba(255, 255, 255, 0.85);
}
.cluster-addon-card__desc {
margin: 0 0 0.75rem;
font-size: 0.85rem;
line-height: 1.45;
}
.cluster-addon-card__actions {
.cluster-addon-card__actions--equal {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
width: 100%;
margin-top: 0.85rem;
gap: 0.5rem;
align-items: stretch;
}
.cluster-addon-card__actions--equal .btn-addon-install,
.cluster-addon-card__actions--equal .btn-addon-remove {
flex: 1 1 0;
min-width: 0;
min-height: 2.65rem;
margin-top: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
}
.cluster-addon-card--wide {
grid-column: 1 / -1;
grid-column: 1;
}
.cluster-addon-istio-grid {
margin-top: 0.25rem;
}
.cluster-addons-log-card {
/* Карточка хода Helm на странице аддонов (разметка как у create-progress-wrap + job-log-panel) */
.cluster-addons-helm-progress {
margin-bottom: 1.5rem;
}
.cluster-addons-log-pre {
.cluster-addons-helm-progress .job-log-panel {
max-height: min(55vh, 420px);
}
.cluster-addons-bootstrap-msg {
margin: 0.5rem 0 0;
max-height: min(50vh, 22rem);
overflow: auto;
padding: 0.65rem 0.75rem;
font-size: 0.78rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
}
html[data-theme="light"] .cluster-addons-log-pre {
background: rgba(15, 23, 42, 0.04);
}
.cluster-addons-busy-overlay {
z-index: 9995;
}
body.cluster-addons-busy {
overflow: hidden;
font-size: 0.88rem;
line-height: 1.45;
}
/* Версии чартов на странице аддонов */
@@ -2930,8 +3135,58 @@ html[data-theme="light"] .journal-details-cell {
border: 1px solid var(--border);
}
.journal-page-size-hint {
/* Три сегмента на странице журнала — шире блок переключателя */
.journal-toolbar-card .cluster-edit-segmented {
max-width: 100%;
flex-wrap: wrap;
}
@media (min-width: 960px) {
.journal-toolbar-card .cluster-edit-segmented {
flex-wrap: nowrap;
max-width: 56rem;
}
}
.journal-view-fieldset {
margin-bottom: 0.25rem;
}
.journal-cluster-picker {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.journal-cluster-picker.hidden {
display: none;
}
.journal-cluster-picker-label {
display: block;
font-size: 0.88rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.journal-cluster-select {
width: 100%;
max-width: 24rem;
padding: 0.45rem 0.65rem;
font-size: 0.9rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
.journal-cluster-picker-hint {
margin: 0.45rem 0 0;
font-size: 0.84rem;
max-width: 40rem;
}
.journal-toolbar-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-top: 0.65rem;
}
.journal-cell-kind {
word-break: break-word;
max-width: 12rem;
}
/* Пагинация журнала */
@@ -3000,12 +3255,6 @@ html[data-theme="light"] .journal-details-cell {
font-size: 0.9rem;
user-select: none;
}
.journal-pagination-summary {
width: 100%;
text-align: center;
margin: 0.85rem 0 0;
font-size: 0.84rem;
}
@media (max-width: 640px) {
.journal-pagination-inner {
flex-direction: column;
+1
View File
@@ -57,6 +57,7 @@
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
<div class="nav-links-scroll">
<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="/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>
+146 -41
View File
@@ -38,46 +38,66 @@
<h1 class="page-title" id="addons-hero-title">Дополнения кластера (Helm)</h1>
<p class="muted page-lead">
Выберите кластер с сохранённым kubeconfig. Установка и удаление выполняются через <code>helm</code> в контейнере веб-приложения;
операции могут занимать несколько минут — дождитесь ответа сервера. Для kind у ingress-nginx задан NodePort <strong>30080</strong>.
операции могут занимать несколько минут — ход и вывод команд отображаются в журнале ниже (как при развёртывании кластера). История операций сохраняется в каталоге кластера.
</p>
</section>
<section class="card create-cluster-card cluster-addons-toolbar-card">
<h2 class="create-cluster-card__title">Кластер</h2>
<div class="cluster-addons-cluster-row">
<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">
<label for="addons-cluster-select" class="cluster-addons-cluster-label">Выберите кластер для просмотра статуса аддонов.</label>
<select id="addons-cluster-select" class="cluster-addons-cluster-select" aria-label="Выберите кластер для просмотра статуса аддонов">
<option value="">— загрузка списка —</option>
</select>
<button type="button" class="btn-secondary" id="addons-refresh-status">Обновить статус</button>
</div>
<p id="addons-cluster-hint" class="muted cluster-addons-hint">
Нужен файл <code>clusters/&lt;имя&gt;/kubeconfig</code> и доступ API из контейнера.
</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>
<div id="addons-status-bar" class="cluster-addons-status-bar" role="status" aria-live="polite"></div>
<p id="addons-bootstrap-msg" class="cluster-addons-bootstrap-msg muted hidden" role="status" aria-live="polite"></p>
</section>
<div
id="addons-cards-wrap"
class="cluster-addons-cards-wrap hidden"
aria-hidden="true"
>
<div class="cluster-addons-grid">
<section class="card cluster-addon-card" data-addon="ingress-nginx">
<h2 class="cluster-addon-card__title">ingress-nginx</h2>
<p class="muted cluster-addon-card__desc">Контроллер Ingress; для kind: NodePort HTTP на порту 30080.</p>
<header class="cluster-addon-card__header">
<h2 class="cluster-addon-card__title">ingress-nginx</h2>
<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">
<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>
</select>
</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-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>
</section>
<section class="card cluster-addon-card" data-addon="kube-prometheus-stack">
<h2 class="cluster-addon-card__title">kube-prometheus-stack</h2>
<header class="cluster-addon-card__header">
<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>
<div class="create-form-field">
<label for="addons-grafana-user">Логин администратора Grafana</label>
@@ -93,14 +113,31 @@
<option value="">Последняя (из репозитория)</option>
</select>
</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-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>
</section>
<section class="card cluster-addon-card" data-addon="metrics-server">
<h2 class="cluster-addon-card__title">metrics-server</h2>
<header class="cluster-addon-card__header">
<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>
<div class="create-form-field">
<label for="addons-ms-version-select">Версия чарта</label>
@@ -108,14 +145,31 @@
<option value="">Последняя (из репозитория)</option>
</select>
</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-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>
</section>
<section class="card cluster-addon-card cluster-addon-card--wide" data-addon="istio-kiali">
<h2 class="cluster-addon-card__title">Istio + Kiali</h2>
<header class="cluster-addon-card__header">
<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">
Чарты <code>istio/base</code>, <code>istio/istiod</code> и <code>kiali-server</code> в <code>istio-system</code>. Вход в Kiali — стратегия
<strong>login</strong> (секрет с логином и паролем).
@@ -142,32 +196,83 @@
</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 чартов 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-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>
</section>
</div>
</div>
<section class="card cluster-addons-log-card">
<h2 class="create-cluster-card__title">Журнал последней операции</h2>
<pre id="addons-log-pre" class="mono cluster-addons-log-pre" role="log" aria-live="polite"></pre>
{# Журнал операции Helm: те же блоки, что на странице создания кластера (прогресс + job-log-panel). Без полноэкранного спиннера. #}
<section
id="addons-helm-progress-wrap"
class="card create-progress-wrap cluster-addons-helm-progress hidden"
aria-hidden="true"
aria-labelledby="addons-helm-progress-title"
>
<h2 class="create-cluster-card__title" id="addons-helm-progress-title">Ход операции Helm</h2>
<p class="create-progress-hint muted" id="addons-helm-progress-hint">
Выполняется команда в контейнере веб-приложения; полный вывод появится по завершении.
</p>
<div
class="progress-track"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-labelledby="addons-helm-progress-label"
id="addons-helm-progress-track"
>
<div class="progress-bar" id="addons-helm-progress-bar" style="width: 0%"></div>
</div>
<p class="progress-label" id="addons-helm-progress-label"></p>
<div class="job-log-panel-wrap">
<label for="addons-helm-log-panel" class="job-log-label muted">Журнал установки / удаления</label>
<pre
id="addons-helm-log-panel"
class="mono job-log-panel"
role="log"
aria-live="polite"
aria-relevant="additions"
></pre>
</div>
</section>
<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"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span id="addons-busy-label" class="page-loading-label">Выполняется операция Helm…</span>
</div>
</div>
{% include "partials/dashboard_modals.html" %}
{% endblock %}
{% block scripts %}
+9 -7
View File
@@ -119,16 +119,18 @@
<section class="card jobs-card">
<div class="row spread jobs-card-toolbar">
<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 class="table-wrap">
<table id="tbl-jobs" aria-label="Последние задания создания">
<div class="table-wrap journal-table-wrap">
<table id="tbl-jobs" class="journal-table" aria-label="Последние задания из журналов кластеров">
<thead>
<tr>
<th>Дата, время <span class="th-hint">(UTC)</span></th>
<th>Кластер</th>
<th>Статус</th>
<th>Сообщение</th>
<th scope="col">Завершено (UTC)</th>
<th scope="col">Кластер</th>
<th scope="col">Тип задания</th>
<th scope="col">Статус</th>
<th scope="col">Сообщение</th>
<th scope="col">Лог</th>
</tr>
</thead>
<tbody></tbody>
+144 -12
View File
@@ -35,7 +35,7 @@
</div>
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
<a href="/">Панель</a>
<a href="/clusters">Кластеры</a>
<span aria-hidden="true"> / </span>
<span>Кластер <code>{{ cluster_name | e }}</code></span>
</p>
@@ -78,7 +78,9 @@
<h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2>
<p class="muted cluster-k8s-scope-hint">
Данные по <strong>всему кластеру</strong>: ресурсы в namespace — со всех пространств имён; узлы и список
namespace — без ограничения по отдельному ns.
namespace — без ограничения по отдельному ns. В карточках ресурсов выпадающий список справа от заголовка
содержит все namespace из ответа API и из фактических объектов в таблицах; при выборе «Все namespace» строки
таблицы упорядочены по namespace, затем по имени ресурса.
</p>
<p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p>
</section>
@@ -94,52 +96,182 @@
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title">
<h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title">
<h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title">
<h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title">
<h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title">
<h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title">
<h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title">
<h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title">
<h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title">
<h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title">
<h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2>
<div class="cluster-k8s-card-header">
<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>
</section>
+1 -1
View File
@@ -17,7 +17,7 @@
{% block content %}
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
<a href="/">Панель</a>
<a href="/clusters">Кластеры</a>
<span aria-hidden="true"> / </span>
<a href="/cluster/{{ cluster_name }}">Кластер <code>{{ cluster_name | e }}</code></a>
<span aria-hidden="true"> / </span>
+81
View File
@@ -0,0 +1,81 @@
{# Список кластеров и сводка ресурсов узлов (перенесено с панели).
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block page_title %}Кластеры{% endblock %}
{% block body_extra_class %} dashboard-clusters-loading{% endblock %}
{% block body_attrs %}data-dashboard-mode="clusters"{% endblock %}
{% block footer %}
<div class="footer-inner">
<p class="footer-copyright">
© {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %}
{% block content %}
<div
id="clusters-page-loading-overlay"
class="page-loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Загрузка списка кластеров"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span class="page-loading-label">Загрузка кластеров и метрик узлов…</span>
</div>
</div>
<section class="card hero-panel clusters-page-hero" aria-labelledby="clusters-page-title">
<div class="clusters-page-hero-row">
<div class="clusters-page-hero-text">
<h1 class="page-title" id="clusters-page-title">Кластеры</h1>
<p class="muted page-lead clusters-page-hero-lead">
Запущенные узлы kind, kubeconfig и действия.
</p>
</div>
<div class="clusters-page-hero-actions">
<a href="/cluster-create" class="clusters-page-create-btn">Создать кластер</a>
</div>
</div>
</section>
<section id="aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="donuts-heading">
<h2 id="donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
</section>
<section class="card clusters-card">
<h2>Список кластеров</h2>
<div class="table-wrap">
<table id="tbl-clusters" aria-label="Список кластеров">
<thead>
<tr>
<th class="col-cluster-name">Имя</th>
<th class="col-kind">kind</th>
<th class="col-kubeconfig">kubeconfig</th>
<th class="col-version">Версия</th>
<th class="col-workers">Workers</th>
<th class="col-state">Состояние</th>
<th class="col-actions">Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="list-msg" class="msg muted"></p>
</section>
{% include "partials/dashboard_modals.html" %}
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js" defer></script>
{% endblock %}
+10 -25
View File
@@ -1,4 +1,4 @@
{# Главная: создание (CTA), статистика, донаты, таблица кластеров.
{# Главная: CTA, статистика, карточка «Ресурсы узлов» (только донаты); список кластеров на /clusters.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
@@ -36,7 +36,7 @@
<div class="cta-hero-inner">
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
<p class="muted page-lead cta-hero-lead">
Кластеры kind, kubeconfig и kubectl — ниже на странице. Данные обновляются автоматически.
Список кластеров, сводка по узлам и действия — на странице <a href="/clusters">Кластеры</a>. Данные там обновляются автоматически.
</p>
<a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a>
</div>
@@ -53,30 +53,15 @@
</aside>
</div>
<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>
<section
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>
<p id="list-msg" class="msg muted"></p>
</section>
{% include "partials/dashboard_modals.html" %}
+79 -15
View File
@@ -1,4 +1,5 @@
{# Агрегированный журнал завершённых заданий (JSON в каталогах кластеров).
{# Журналы: задания по кластеру, развёртывание (provision_log), Helm (helm_addon_log).
Переключатель режима — как «Простой / Расширенный» на странице редактирования кластера.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
@@ -18,7 +19,6 @@
{% endblock %}
{% block content %}
{# Полноэкранный спиннер до первой загрузки журнала; скрывается в journal.js #}
<div
id="journal-page-loading-overlay"
class="page-loading-overlay"
@@ -35,36 +35,100 @@
</div>
<section class="card hero-panel cluster-create-hero" aria-labelledby="journal-hero-title">
<h1 class="page-title" id="journal-hero-title">Журнал заданий</h1>
<p class="muted page-lead">
Записи о завершённых операциях (<strong>create</strong>, <strong>start</strong>, <strong>stop</strong> и др.) хранятся в
<code>clusters/&lt;имя&gt;/journal/jobs_history.json</code> на томе с хоста. Сводка по всем кластерам, по <strong>30</strong> записей на страницу;
детали задания в процессе — по-прежнему в <a href="/cluster-create">создании кластера</a> и <code>GET /api/v1/jobs/{id}</code>.
</p>
<h1 class="page-title" id="journal-hero-title">Журналы</h1>
</section>
<section class="card create-cluster-card journal-toolbar-card">
<h2 class="create-cluster-card__title">Действия</h2>
<div class="journal-toolbar-row">
<h2 class="create-cluster-card__title">Вид журнала</h2>
<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>
<span class="muted journal-page-size-hint">На странице по <strong>30</strong> записей.</span>
</div>
<p id="journal-status" class="muted journal-status" role="status" aria-live="polite"></p>
</section>
<section class="card journal-list-card">
<h2 class="create-cluster-card__title">Последние записи</h2>
<div id="journal-empty" class="muted journal-empty hidden">Нет записей в журналах кластеров (или каталоги кластеров ещё не созданы).</div>
<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-table-wrap" class="journal-table-wrap">
<table class="journal-table" id="journal-table" aria-describedby="journal-hero-title">
<thead>
<tr>
<th scope="col">Завершено (UTC)</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>
</tr>
</thead>
<tbody id="journal-tbody"></tbody>
+1 -1
View File
@@ -54,7 +54,7 @@
</div>
</div>
{# Журнал фонового задания (ошибка / отмена) — страница «Последние задания». #}
{# Журнал задания по API GET /jobs/{id} (модалка при необходимости из скриптов). #}
<div
id="job-log-modal-overlay"
class="modal-overlay job-log-modal-overlay hidden"
+19
View File
@@ -0,0 +1,19 @@
# Дополнение только для «podman compose» (подключайте вместе с docker-compose.yml, см. Makefile).
# Сокет хоста подставляет Makefile (detect_podman_socket.py); в контейнере — /run/podman/podman.sock + KIND_K8S_REMOTE_SOCKET_URI.
#
# Rootless Podman: без keep-id внутренний user: 501:501 в контейнере — это другой subuid, не ваш UID
# на хосте; смонтированный podman.sock остаётся недоступен → permission denied на /var/run/docker.sock.
# Режим keep-id выравнивает user namespace с пользователем на хосте.
# «make podman …» по умолчанию подставляет KIND_K8S_CONTAINER_UIDGID=0:0 (см. Makefile): иначе docker-compose
# к сокету Podman часто не применяет keep-id и сокет остаётся недоступен.
#
# Docker Compose с движком Docker этот файл не используйте (цели make docker …).
#
# Автор: Сергей Антропов — https://devops.org.ru
services:
kind-k8s-web:
userns_mode: keep-id
# Не использовать group_add: keep-groups здесь: у Podman это «магическое» имя, а docker-compose
# (в т.ч. /usr/local/bin/docker-compose к сокету Podman) ищет реальную группу в /etc/group и падает
# с «Unable to find group keep-groups». Для сокета полагаемся на userns keep-id и uid:gid владельца сокета (Makefile).
+18 -2
View File
@@ -6,6 +6,14 @@
# export CONTAINER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock"
# 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
services:
@@ -18,9 +26,12 @@ services:
KUBECTL_VERSION: ${KUBECTL_VERSION:-}
HELM_VERSION: ${HELM_VERSION:-v3.16.3}
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:
- ./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
# README для /documentation и GET /api/v1/docs/readme (без пересборки образа).
@@ -33,7 +44,10 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
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_WORKDIR: /work
# kubeconfig.host на томе; скачивание в UI патчит копию с KIND_K8S_KUBECONFIG_CLIENT_HOST (по умолчанию localhost).
@@ -64,6 +78,8 @@ services:
# Журнал заданий в clusters/<имя>/journal/jobs_history.json
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:-}
# Максимум записей в 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:-}
# 1 — uvicorn --reload (изменения в ./app); 0 — один процесс без reload.
KIND_K8S_UVICORN_RELOAD: ${KIND_K8S_UVICORN_RELOAD:-1}
+8
View File
@@ -5,6 +5,13 @@ KIND_VERSION
KUBECTL_VERSION
HELM_VERSION
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_PATCH_KUBECONFIG
KIND_K8S_KUBECONFIG_CLIENT_HOST
@@ -27,5 +34,6 @@ KIND_K8S_HELM_VERSIONS_CACHE_SEC
KIND_K8S_HELM_VERSIONS_MAX
KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES
KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES
KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES
KIND_K8S_APP_TITLE
KIND_K8S_UVICORN_RELOAD
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Печатает в stdout путь к ``podman.sock`` или ``uid:gid`` владельца сокета.
Режимы:
* без аргументов путь (для ``$(shell ...)`` в Makefile);
* ``--print-owner`` ``uid:gid`` по ``stat`` найденного сокета (для ``KIND_K8S_CONTAINER_UIDGID``).
Логика пути: ``podman info`` (схема ``unix://`` снимается), ``podman machine inspect`` (без путей
в ``/var/folders/`` на macOS там API-сокет, **compose не смонтирует**: ``operation not supported``),
стабильные ``*.sock`` под ``~/.local/share/containers/podman/machine/``, затем типичные пути пользователя.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
def _compose_cannot_bind_mount_socket(path_str: str) -> bool:
"""
На macOS сокет в ``/var/folders//T/podman/`` (например ``*-api.sock`` из machine inspect)
существует на диске, но bind-mount в контейнер через compose часто даёт
``mkdir operation not supported`` такой путь в ``CONTAINER_SOCKET`` использовать нельзя.
"""
if sys.platform != "darwin":
return False
norm = os.path.normpath(path_str)
return "/var/folders/" in norm
def _strip_unix_scheme(raw: str) -> str:
"""Путь на диске без префикса ``unix://`` / ``unix:`` (как в выводе ``podman info``)."""
s = (raw or "").strip()
if s.startswith("unix://"):
return s[7:].strip() or s
if s.startswith("unix:"):
rest = s[5:].lstrip("/")
return "/" + rest if rest else s
return s
def _podman_machine_socket_paths(podman_bin: str) -> list[str]:
"""
Пути к сокету на **хосте** из ``podman machine inspect`` (актуально для macOS/Windows).
На Linux без machine обычно возвращается ``[]``.
"""
try:
proc = subprocess.run(
[podman_bin, "machine", "inspect"],
capture_output=True,
text=True,
timeout=20,
)
if proc.returncode != 0 or not (proc.stdout or "").strip():
return []
data = json.loads(proc.stdout)
if not isinstance(data, list):
return []
out: list[str] = []
for m in data:
if not isinstance(m, dict):
continue
ci = m.get("ConnectionInfo") or {}
if not isinstance(ci, dict):
continue
ps = ci.get("PodmanSocket")
pth: str | None = None
if isinstance(ps, dict):
raw = ps.get("Path")
pth = str(raw).strip() if raw else None
elif isinstance(ps, str) and ps.strip():
pth = ps.strip()
if pth:
cleaned = _strip_unix_scheme(pth)
if not _compose_cannot_bind_mount_socket(cleaned):
out.append(cleaned)
return out
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, TypeError, ValueError):
return []
def _darwin_stable_machine_sockets() -> list[str]:
"""Подкаталог machine на macOS: сокеты вне временного ``/var/folders``."""
base = Path.home() / ".local/share/containers/podman/machine"
if sys.platform != "darwin" or not base.is_dir():
return []
out: list[str] = []
try:
for p in sorted(base.rglob("*.sock"), key=lambda x: (len(x.parts), str(x))):
s = str(p)
if _compose_cannot_bind_mount_socket(s):
continue
out.append(s)
except OSError:
return []
return out
def _podman_socket_path_for_user() -> str:
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
if xdg:
return f"{xdg.rstrip('/')}/podman/podman.sock"
try:
return f"/run/user/{os.getuid()}/podman/podman.sock"
except AttributeError:
return "/run/user/1000/podman/podman.sock"
def podman_socket_owner_uidgid() -> str:
"""
UID:GID владельца найденного ``podman.sock`` на диске (``stat``).
Нужен для ``user:`` в compose: при Podman на macOS и в VM владелец сокета часто
**не совпадает** с ``id -u`` / ``id -g`` процесса ``make`` на хосте иначе
``permission denied`` на смонтированный сокет внутри контейнера.
Если сокет недоступен fallback на UID:GID текущего процесса.
"""
path_str = detect_podman_socket_path()
p = Path(path_str)
try:
if p.is_socket():
st = p.stat()
return f"{st.st_uid}:{st.st_gid}"
except OSError:
pass
try:
return f"{os.getuid()}:{os.getgid()}"
except AttributeError:
return "1000:1000"
def detect_podman_socket_path() -> str:
"""Путь к существующему сокету или типичный путь по умолчанию."""
cands: list[str] = []
podman_bin = shutil.which("podman")
if podman_bin:
try:
proc = subprocess.run(
[podman_bin, "info", "-f", "{{.Host.RemoteSocket.Path}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
line = (proc.stdout or "").strip().splitlines()
candidate = (line[0] if line else "").strip()
if candidate and "<no value>" not in candidate.lower():
cands.append(_strip_unix_scheme(candidate))
except (OSError, subprocess.TimeoutExpired):
pass
# Реальный файл на хосте (без macOS temp api.sock — см. _compose_cannot_bind_mount_socket).
cands.extend(_podman_machine_socket_paths(podman_bin))
cands.extend(_darwin_stable_machine_sockets())
cands.append(_podman_socket_path_for_user())
for path in cands:
cleaned = _strip_unix_scheme(path)
if _compose_cannot_bind_mount_socket(cleaned):
continue
p = Path(cleaned).expanduser()
try:
if p.is_socket():
return str(p.resolve())
except OSError:
continue
return str(Path(_strip_unix_scheme(_podman_socket_path_for_user())).expanduser())
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--print-owner":
sys.stdout.write(podman_socket_owner_uidgid())
return
sys.stdout.write(detect_podman_socket_path())
if __name__ == "__main__":
main()
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
Проверка Unix-сокетов Docker и Podman: есть ли доступ без EACCES и отвечает ли API (GET /_ping).
Печатает таблицу и рекомендуемый ``DOCKER_HOST`` для рабочих вариантов.
Запуск на хосте (не внутри контейнера веб-UI).
Важно: вывод ``podman info`` (RemoteSocket.Path) на macOS часто указывает путь **внутри VM**
Podman Machine (например ``/run/podman/...``) на диске macOS такого файла нет. «Настоящий»
сокет для монтирования в compose первый **существующий** ``*.sock`` из той же логики, что
``scripts/detect_podman_socket.py`` (как у ``make podman``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import argparse
import importlib.util
import logging
import os
import shutil
import socket
import stat
import subprocess
import sys
from pathlib import Path
from typing import Iterable
logger = logging.getLogger(__name__)
# Минимальный запрос к Docker-compatible API (Podman отвечает так же).
_PING_REQUEST = b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
def _docker_host_unix_from_env() -> str | None:
raw = (os.environ.get("DOCKER_HOST") or "").strip()
if raw.startswith("unix://"):
return raw[len("unix://") :].strip() or None
return None
def _normalize_socket_path(raw: str) -> str:
"""
Путь к сокету на диске: убираем префикс ``unix://`` / ``unix:`` (как в выводе ``podman info``).
"""
p = (raw or "").strip()
if p.startswith("unix://"):
p = p[7:]
elif p.startswith("unix:"):
rest = p[5:].lstrip("/")
p = "/" + rest if rest else p
return p.strip() or raw.strip()
def _detect_podman_socket_path_via_project_script() -> str | None:
"""
Тот же поиск «живого» сокета на **этой** ФС, что и ``make podman`` / compose.
Возвращает ``None``, если модуль не загрузился.
"""
script_path = Path(__file__).resolve().parent / "detect_podman_socket.py"
try:
spec = importlib.util.spec_from_file_location("_kind_k8s_detect_podman", script_path)
if spec is None or spec.loader is None:
return None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
fn = getattr(mod, "detect_podman_socket_path", None)
if callable(fn):
return str(fn())
except Exception as e:
logger.debug("detect_podman_socket.py: %s", e)
return None
def _podman_default_path() -> str:
xdg = (os.environ.get("XDG_RUNTIME_DIR") or "").strip()
if xdg:
return f"{xdg.rstrip('/')}/podman/podman.sock"
try:
return f"/run/user/{os.getuid()}/podman/podman.sock"
except AttributeError:
return "/run/user/1000/podman/podman.sock"
def _collect_candidates() -> list[tuple[str, str]]:
"""
Список (метка, путь). Порядок: переменная окружения, CLI, стандартные пути.
Дубликаты путей убираем, метки склеиваем через « + ».
"""
# Ключ — нормализованный путь; для несуществующих файлов не вызываем resolve().
buckets: dict[str, tuple[list[str], str]] = {}
order_keys: list[str] = []
def add(label: str, path: str | None) -> None:
if not path:
return
expanded = str(Path(_normalize_socket_path(path)).expanduser())
key = os.path.normpath(expanded)
if key not in buckets:
order_keys.append(key)
buckets[key] = ([label], expanded)
else:
buckets[key][0].append(label)
env_u = _docker_host_unix_from_env()
if env_u:
add("DOCKER_HOST", env_u)
# Реальный путь к файлу сокета на хосте (не «логический» путь из VM в podman info).
det = _detect_podman_socket_path_via_project_script()
if det:
add("хост: detect_podman_socket.py (как make podman)", det)
podman_bin = shutil.which("podman")
if podman_bin:
try:
proc = subprocess.run(
[podman_bin, "info", "-f", "{{.Host.RemoteSocket.Path}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
line = (proc.stdout or "").strip().splitlines()
cand = (line[0] if line else "").strip()
if cand and "<no value>" not in cand.lower():
add("podman info", cand)
except (OSError, subprocess.TimeoutExpired) as e:
logger.debug("podman info: %s", e)
docker_bin = shutil.which("docker")
if docker_bin:
try:
proc = subprocess.run(
[docker_bin, "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"],
capture_output=True,
text=True,
timeout=15,
)
if proc.returncode == 0:
host = (proc.stdout or "").strip().splitlines()
h = (host[0] if host else "").strip()
if h.startswith("unix://"):
add("docker context", h[len("unix://") :])
except (OSError, subprocess.TimeoutExpired) as e:
logger.debug("docker context: %s", e)
add("стандарт Docker", "/var/run/docker.sock")
home = Path.home()
add("Docker Desktop / Linux", str(home / ".docker/run/docker.sock"))
add("Colima", str(home / ".colima/default/docker.sock"))
add("Podman типичный", _podman_default_path())
return [(" + ".join(buckets[k][0]), buckets[k][1]) for k in order_keys]
def _stat_line(path: Path) -> str:
try:
if not path.exists():
return "нет файла"
if not path.is_socket():
return "не сокет"
st = path.stat()
mode = stat.filemode(st.st_mode)
return f"{mode} uid={st.st_uid} gid={st.st_gid}"
except OSError as e:
return f"stat: {e}"
def _probe_http_ping(sock_path: Path, timeout_sec: float) -> tuple[str, str]:
"""Статус: ok | denied | conn_err | bad_reply | missing."""
try:
if not sock_path.exists():
return "missing", "файл отсутствует"
if not sock_path.is_socket():
return "missing", "не Unix-сокет"
except OSError as e:
return "missing", str(e)
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout_sec)
sock.connect(os.fspath(sock_path))
sock.sendall(_PING_REQUEST)
chunks: list[bytes] = []
while True:
part = sock.recv(4096)
if not part:
break
chunks.append(part)
if len(chunks) > 8: # достаточно для заголовков + OK
break
sock.close()
except PermissionError:
return "denied", "EACCES при connect (как у docker info)"
except OSError as e:
return "conn_err", str(e)
raw = b"".join(chunks)
if b" 200 " in raw and b"OK" in raw:
return "ok", "HTTP 200, тело OK"
head = raw[: min(120, len(raw))].decode("utf-8", errors="replace")
return "bad_reply", head.replace("\r", " ").replace("\n", " ")
def _probe_docker_cli(unix_path: str, timeout_sec: int) -> tuple[str, str]:
docker = shutil.which("docker")
if not docker:
return "skip", "docker CLI нет в PATH"
uri = f"unix://{unix_path}"
try:
proc = subprocess.run(
[docker, "-H", uri, "info"],
capture_output=True,
text=True,
timeout=timeout_sec,
)
if proc.returncode == 0:
return "ok", "docker -H … info → 0"
err = (proc.stderr or proc.stdout or "").strip()[:200]
if "permission denied" in err.lower() or "eacces" in err.lower():
return "denied", err
return "cli_err", err or f"код {proc.returncode}"
except subprocess.TimeoutExpired:
return "cli_err", "timeout"
except OSError as e:
return "cli_err", str(e)
def _run(candidates: Iterable[tuple[str, str]], timeout: float, with_docker_cli: bool) -> int:
rows: list[tuple[str, str, str, str, str]] = []
working: list[str] = []
for label, path_str in candidates:
p = Path(path_str).expanduser()
st = _stat_line(p)
http_st, http_msg = _probe_http_ping(p, timeout_sec=timeout)
cli_st, cli_msg = ("", "")
if with_docker_cli:
cli_st, cli_msg = _probe_docker_cli(path_str, timeout_sec=int(timeout) + 5)
rows.append((label, path_str, st, f"{http_st}: {http_msg}", f"{cli_st}: {cli_msg}"))
if http_st == "ok":
working.append(f"unix://{path_str}")
w = 22
print(
f"{'Источник':<{w}} {'Путь':<40} {'Файл':<28} {'HTTP /_ping':<35} {'docker CLI':<30}",
)
print("-" * 145)
for label, path_str, st, http_col, cli_col in rows:
p_short = path_str if len(path_str) <= 40 else path_str[:37] + "..."
print(f"{label:<{w}} {p_short:<40} {st:<28} {http_col:<35} {cli_col:<30}")
me = ""
try:
me = f"{os.getuid()}:{os.getgid()}"
except AttributeError:
me = "?"
print()
print(f"Текущий процесс: uid:gid = {me}")
if working:
print("\nРабочие URI (HTTP /_ping OK), можно задать:")
for u in working:
print(f" export DOCKER_HOST={u!r}")
print("\nДля compose этого проекта см. CONTAINER_SOCKET / KIND_K8S_REMOTE_SOCKET_URI в .env.")
return 0
print(
"\nНи один сокет не ответил на /_ping без отказа. "
"Проверьте, что Docker Desktop / Podman machine запущены; для Podman на macOS часто помогает "
"запуск веб-UI от 0:0 (см. Makefile, make podman) или нативный «podman compose».",
)
return 1
def main() -> int:
parser = argparse.ArgumentParser(
description="Проверка сокетов Docker/Podman: доступ к API без permission denied.",
)
parser.add_argument(
"-t",
"--timeout",
type=float,
default=5.0,
help="Таймаут connect/read для сокета (сек)",
)
parser.add_argument(
"--no-docker-cli",
action="store_true",
help="Не вызывать «docker -H … info»",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Подробный журнал",
)
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING)
cands = _collect_candidates()
if not cands:
print("Нет кандидатов путей.", file=sys.stderr)
return 2
return _run(cands, timeout=args.timeout, with_docker_cli=not args.no_docker_cli)
if __name__ == "__main__":
raise SystemExit(main())
+256 -12
View File
@@ -2,6 +2,7 @@
"""Интерактивное создание ``.env`` в корне репозитория Kind Clusters Dashboard.
Список переменных и подсказок задаётся в этом файле (не используется env.example).
В начале мастера выбирается только **docker** или **podman** путь ``CONTAINER_SOCKET`` подставляется автоматически.
Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter записать предложенное значение.
Запуск: ``python3 scripts/setup_env_interactive.py`` или ``make setup``.
@@ -16,7 +17,11 @@ from __future__ import annotations
import argparse
import logging
import os
import secrets
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@@ -85,9 +90,9 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
"дефолт скрипта — закреплённый тег (в Dockerfile без аргумента берётся stable.txt).",
),
_EnvPrompt(
"# --- docker-compose.yml: том сокета (Docker / Podman rootless) ---",
"# --- docker-compose.yml: сокет + CONTAINER_CLI + user/HOME (доступ к сокету rootless Podman) ---",
"CONTAINER_SOCKET",
"Путь к сокету на хосте для volume (Docker: /var/run/docker.sock; Podman rootless — см. README).",
"",
),
_EnvPrompt(
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000 внутри) ---",
@@ -105,7 +110,6 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
"KIND_K8S_KUBECONFIG_CLIENT_HOST",
"Имя хоста в server= для скачиваемого kubeconfig (kubectl на машине пользователя). Пусто — localhost (лучше 127.0.0.1 с TLS kind). Для доступа с другой машины — IP/имя хоста Docker.",
),
_EnvPrompt(None, "CONTAINER_CLI", "Имя CLI для вызовов к движку контейнеров: docker или podman."),
_EnvPrompt(
None,
"KIND_K8S_SKIP_VERSION_LIST",
@@ -132,7 +136,12 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
_EnvPrompt(
None,
"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(
None,
@@ -161,14 +170,239 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
),
)
_ENV_FILE_HEADER = """# Файл .env для Kind Clusters Dashboard
# Создан интерактивно: scripts/setup_env_interactive.py
#
# В docker-compose.yml заданы литералами (не из этого файла):
# DOCKER_HOST, KIND_K8S_IN_CONTAINER, KIND_K8S_WORKDIR=/work в контейнере.
#
# Путь к сокету Docker в примерах и значение по умолчанию для CONTAINER_SOCKET (не подставляется из UID).
_DOCKER_SOCKET_EXAMPLE = "/var/run/docker.sock"
"""
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:
@@ -247,13 +481,23 @@ def run(*, output: Path, force: bool = False) -> int:
"Enter без ввода — записать значение по умолчанию из подсказки.\n",
)
engine_cli, engine_sock = ask_container_engine()
collected: dict[str, str] = {}
out_chunks: list[str] = [_ENV_FILE_HEADER]
out_chunks: list[str] = [_build_env_file_header()]
for spec in _SETUP_PROMPTS:
if spec.section_comment:
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)
if spec.key == "DATABASE_URL":
built = _default_database_url(collected)