UI: автообновление, прогресс, отмена; порт 8080; меню-пилюли и отдельные окна

- Порт хоста по умолчанию 8080 (Chrome ERR_UNSAFE_PORT на 6000); compose, setup, config, README.
- Дашборд: одна hero-карточка, прогресс создания, POST /jobs/{id}/cancel, JobView progress_*.
- job_store: отмена и прогресс (thread-safe); cluster_lifecycle этапы и откат.
- Навигация: стили nav-pill; Swagger/ReDoc/Health через window.open.
- main.py: TemplateResponse(request, …) для Starlette.
- Документация: README, app/docs (api_routes, README); Makefile ps; .gitignore clusters.
This commit is contained in:
Sergey Antropoff
2026-04-04 05:58:11 +03:00
parent 74538423d5
commit 02f4c655b9
17 changed files with 618 additions and 181 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
# Сгенерированные конфиги и kubeconfig локальных кластеров kind # Сгенерированные конфиги и kubeconfig локальных кластеров kind.
# Содержимое clusters/<имя>/ в репозиторий не попадает (только clusters/.gitkeep).
.DS_Store .DS_Store
.env .env
+12 -8
View File
@@ -1,10 +1,10 @@
# kind-k8s-develop — веб-интерфейс (FastAPI) для kind. # kind-k8s-develop — веб-интерфейс (FastAPI) для kind.
# Создание кластеров — в браузере: http://127.0.0.1:6000 (порт: KIND_K8S_WEB_PORT). # Создание кластеров — в браузере: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT; 6000 на хосте — ERR_UNSAFE_PORT в Chrome).
# #
# Все операции с Compose только с явным выбором среды: # Все операции с Compose только с явным выбором среды:
# make docker up | make docker down | make docker logs | … # make docker up | make docker down | make docker logs | …
# make podman up | make podman down | … # make podman up | make podman down | …
# Без префикса docker/podman цели up/down/logs/compose-build/check-docker завершатся с подсказкой. # Без префикса docker/podman цели up/down/logs/ps/compose-build/check-docker завершатся с подсказкой.
# #
# Автор: Сергей Антропов — https://devops.org.ru # Автор: Сергей Антропов — https://devops.org.ru
@@ -14,7 +14,7 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS)))
COMPOSE := docker compose COMPOSE := docker compose
endif endif
.PHONY: help docker podman _require_runtime up down logs setup clusters-dir check-docker compose-build .PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build
KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py
@@ -24,9 +24,10 @@ COMPOSE_BUILD_FLAGS ?=
help: ## Справка по целям help: ## Справка по целям
@echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:" @echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:"
@echo " make docker up или make podman up → http://127.0.0.1:\$${KIND_K8S_WEB_PORT:-6000}" @echo " make docker up или make podman up → http://127.0.0.1:\$${KIND_K8S_WEB_PORT:-8080}"
@echo " make docker down / make podman down" @echo " make docker down / make podman down"
@echo " make docker logs / make podman logs" @echo " make docker logs / make podman logs (follow -f)"
@echo " make docker ps / make podman ps (статус сервисов)"
@echo " make docker compose-build / make podman compose-build" @echo " make docker compose-build / make podman compose-build"
@echo " make docker check-docker / make podman check-docker" @echo " make docker check-docker / make podman check-docker"
@echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)." @echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)."
@@ -38,12 +39,12 @@ docker: ## Маркер среды: задайте вторую цель (нап
podman: ## Маркер среды: задайте вторую цель (например: make podman up) podman: ## Маркер среды: задайте вторую цель (например: make podman up)
@: @:
# Общая проверка: цели up/down/logs/compose-build/check-docker вызывать только как make docker … / make podman … # Общая проверка: цели up/down/logs/ps/compose-build/check-docker только make docker … / make podman …
_require_runtime: _require_runtime:
@if [ -z "$(COMPOSE)" ]; then \ @if [ -z "$(COMPOSE)" ]; then \
echo >&2 "Укажите среду в той же команде, что и цель:"; \ echo >&2 "Укажите среду в той же команде, что и цель:"; \
echo >&2 " make docker up | make podman up"; \ echo >&2 " make docker up | make podman up"; \
echo >&2 " make docker down | make docker logs | make docker compose-build | make docker check-docker"; \ echo >&2 " make docker down | make docker logs | make docker ps | make docker compose-build | make docker check-docker"; \
echo >&2 " (или то же с префиксом podman)"; \ echo >&2 " (или то же с префиксом podman)"; \
exit 1; \ exit 1; \
fi fi
@@ -54,9 +55,12 @@ up: _require_runtime clusters-dir compose-build ## (с docker/podman) Подня
down: _require_runtime ## (с docker/podman) Остановить compose в этом каталоге down: _require_runtime ## (с docker/podman) Остановить compose в этом каталоге
cd "$(KIND_K8S_DIR)" && $(COMPOSE) down cd "$(KIND_K8S_DIR)" && $(COMPOSE) down
logs: _require_runtime ## (с docker/podman) Логи kind-k8s-web logs: _require_runtime ## (с docker/podman) Логи kind-k8s-web (follow -f)
cd "$(KIND_K8S_DIR)" && $(COMPOSE) logs -f kind-k8s-web cd "$(KIND_K8S_DIR)" && $(COMPOSE) logs -f kind-k8s-web
ps: _require_runtime ## (с docker/podman) Статус контейнеров compose-проекта
cd "$(KIND_K8S_DIR)" && $(COMPOSE) ps
setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте) setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте)
@$(PYTHON) "$(SETUP_ENV_SCRIPT)" @$(PYTHON) "$(SETUP_ENV_SCRIPT)"
+16 -11
View File
@@ -1,6 +1,6 @@
# kind-k8s-develop — локальные кластеры Kubernetes (kind) # kind-k8s-develop — локальные кластеры Kubernetes (kind)
Образ **kind-k8s-tools:local** и **Makefile** поднимают **веб-интерфейс** (FastAPI) на порту **6000** на хосте (или значении **`KIND_K8S_WEB_PORT`** в `.env`): через браузер создаёте и удаляете кластеры, смотрите статистику и вывод `kubectl`. **kubeconfig** сохраняется в `clusters/<имя>/`. На хосте достаточно **Docker** (или Podman) и **make**; **kind** и **kubectl** — внутри контейнера. Образ **kind-k8s-tools:local** и **Makefile** поднимают **веб-интерфейс** (FastAPI) на порту **8080** на хосте по умолчанию (или **`KIND_K8S_WEB_PORT`** в `.env`; внутри контейнера приложение слушает **6000**). Порт **6000 на хосте** не используем по умолчанию: Chrome и другие браузеры на Chromium отдают **ERR_UNSAFE_PORT**. Через браузер создаёте и удаляете кластеры, смотрите статистику и вывод `kubectl`. **kubeconfig** сохраняется в `clusters/<имя>/`. На хосте достаточно **Docker** (или Podman) и **make**; **kind** и **kubectl** — внутри контейнера.
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
@@ -10,7 +10,7 @@
|--------|----------| |--------|----------|
| **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) | | **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) |
| **[app/docs/README.md](app/docs/README.md)** | Указатель по каталогу `app/docs/` | | **[app/docs/README.md](app/docs/README.md)** | Указатель по каталогу `app/docs/` |
| **`/docs`** (Swagger) и **`/redoc`** | Интерактивная OpenAPI-документация на том же порту, что и UI | | **`/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`).
@@ -23,14 +23,18 @@
## Веб-интерфейс ## Веб-интерфейс
- Плашка **состояния среды**: наличие `kind`/`kubectl`, доступность Docker/Podman API по сокету. - Верхняя **единая карточка**: заголовок, краткое описание и строка **состояния среды** (`kind` / `kubectl` / Docker или Podman API).
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление. - **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление.
- **Последние задания** создания (данные в памяти процесса; после перезапуска контейнера история сбрасывается). - **Последние задания** создания (данные в памяти процесса; после перезапуска контейнера история сбрасывается).
- Кнопки обновления, опциональное **автообновление каждые 15 с**, уведомления (toast) при успехе/ошибке. - **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
- При создании кластера — **прогресс-бар** и текст текущего этапа; кнопка **«Отменить создание»** (между этапами; шаг `kind create` до конца не прерывается).
- Уведомления (toast) при успехе/ошибке; в подвале — копирайт и ссылка на **devops.org.ru**.
**Структура фронтенда:** `app/templates/base.html` (шапка, ссылки на Swagger/ReDoc/Health), `app/templates/dashboard.html`, стили `app/static/style.css`, логика `app/static/js/dashboard.js` (базовый префикс API из `data-api-base` на `<body>`, по умолчанию `/api/v1`). **Шапка:** навигация в виде **пилюль** (стили `.nav-pill`); пункты **Swagger**, **ReDoc** и **Health** открывают страницу в **отдельном именованном окне** (~1240×840), чтобы не уходить с панели (см. скрипт в `base.html`).
**Структура фронтенда:** `app/templates/base.html` (шапка и меню), `app/templates/dashboard.html`, `app/static/style.css`, `app/static/js/dashboard.js` (префикс API: `data-api-base` на `<body>`, по умолчанию `/api/v1`).
## Требования на хосте ## Требования на хосте
@@ -54,12 +58,12 @@ cd kind-k8s-develop
make setup # опционально: интерактивно создать .env (Enter — дефолты из скрипта) make setup # опционально: интерактивно создать .env (Enter — дефолты из скрипта)
make docker check-docker # или: make podman check-docker make docker check-docker # или: make podman check-docker
make docker up # или: make podman up make docker up # или: make podman up
# Браузер: http://127.0.0.1:6000 (порт: KIND_K8S_WEB_PORT в .env) # Браузер: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT в .env; не 6000 на хосте — Chrome ERR_UNSAFE_PORT)
``` ```
Из родительского каталога: `make -C kind-k8s-develop docker up`. Из родительского каталога: `make -C kind-k8s-develop docker up`.
**Логи и остановка:** `make docker logs` / `make podman logs`, `make docker down` / `make podman down`. **Логи, статус и остановка:** `make docker logs` / `make podman logs` (follow), `make docker ps` / `make podman ps`, `make docker down` / `make podman down`.
### Разработка UI и API без пересборки образа ### Разработка UI и API без пересборки образа
@@ -92,14 +96,15 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \
| `make help` | Краткая справка | | `make help` | Краткая справка |
| `make docker up` / `make podman up` | Поднять веб-UI (`kind-k8s-web`) | | `make docker up` / `make podman up` | Поднять веб-UI (`kind-k8s-web`) |
| `make docker down` / `make podman down` | Остановить compose в каталоге репозитория | | `make docker down` / `make podman down` | Остановить compose в каталоге репозитория |
| `make docker logs` / `make podman logs` | Логи `kind-k8s-web` | | `make docker logs` / `make podman logs` | Логи `kind-k8s-web` (stream, `-f`) |
| `make docker ps` / `make podman ps` | Статус контейнеров текущего compose-проекта |
| `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` | | `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` |
| `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` | | `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` |
| `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) | | `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) |
| `make clusters-dir` | Создать каталог `clusters/` | | `make clusters-dir` | Создать каталог `clusters/` |
| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `compose-build`, `check-docker` | | `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `ps`, `compose-build`, `check-docker` |
Цели `up`, `down`, `logs`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. Цели `up`, `down`, `logs`, `ps`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой.
## Переменные окружения ## Переменные окружения
@@ -111,7 +116,7 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \
|------------|------------------|------------| |------------|------------------|------------|
| **`KIND_VERSION`** | build-arg | Версия бинарника kind при сборке образа | | **`KIND_VERSION`** | build-arg | Версия бинарника kind при сборке образа |
| **`KUBECTL_VERSION`** | build-arg | Версия kubectl в образе; пусто в compose → в Dockerfile подставляется `stable.txt` при сборке; `make setup` предлагает закреплённый тег | | **`KUBECTL_VERSION`** | build-arg | Версия kubectl в образе; пусто в compose → в Dockerfile подставляется `stable.txt` при сборке; `make setup` предлагает закреплённый тег |
| **`KIND_K8S_WEB_PORT`** | ports | Порт **на хосте** для веб-UI (в контейнере 6000) | | **`KIND_K8S_WEB_PORT`** | ports | Порт **на хосте** для веб-UI (по умолчанию **8080**; в контейнере публикация идёт на процесс на **6000**) |
| **`KIND_K8S_WEB_HOST`** | локальный uvicorn / Settings | Хост привязки при запуске вне compose (в контейнере задаётся entrypoint) | | **`KIND_K8S_WEB_HOST`** | локальный uvicorn / Settings | Хост привязки при запуске вне compose (в контейнере задаётся entrypoint) |
| **`KIND_K8S_UVICORN_RELOAD`** | контейнер | `1` (по умолчанию) — hot-reload при правках в `./app`; `0` — без reload | | **`KIND_K8S_UVICORN_RELOAD`** | контейнер | `1` (по умолчанию) — hot-reload при правках в `./app`; `0` — без reload |
| **`KIND_K8S_APP_TITLE`** | контейнер / Settings | Заголовок OpenAPI и HTML; пустое значение из compose не ломает приложение (`env_ignore_empty`, fallback) | | **`KIND_K8S_APP_TITLE`** | контейнер / Settings | Заголовок OpenAPI и HTML; пустое значение из compose не ломает приложение (`env_ignore_empty`, fallback) |
+84 -51
View File
@@ -24,7 +24,7 @@ from core.cluster_lifecycle import (
read_meta_json, read_meta_json,
validate_cluster_name, validate_cluster_name,
) )
from core.job_store import job_store from core.job_store import JobRecord, end_job_tracking, get_progress_sync, job_store, request_cancel_sync
from core.kind_guard import kind_cluster_lock from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir from kind_k8s_paths import clusters_dir
from models.schemas import ( from models.schemas import (
@@ -41,6 +41,25 @@ logger = logging.getLogger("kind_k8s.api.clusters")
router = APIRouter(tags=["clusters"]) router = APIRouter(tags=["clusters"])
def _record_to_job_view(rec: JobRecord) -> JobView:
"""JobRecord → JobView с полями прогресса из потокобезопасного снимка."""
prog = get_progress_sync(rec.job_id)
stage, pct = (None, None)
if prog is not None:
stage, pct = prog[0], prog[1]
return JobView(
job_id=rec.job_id,
kind=rec.kind,
status=rec.status,
cluster_name=rec.cluster_name,
created_at_utc=rec.created_at_utc,
message=rec.message,
result=rec.result,
progress_stage=stage,
progress_percent=pct,
)
def _stats_sync() -> StatsResponse: def _stats_sync() -> StatsResponse:
"""Собрать статистику (синхронно; вызывать из thread при необходимости).""" """Собрать статистику (синхронно; вызывать из thread при необходимости)."""
kind_names = list_registered_kind_clusters() kind_names = list_registered_kind_clusters()
@@ -86,18 +105,7 @@ async def get_stats() -> StatsResponse:
async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]: async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]:
"""История создания кластеров (в памяти процесса; после перезапуска контейнера пусто).""" """История создания кластеров (в памяти процесса; после перезапуска контейнера пусто)."""
items = job_store.snapshot_recent_sorted(limit=limit) items = job_store.snapshot_recent_sorted(limit=limit)
return [ return [_record_to_job_view(r) for r in items]
JobView(
job_id=r.job_id,
kind=r.kind,
status=r.status,
cluster_name=r.cluster_name,
created_at_utc=r.created_at_utc,
message=r.message,
result=r.result,
)
for r in items
]
@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров") @router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров")
@@ -187,36 +195,44 @@ async def get_cluster(name: str) -> dict[str, object]:
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None: async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
async with kind_cluster_lock: try:
await job_store.set_running(job_id) async with kind_cluster_lock:
try: await job_store.set_running(job_id)
result = await asyncio.to_thread( try:
create_cluster_non_interactive, result = await asyncio.to_thread(
name=body.name.strip(), create_cluster_non_interactive,
kubernetes_version_tag=body.kubernetes_version.strip(), name=body.name.strip(),
workers=body.workers, kubernetes_version_tag=body.kubernetes_version.strip(),
) workers=body.workers,
except KindClusterError as e: job_id=job_id,
await job_store.set_failed(job_id, str(e)) )
logger.warning("create job %s: %s", job_id, e) except KindClusterError as e:
return msg = str(e)
except Exception as e: if "отменено" in msg.lower():
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}") await job_store.set_cancelled(job_id, msg)
logger.exception("create job %s: непредвиденная ошибка", job_id) else:
return await job_store.set_failed(job_id, msg)
logger.warning("create job %s: %s", job_id, e)
return
except Exception as e:
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
logger.exception("create job %s: непредвиденная ошибка", job_id)
return
payload: dict[str, Any] = { payload: dict[str, Any] = {
"cluster_name": result.cluster_name, "cluster_name": result.cluster_name,
"kubernetes_version_tag": result.ver_tag, "kubernetes_version_tag": result.ver_tag,
"node_image": result.node_image, "node_image": result.node_image,
"workers": result.workers, "workers": result.workers,
"kubeconfig_path": str(result.kubeconfig_path), "kubeconfig_path": str(result.kubeconfig_path),
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host, "kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
"nodes_ready": result.nodes_ready, "nodes_ready": result.nodes_ready,
"nodes_ready_message": result.nodes_ready_message, "nodes_ready_message": result.nodes_ready_message,
} }
await job_store.set_success(job_id, result=payload, message="Кластер создан") await job_store.set_success(job_id, result=payload, message="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name) logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
end_job_tracking(job_id)
@router.post( @router.post(
@@ -263,18 +279,35 @@ async def delete_cluster(name: str) -> dict[str, object]:
return {"name": name, "kind_delete_ok": kind_ok, "summary": summary} return {"name": name, "kind_delete_ok": kind_ok, "summary": summary}
@router.post(
"/jobs/{job_id}/cancel",
summary="Запросить отмену создания кластера",
responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}},
)
async def cancel_create_job(job_id: str) -> dict[str, object]:
"""
Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения;
после него отмена удалит кластер и данные (если успели создать).
"""
rec = await job_store.get(job_id)
if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено")
if rec.status not in ("queued", "running"):
raise HTTPException(status_code=400, detail="Задание уже завершено; отмена невозможна")
if not request_cancel_sync(job_id):
raise HTTPException(status_code=404, detail="Задание не найдено")
logger.info("Принят запрос отмены задания %s", job_id)
return {
"job_id": job_id,
"cancel_requested": True,
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага",
}
@router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания") @router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания")
async def get_job(job_id: str) -> JobView: async def get_job(job_id: str) -> JobView:
"""Узнать состояние фонового создания кластера.""" """Узнать состояние фонового создания кластера."""
rec = await job_store.get(job_id) rec = await job_store.get(job_id)
if not rec: if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено") raise HTTPException(status_code=404, detail="Задание не найдено")
return JobView( return _record_to_job_view(rec)
job_id=rec.job_id,
kind=rec.kind,
status=rec.status,
cluster_name=rec.cluster_name,
created_at_utc=rec.created_at_utc,
message=rec.message,
result=rec.result,
)
+51
View File
@@ -24,6 +24,22 @@ from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_afte
logger = logging.getLogger("kind_k8s.cluster_lifecycle") logger = logging.getLogger("kind_k8s.cluster_lifecycle")
def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None:
"""Удалить кластер kind и каталог данных после запроса отмены (best-effort)."""
logger.info("Откат после отмены: kind delete «%s»", cluster_name)
subprocess.run(
["kind", "delete", "cluster", "--name", cluster_name],
capture_output=True,
text=True,
)
if out_dir.is_dir():
try:
shutil.rmtree(out_dir)
logger.info("Удалён каталог %s", out_dir)
except OSError as e:
logger.warning("Не удалось удалить %s: %s", out_dir, e)
# Имя кластера: поддомен DNS (RFC 1123) # Имя кластера: поддомен DNS (RFC 1123)
_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") _NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
@@ -160,12 +176,24 @@ def create_cluster_non_interactive(
name: str, name: str,
kubernetes_version_tag: str, kubernetes_version_tag: str,
workers: int, workers: int,
job_id: str | None = None,
) -> CreateClusterResult: ) -> CreateClusterResult:
""" """
Создать кластер kind без диалогов. Создать кластер kind без диалогов.
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
""" """
from core import job_store as _job_store
def _progress(stage: str, pct: int) -> None:
if job_id:
_job_store.set_progress_sync(job_id, stage, pct)
def _cancelled() -> bool:
return bool(job_id and _job_store.is_cancelled_sync(job_id))
if not shutil.which("kind"): if not shutil.which("kind"):
raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127) raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127)
@@ -193,19 +221,40 @@ def create_cluster_non_interactive(
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
cfg_path.write_text(yaml_text, encoding="utf-8") cfg_path.write_text(yaml_text, encoding="utf-8")
_progress("Подготовка каталога и kind-config", 12)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers) logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers)
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
_run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)]) _run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)])
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
_progress("Сохранение kubeconfig", 58)
kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name]) kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name])
kube_path.write_text(kube, encoding="utf-8") kube_path.write_text(kube, encoding="utf-8")
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
patched = False patched = False
if should_patch_after_create(): if should_patch_after_create():
_progress("Патч kubeconfig для доступа к API с хоста", 72)
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path) patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
nodes_ready: bool | None = None nodes_ready: bool | None = None
nodes_msg: str | None = None nodes_msg: str | None = None
if _wait_nodes_enabled(): if _wait_nodes_enabled():
_progress("Ожидание готовности нод (kubectl wait …)", 82)
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path) ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
nodes_ready = ok nodes_ready = ok
nodes_msg = msg nodes_msg = msg
@@ -229,6 +278,8 @@ def create_cluster_non_interactive(
} }
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
_progress("Финализация", 95)
return CreateClusterResult( return CreateClusterResult(
cluster_name=name, cluster_name=name,
ver_tag=ver_tag, ver_tag=ver_tag,
+2 -1
View File
@@ -26,7 +26,8 @@ class Settings(BaseSettings):
) )
kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST") kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST")
kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT") # Согласовано с дефолтом compose на хосте (8080); в контейнере процесс слушает 6000 через run_uvicorn.sh.
kind_k8s_web_port: int = Field(default=8080, validation_alias="KIND_K8S_WEB_PORT")
# Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI. # Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI.
app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE") app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE")
+66 -1
View File
@@ -2,6 +2,8 @@
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды. При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
""" """
@@ -10,6 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import threading
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -20,7 +23,58 @@ logger = logging.getLogger("kind_k8s.job_store")
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются) # Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
_MAX_JOBS = 200 _MAX_JOBS = 200
JobStatus = Literal["queued", "running", "success", "failed"] JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
_thread_lock = threading.Lock()
_cancel_events: dict[str, threading.Event] = {}
_progress: dict[str, tuple[str, int]] = {}
def begin_job_tracking(job_id: str) -> None:
"""Зарегистрировать отмену/прогресс для нового job_id (вызывать при создании задания)."""
with _thread_lock:
_cancel_events[job_id] = threading.Event()
_progress[job_id] = ("В очереди", 0)
def end_job_tracking(job_id: str) -> None:
"""Очистить служебные структуры после завершения задания."""
with _thread_lock:
_cancel_events.pop(job_id, None)
_progress.pop(job_id, None)
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
"""Обновить текст этапа и процент (0–100) из worker-thread."""
pct = max(0, min(100, int(percent)))
with _thread_lock:
if job_id in _progress:
_progress[job_id] = (stage, pct)
def get_progress_sync(job_id: str) -> tuple[str, int] | None:
"""Снимок прогресса для ответа API."""
with _thread_lock:
return _progress.get(job_id)
def request_cancel_sync(job_id: str) -> bool:
"""Запросить отмену. Вернуть False, если job_id не отслеживается."""
with _thread_lock:
ev = _cancel_events.get(job_id)
if ev is None:
return False
ev.set()
logger.info("Запрошена отмена задания %s", job_id)
return True
def is_cancelled_sync(job_id: str) -> bool:
"""Проверка из worker-thread между этапами создания кластера."""
with _thread_lock:
ev = _cancel_events.get(job_id)
return bool(ev and ev.is_set())
@dataclass @dataclass
@@ -58,8 +112,10 @@ class JobStore:
self._jobs[jid] = rec self._jobs[jid] = rec
while len(self._jobs) > _MAX_JOBS: while len(self._jobs) > _MAX_JOBS:
oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc) oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc)
end_job_tracking(oldest_id)
del self._jobs[oldest_id] del self._jobs[oldest_id]
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id) logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
begin_job_tracking(jid)
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name) logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec return rec
@@ -68,6 +124,7 @@ class JobStore:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "running" self._jobs[job_id].status = "running"
self._jobs[job_id].message = None self._jobs[job_id].message = None
set_progress_sync(job_id, "Запуск создания кластера…", 5)
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None: async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
async with self._lock: async with self._lock:
@@ -75,6 +132,7 @@ class JobStore:
self._jobs[job_id].status = "success" self._jobs[job_id].status = "success"
self._jobs[job_id].result = result self._jobs[job_id].result = result
self._jobs[job_id].message = message self._jobs[job_id].message = message
set_progress_sync(job_id, "Готово", 100)
async def set_failed(self, job_id: str, message: str) -> None: async def set_failed(self, job_id: str, message: str) -> None:
async with self._lock: async with self._lock:
@@ -83,6 +141,13 @@ class JobStore:
self._jobs[job_id].message = message self._jobs[job_id].message = message
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "cancelled"
self._jobs[job_id].message = message
logger.info("Задание %s отменено: %s", job_id, message)
async def get(self, job_id: str) -> JobRecord | None: async def get(self, job_id: str) -> JobRecord | None:
async with self._lock: async with self._lock:
return self._jobs.get(job_id) return self._jobs.get(job_id)
+1 -1
View File
@@ -6,6 +6,6 @@
|------|------------| |------|------------|
| [api_routes.md](api_routes.md) | Полное описание REST API `/api/v1/*` с примерами JSON (ориентир для фронтенда и клиентов). | | [api_routes.md](api_routes.md) | Полное описание REST API `/api/v1/*` с примерами JSON (ориентир для фронтенда и клиентов). |
После запуска сервиса доступны интерактивно: **Swagger**`/docs`, **ReDoc**`/redoc` (тот же порт, что и веб-UI). После запуска: **Swagger**`/docs`, **ReDoc**`/redoc`, **Health**`/api/v1/health` (тот же порт, что и UI). С дашборда эти ссылки открываются в **отдельном окне** браузера.
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
+31 -4
View File
@@ -7,15 +7,18 @@
| Способ | URL / путь | | Способ | URL / путь |
|--------|------------| |--------|------------|
| Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт по умолчанию **6000**, см. `KIND_K8S_WEB_PORT`) | | Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) |
| ReDoc | `http://127.0.0.1:<порт>/redoc` | | ReDoc | `http://127.0.0.1:<порт>/redoc` |
| Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` |
| Этот файл | `app/docs/api_routes.md` в репозитории | | Этот файл | `app/docs/api_routes.md` в репозитории |
С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно).
## Веб-интерфейс и статика (не JSON) ## Веб-интерфейс и статика (не JSON)
| Маршрут | Описание | | Маршрут | Описание |
|---------|----------| |---------|----------|
| `GET /` | HTML-панель: статус среды (kind, kubectl, Docker/Podman), статистика, форма создания кластера, таблицы кластеров и заданий, модальное окно «узлы / поды», ссылки на API. | | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, отмена), таблицы (автообновление ~3,5 с), модалка узлов/подов; в шапке — меню-пилюли и отдельные окна для Swagger / ReDoc / Health. |
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
@@ -37,13 +40,16 @@
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| GET | `/api/v1/jobs` | Последние задания создания | | GET | `/api/v1/jobs` | Последние задания создания |
| GET | `/api/v1/jobs/{job_id}` | Статус одного задания | | GET | `/api/v1/jobs/{job_id}` | Статус одного задания (включая `progress_stage`, `progress_percent`) |
| POST | `/api/v1/jobs/{job_id}/cancel` | Запросить отмену создания (между этапами; `kind create` до конца не прерывается) |
### Фоновые задания (jobs) ### Фоновые задания (jobs)
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется. - Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
- Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается.
--- ---
@@ -170,13 +176,34 @@
"cluster_name": "dev", "cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00", "created_at_utc": "2026-04-04T12:00:00+00:00",
"message": "Кластер создан", "message": "Кластер создан",
"result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" } "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" },
"progress_stage": null,
"progress_percent": null
} }
] ]
``` ```
--- ---
## POST /api/v1/jobs/{job_id}/cancel
Запрос отмены создания кластера. Пока задание в статусе `queued` или `running`, между этапами выполняется проверка флага; после уже запущенного `kind create cluster` нужно дождаться окончания этого шага.
**Пример ответа 200:**
```json
{
"job_id": "a1b2…",
"cancel_requested": true,
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага"
}
```
**Ошибка 400:** задание уже завершено.
**Ошибка 404:** неизвестный `job_id`.
---
## GET /api/v1/clusters/{name}/kubeconfig ## GET /api/v1/clusters/{name}/kubeconfig
Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`). Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`).
+6 -5
View File
@@ -1,4 +1,6 @@
"""Веб-интерфейс и REST API для управления локальными кластерами kind (порт по умолчанию 6000). """Веб-интерфейс и REST API для управления локальными кластерами kind.
В контейнере uvicorn слушает порт 6000; на хост публикация по умолчанию 8080 (``KIND_K8S_WEB_PORT``), т.к. 6000 на хосте блокируется Chrome (ERR_UNSAFE_PORT).
Запуск в контейнере: ``python3 -m uvicorn main:app --host 0.0.0.0 --port 6000`` из каталога ``/opt/kind-k8s/app`` Запуск в контейнере: ``python3 -m uvicorn main:app --host 0.0.0.0 --port 6000`` из каталога ``/opt/kind-k8s/app``
или через ``make docker up`` / ``make podman up``. или через ``make docker up`` / ``make podman up``.
@@ -65,12 +67,11 @@ async def dashboard(request: Request) -> HTMLResponse:
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>", content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
status_code=500, status_code=500,
) )
# Starlette ≥0.37: первым аргументом обязателен Request (иначе dict уйдёт в get_template → unhashable type).
return templates.TemplateResponse( return templates.TemplateResponse(
request,
"dashboard.html", "dashboard.html",
{ {"app_title": settings.app_title},
"request": request,
"app_title": settings.app_title,
},
) )
+3 -1
View File
@@ -36,11 +36,13 @@ class JobView(BaseModel):
job_id: str job_id: str
kind: str kind: str
status: Literal["queued", "running", "success", "failed"] status: Literal["queued", "running", "success", "failed", "cancelled"]
cluster_name: str | None cluster_name: str | None
created_at_utc: str created_at_utc: str
message: str | None = None message: str | None = None
result: dict[str, Any] | None = None result: dict[str, Any] | None = None
progress_stage: str | None = Field(default=None, description="Текущий этап создания (пока задание активно)")
progress_percent: int | None = Field(default=None, description="Прогресс 0–100 для индикатора в UI")
class ClusterSummary(BaseModel): class ClusterSummary(BaseModel):
+99 -50
View File
@@ -1,5 +1,6 @@
/** /**
* Панель управления кластерами kind (REST /api/v1). * Панель управления кластерами kind (REST /api/v1).
* Автообновление списков и health; прогресс и отмена создания кластера.
* *
* Автор: Сергей Антропов * Автор: Сергей Антропов
* Сайт: https://devops.org.ru * Сайт: https://devops.org.ru
@@ -10,11 +11,18 @@
const body = document.body; const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Интервал опроса списков и среды (мс) */
var AUTO_REFRESH_MS = 3500;
/** Интервал опроса задания создания (мс) */
var JOB_POLL_MS = 1500;
/** @type {ReturnType<typeof setInterval> | null} */ /** @type {ReturnType<typeof setInterval> | null} */
let autoTimer = null; var autoTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */ /** @type {ReturnType<typeof setInterval> | null} */
let pollTimer = null; var pollTimer = null;
let createInProgress = false; var createInProgress = false;
/** @type {string | null} */
var currentPollJobId = null;
function formatApiError(data, fallback) { function formatApiError(data, fallback) {
if (!data) return fallback; if (!data) return fallback;
@@ -37,10 +45,10 @@
const url = path.startsWith("http") ? path : API + path; const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, opts); const r = await fetch(url, opts);
const text = await r.text(); const text = await r.text();
let data; var data;
try { try {
data = text ? JSON.parse(text) : null; data = text ? JSON.parse(text) : null;
} catch { } catch (e) {
data = { raw: text }; data = { raw: text };
} }
if (!r.ok) { if (!r.ok) {
@@ -81,6 +89,16 @@
el.classList.toggle("is-loading", busy); el.classList.toggle("is-loading", busy);
} }
function setStatusBannerClass(ok, degraded) {
const el = document.getElementById("status-banner");
if (!el) return;
var cls = "hero-panel-status muted ";
if (ok) cls += "ok";
else if (degraded) cls += "degraded";
else cls += "err";
el.className = cls;
}
async function loadHealth() { async function loadHealth() {
const el = document.getElementById("status-banner"); const el = document.getElementById("status-banner");
if (!el) return; if (!el) return;
@@ -88,8 +106,8 @@
const h = await api("/health"); const h = await api("/health");
const ok = const ok =
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path; h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
el.className = "status-banner " + (ok ? "ok" : "degraded"); setStatusBannerClass(ok, !ok);
let lines = "<strong>Среда:</strong> "; var lines = "<strong>Среда:</strong> ";
lines += escapeHtml(String(h.container_cli || "?")); lines += escapeHtml(String(h.container_cli || "?"));
lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен"); lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен");
lines += " · kind: " + (h.kind_in_path ? "да" : "нет"); lines += " · kind: " + (h.kind_in_path ? "да" : "нет");
@@ -102,7 +120,7 @@
} }
el.innerHTML = lines; el.innerHTML = lines;
} catch (e) { } catch (e) {
el.className = "status-banner err"; setStatusBannerClass(false, false);
el.textContent = "Не удалось запросить health: " + e.message; el.textContent = "Не удалось запросить health: " + e.message;
} }
} }
@@ -161,7 +179,7 @@
sel.onchange = function () { sel.onchange = function () {
if (sel.value) verInput.value = sel.value.replace(/^v/, ""); if (sel.value) verInput.value = sel.value.replace(/^v/, "");
}; };
} catch { } catch (e) {
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>"; sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
} }
} }
@@ -170,6 +188,7 @@
if (status === "success") return "badge badge-ok"; if (status === "success") return "badge badge-ok";
if (status === "failed") return "badge badge-err"; if (status === "failed") return "badge badge-err";
if (status === "running") return "badge badge-run"; if (status === "running") return "badge badge-run";
if (status === "cancelled") return "badge badge-cancelled";
return "badge"; return "badge";
} }
@@ -253,6 +272,10 @@
rows.forEach(function (j) { rows.forEach(function (j) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const st = escapeHtml(j.status || ""); const st = escapeHtml(j.status || "");
var cellMsg = (j.message || "").slice(0, 160);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
tr.innerHTML = tr.innerHTML =
"<td><time datetime=\"" + "<td><time datetime=\"" +
escapeHtml(j.created_at_utc || "") + escapeHtml(j.created_at_utc || "") +
@@ -268,7 +291,7 @@
st + st +
"</span></td>" + "</span></td>" +
"<td class=\"jobs-msg-cell\">" + "<td class=\"jobs-msg-cell\">" +
escapeHtml((j.message || "").slice(0, 160)) + escapeHtml(cellMsg) +
"</td>"; "</td>";
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -348,38 +371,80 @@
}); });
} }
function showCreateProgress(show) {
const wrap = document.getElementById("create-progress-wrap");
if (!wrap) return;
wrap.classList.toggle("hidden", !show);
wrap.setAttribute("aria-hidden", show ? "false" : "true");
}
function updateCreateProgressFromJob(j) {
const bar = document.getElementById("create-progress-bar");
const track = document.getElementById("create-progress-track");
const label = document.getElementById("create-progress-label");
var pct = j.progress_percent != null ? Number(j.progress_percent) : 0;
if (isNaN(pct)) pct = 0;
pct = Math.max(0, Math.min(100, pct));
var stage = j.progress_stage || (j.status === "queued" ? "В очереди…" : "…");
if (bar) bar.style.width = pct + "%";
if (track) {
track.setAttribute("aria-valuenow", String(pct));
}
if (label) label.textContent = stage;
}
function stopPollJob() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
currentPollJobId = null;
createInProgress = false;
setCreateFormDisabled(false);
showCreateProgress(false);
}
async function requestCancelJob() {
if (!currentPollJobId) return;
try {
await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
showToast("Запрос отмены отправлен (между этапами)", false);
} catch (e) {
showToast(e.message || "Не удалось отменить", true);
}
}
function pollJob(jobId) { function pollJob(jobId) {
const pre = document.getElementById("job-status");
const details = document.getElementById("job-details"); const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg"); const msg = document.getElementById("create-msg");
if (pre) pre.classList.add("hidden");
if (details) { if (details) {
details.classList.remove("hidden"); details.classList.remove("hidden");
details.open = true; details.open = false;
} }
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
createInProgress = true; createInProgress = true;
currentPollJobId = jobId;
setCreateFormDisabled(true); setCreateFormDisabled(true);
showCreateProgress(true);
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
const tick = async function () { const tick = async function () {
try { try {
const j = await api("/jobs/" + jobId); const j = await api("/jobs/" + jobId);
const preEl = document.getElementById("job-json"); const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2); if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
if (j.status === "success" || j.status === "failed") { updateCreateProgressFromJob(j);
clearInterval(pollTimer);
pollTimer = null; if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
createInProgress = false; stopPollJob();
setCreateFormDisabled(false);
if (msg) { if (msg) {
msg.textContent = if (j.status === "success") msg.textContent = "Кластер создан.";
j.status === "success" ? "Кластер создан." : "Ошибка: " + (j.message || ""); else if (j.status === "cancelled") msg.textContent = j.message || "Создание отменено.";
} else msg.textContent = "Ошибка: " + (j.message || "");
if (j.status === "success") {
showToast("Кластер создан", false);
} else {
showToast(j.message || "Ошибка создания", true);
} }
if (j.status === "success") showToast("Кластер создан", false);
else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка создания", true);
await loadClusters(); await loadClusters();
await loadStats(); await loadStats();
await loadJobs(); await loadJobs();
@@ -389,10 +454,10 @@
} }
}; };
tick(); tick();
pollTimer = setInterval(tick, 2000); pollTimer = setInterval(tick, JOB_POLL_MS);
} }
function refreshAll() { function refreshLists() {
loadHealth(); loadHealth();
loadStats(); loadStats();
loadClusters(); loadClusters();
@@ -430,17 +495,12 @@
}); });
} }
const bStats = document.getElementById("btn-refresh-stats"); const btnCancel = document.getElementById("btn-cancel-create");
if (bStats) bStats.addEventListener("click", function () { loadHealth(); loadStats(); }); if (btnCancel) {
btnCancel.addEventListener("click", function () {
const bList = document.getElementById("btn-refresh-list"); requestCancelJob();
if (bList) bList.addEventListener("click", loadClusters); });
}
const bJobs = document.getElementById("btn-refresh-jobs");
if (bJobs) bJobs.addEventListener("click", loadJobs);
const bAll = document.getElementById("btn-refresh-all");
if (bAll) bAll.addEventListener("click", refreshAll);
const mClose = document.getElementById("modal-close"); const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal); if (mClose) mClose.addEventListener("click", closeModal);
@@ -456,18 +516,7 @@
if (ev.key === "Escape") closeModal(); if (ev.key === "Escape") closeModal();
}); });
const auto = document.getElementById("auto-refresh"); autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
if (auto) {
auto.addEventListener("change", function (ev) {
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
}
if (ev.target.checked) {
autoTimer = setInterval(refreshAll, 15000);
}
});
}
loadHealth(); loadHealth();
loadStats(); loadStats();
+160 -3
View File
@@ -88,14 +88,80 @@ body.modal-open {
.nav-links { .nav-links {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem 1rem; align-items: center;
gap: 0.45rem;
} }
.nav-link {
/* Пункты меню: компактные «пилюли» */
.nav-link.nav-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.95rem;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.02em;
text-decoration: none;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.18);
color: var(--fg);
transition:
background 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease,
transform 0.12s ease;
}
.nav-link.nav-pill:hover {
text-decoration: none;
border-color: var(--accent);
background: rgba(59, 130, 246, 0.14);
color: var(--accent);
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.2);
transform: translateY(-1px);
}
.nav-link.nav-pill:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* Текущее приложение — акцентная пилюля */
.nav-link.nav-pill--home {
background: linear-gradient(145deg, rgba(59, 130, 246, 0.28), rgba(59, 130, 246, 0.1));
border-color: rgba(59, 130, 246, 0.55);
color: #93c5fd;
}
@media (prefers-color-scheme: light) {
.nav-link.nav-pill--home {
color: #1d4ed8;
}
}
.nav-link.nav-pill--home:hover {
color: var(--accent);
background: linear-gradient(145deg, rgba(59, 130, 246, 0.35), rgba(59, 130, 246, 0.15));
}
/* Внешние окна: иконка «новое окно» */
.nav-link.nav-pill--ext::after {
content: "↗";
margin-left: 0.35rem;
font-size: 0.72em;
opacity: 0.72;
font-weight: 700;
}
/* Обычная ссылка без пилюли (если понадобится) */
.nav-link:not(.nav-pill) {
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
} }
.nav-link:hover { .nav-link:not(.nav-pill):hover {
text-decoration: underline; text-decoration: underline;
} }
@@ -114,6 +180,93 @@ body.modal-open {
font-size: 0.85rem; font-size: 0.85rem;
} }
.footer-inner {
text-align: center;
}
.footer-line {
margin: 0 0 0.5rem;
}
.footer-copyright {
margin: 0;
font-size: 0.9rem;
color: var(--fg);
}
.footer-copyright a {
color: var(--accent);
font-weight: 600;
}
/* Одна карточка: заголовок + описание + строка состояния среды */
.hero-panel {
margin-bottom: 1rem;
}
.hero-panel-main {
margin-bottom: 0.85rem;
}
.hero-panel .page-title {
margin: 0 0 0.35rem;
}
.hero-panel .page-lead {
margin: 0;
max-width: 48rem;
}
.hero-panel-status {
margin: 0;
border-radius: 8px;
padding: 0.65rem 0.85rem;
font-size: 0.9rem;
border: 1px solid var(--border);
}
.hero-panel-status.ok {
border-color: #15803d;
background: rgba(34, 197, 94, 0.12);
}
.hero-panel-status.degraded {
border-color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.hero-panel-status.err {
border-color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
}
/* Прогресс создания кластера */
.create-progress-wrap {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.create-progress-hint {
margin: 0 0 0.65rem;
font-size: 0.85rem;
}
.progress-track {
height: 0.65rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), #60a5fa);
transition: width 0.35s ease;
}
.progress-label {
margin: 0.45rem 0 0.65rem;
font-size: 0.88rem;
min-height: 1.35rem;
}
#create-progress-wrap .btn-secondary {
margin-top: 0;
}
.git-hint {
font-size: 0.85rem;
margin: 0 0 0.65rem;
}
.page-intro { .page-intro {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -173,6 +326,10 @@ body.modal-open {
border-color: #b45309; border-color: #b45309;
color: #fbbf24; color: #fbbf24;
} }
.badge-cancelled {
border-color: #6b7280;
color: #d1d5db;
}
.cluster-name { .cluster-name {
font-weight: 600; font-weight: 600;
+25 -6
View File
@@ -24,10 +24,10 @@
<span class="nav-tag muted">kind · локальные кластеры</span> <span class="nav-tag muted">kind · локальные кластеры</span>
</div> </div>
<nav class="nav-links" aria-label="Разделы"> <nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link">Панель</a> <a href="/" class="nav-link nav-pill nav-pill--home">Панель</a>
<a href="/docs" class="nav-link" target="_blank" rel="noopener">Swagger</a> <a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a>
<a href="/redoc" class="nav-link" target="_blank" rel="noopener">ReDoc</a> <a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
<a href="/api/v1/health" class="nav-link" target="_blank" rel="noopener">Health</a> <a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -38,12 +38,31 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="footer muted app-footer"> <footer class="footer app-footer">
{% block footer %} {% block footer %}
<span>Данные: том <code>clusters/</code> на хосте · <code>app/docs/api_routes.md</code></span> <div class="footer-inner">
<p class="muted footer-line">Данные: том <code>clusters/</code> на хосте</p>
<p class="footer-copyright">
© kind-k8s-develop ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %} {% endblock %}
</footer> </footer>
{# Отдельное окно браузера для документации и health (не вкладка). #}
<script>
(function () {
var opts = "noopener,noreferrer,width=1240,height=840,scrollbars=yes,resizable=yes";
document.querySelectorAll(".nav-link[data-open-window]").forEach(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
var name = a.getAttribute("data-open-window") || "kind_ext";
window.open(a.getAttribute("href"), name, opts);
});
});
})();
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+54 -33
View File
@@ -4,39 +4,41 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block footer %} {% block footer %}
<span>Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code></span> <div class="footer-inner">
<p class="footer-line muted">
Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code> (не в Git)
</p>
<p class="footer-copyright">
© kind-k8s-develop ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="page-intro"> <section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
<h1 class="page-title">Панель управления</h1> <div class="hero-panel-main">
<p class="muted page-lead"> <h1 class="page-title" id="hero-title">Панель управления</h1>
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера. <p class="muted page-lead">
</p> Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
</div> Данные обновляются автоматически.
</p>
<div id="status-banner" class="status-banner muted" role="status" aria-live="polite">
Проверка среды…
</div>
<div class="toolbar row spread">
<div class="row" style="margin-top:0;align-items:center;gap:0.75rem">
<button type="button" id="btn-refresh-all" class="btn-secondary">Обновить всё</button>
<label class="row" style="margin-top:0;align-items:center;gap:0.35rem">
<input type="checkbox" id="auto-refresh" />
авто каждые 15 с
</label>
</div> </div>
</div> <div
id="status-banner"
class="hero-panel-status muted"
role="status"
aria-live="polite"
>
Проверка среды…
</div>
</section>
<section class="grid"> <section class="grid">
<div class="card"> <div class="card">
<h2>Статистика</h2> <h2>Статистика</h2>
<dl id="stats-dl" class="stats-dl" aria-label="Сводка по кластерам и заданиям"></dl> <dl id="stats-dl" class="stats-dl" aria-label="Сводка по кластерам и заданиям"></dl>
<p id="stats-err" class="msg hidden" role="alert"></p> <p id="stats-err" class="msg hidden" role="alert"></p>
<div class="row">
<button type="button" id="btn-refresh-stats">Обновить статистику</button>
</div>
</div> </div>
<div class="card"> <div class="card">
@@ -74,21 +76,43 @@
<label for="fld-workers">Worker-ноды (020)</label> <label for="fld-workers">Worker-ноды (020)</label>
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" /> <input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
<button type="submit">Создать кластер</button> <div class="row create-actions">
<button type="submit">Создать кластер</button>
</div>
</form> </form>
<div id="create-progress-wrap" class="create-progress-wrap hidden" aria-hidden="true">
<p class="create-progress-hint muted" id="create-progress-hint">
Идёт создание — шаг <code>kind create</code> может занять несколько минут при первом pull образов.
</p>
<div
class="progress-track"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-labelledby="create-progress-label"
id="create-progress-track"
>
<div class="progress-bar" id="create-progress-bar" style="width:0%"></div>
</div>
<p class="progress-label" id="create-progress-label"></p>
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить создание</button>
</div>
<p id="create-msg" class="msg" aria-live="polite"></p> <p id="create-msg" class="msg" aria-live="polite"></p>
<details id="job-details" class="job-details hidden"> <details id="job-details" class="job-details hidden">
<summary>Ход задания (JSON)</summary> <summary>Технические детали (JSON)</summary>
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre> <pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
</details> </details>
</div> </div>
</section> </section>
<section class="card clusters-card"> <section class="card clusters-card">
<div class="row spread"> <h2>Кластеры</h2>
<h2>Кластеры</h2> <p class="muted git-hint">
<button type="button" class="btn-secondary" id="btn-refresh-list">Обновить список</button> Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
</div> </p>
<div class="table-wrap" data-busy="clusters"> <div class="table-wrap" data-busy="clusters">
<table id="tbl-clusters" aria-label="Список кластеров"> <table id="tbl-clusters" aria-label="Список кластеров">
<thead> <thead>
@@ -108,10 +132,7 @@
</section> </section>
<section class="card jobs-card"> <section class="card jobs-card">
<div class="row spread"> <h2>Последние задания</h2>
<h2>Последние задания</h2>
<button type="button" class="btn-secondary" id="btn-refresh-jobs">Обновить</button>
</div>
<div class="table-wrap" data-busy="jobs"> <div class="table-wrap" data-busy="jobs">
<table id="tbl-jobs" aria-label="Последние задания создания"> <table id="tbl-jobs" aria-label="Последние задания создания">
<thead> <thead>
+2 -1
View File
@@ -23,8 +23,9 @@ services:
# Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна). # Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна).
- ./app:/opt/kind-k8s/app - ./app:/opt/kind-k8s/app
working_dir: /opt/kind-k8s/app working_dir: /opt/kind-k8s/app
# Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000.
ports: ports:
- "${KIND_K8S_WEB_PORT:-6000}:6000" - "${KIND_K8S_WEB_PORT:-8080}:6000"
environment: environment:
DOCKER_HOST: unix:///var/run/docker.sock DOCKER_HOST: unix:///var/run/docker.sock
KIND_K8S_IN_CONTAINER: "1" KIND_K8S_IN_CONTAINER: "1"
+4 -4
View File
@@ -31,7 +31,7 @@ _SETUP_DEFAULTS: dict[str, str] = {
"KIND_VERSION": "0.24.0", "KIND_VERSION": "0.24.0",
"KUBECTL_VERSION": "v1.32.0", "KUBECTL_VERSION": "v1.32.0",
"CONTAINER_SOCKET": "/var/run/docker.sock", "CONTAINER_SOCKET": "/var/run/docker.sock",
"KIND_K8S_WEB_PORT": "6000", "KIND_K8S_WEB_PORT": "8080",
"KIND_K8S_PATCH_KUBECONFIG": "1", "KIND_K8S_PATCH_KUBECONFIG": "1",
"CONTAINER_CLI": "docker", "CONTAINER_CLI": "docker",
"KIND_K8S_SKIP_VERSION_LIST": "", "KIND_K8S_SKIP_VERSION_LIST": "",
@@ -40,7 +40,7 @@ _SETUP_DEFAULTS: dict[str, str] = {
"KIND_K8S_DEBUG": "", "KIND_K8S_DEBUG": "",
"KIND_K8S_WAIT_NODES": "1", "KIND_K8S_WAIT_NODES": "1",
"KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300", "KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300",
"KIND_K8S_APP_TITLE": "kind-k8s-develop", "KIND_K8S_APP_TITLE": "Kind Clusters Dashboard",
"KIND_K8S_UVICORN_RELOAD": "1", "KIND_K8S_UVICORN_RELOAD": "1",
"KIND_K8S_WEB_HOST": "0.0.0.0", "KIND_K8S_WEB_HOST": "0.0.0.0",
"KIND_K8S_WORKDIR": "", "KIND_K8S_WORKDIR": "",
@@ -88,9 +88,9 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
"Путь к сокету на хосте для volume (Docker: /var/run/docker.sock; Podman rootless — см. README).", "Путь к сокету на хосте для volume (Docker: /var/run/docker.sock; Podman rootless — см. README).",
), ),
_EnvPrompt( _EnvPrompt(
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000) ---", "# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000 внутри) ---",
"KIND_K8S_WEB_PORT", "KIND_K8S_WEB_PORT",
"Порт на хосте для веб-интерфейса; внутри контейнера всегда 6000.", "Порт на хосте для браузера (дефолт 8080 — 6000 на хосте блокирует Chrome как ERR_UNSAFE_PORT); внутри контейнера uvicorn слушает 6000.",
), ),
_EnvPrompt( _EnvPrompt(
"# --- docker-compose.yml: environment сервиса kind-k8s-web ---", "# --- docker-compose.yml: environment сервиса kind-k8s-web ---",