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:
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
# Сгенерированные конфиги и kubeconfig локальных кластеров kind
|
# Сгенерированные конфиги и kubeconfig локальных кластеров kind.
|
||||||
|
# Содержимое clusters/<имя>/ в репозиторий не попадает (только clusters/.gitkeep).
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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,6 +195,7 @@ 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:
|
||||||
|
try:
|
||||||
async with kind_cluster_lock:
|
async with kind_cluster_lock:
|
||||||
await job_store.set_running(job_id)
|
await job_store.set_running(job_id)
|
||||||
try:
|
try:
|
||||||
@@ -195,9 +204,14 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
|||||||
name=body.name.strip(),
|
name=body.name.strip(),
|
||||||
kubernetes_version_tag=body.kubernetes_version.strip(),
|
kubernetes_version_tag=body.kubernetes_version.strip(),
|
||||||
workers=body.workers,
|
workers=body.workers,
|
||||||
|
job_id=job_id,
|
||||||
)
|
)
|
||||||
except KindClusterError as e:
|
except KindClusterError as e:
|
||||||
await job_store.set_failed(job_id, str(e))
|
msg = str(e)
|
||||||
|
if "отменено" in msg.lower():
|
||||||
|
await job_store.set_cancelled(job_id, msg)
|
||||||
|
else:
|
||||||
|
await job_store.set_failed(job_id, msg)
|
||||||
logger.warning("create job %s: %s", job_id, e)
|
logger.warning("create job %s: %s", job_id, e)
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -217,6 +231,8 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
|||||||
}
|
}
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<h1 class="page-title" id="hero-title">Панель управления</h1>
|
||||||
<p class="muted page-lead">
|
<p class="muted page-lead">
|
||||||
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
|
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
|
||||||
|
Данные обновляются автоматически.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-ноды (0–20)</label>
|
<label for="fld-workers">Worker-ноды (0–20)</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" />
|
||||||
|
|
||||||
|
<div class="row create-actions">
|
||||||
<button type="submit">Создать кластер</button>
|
<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>
|
||||||
<button type="button" class="btn-secondary" id="btn-refresh-list">Обновить список</button>
|
<p class="muted git-hint">
|
||||||
</div>
|
Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
|
||||||
|
</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
@@ -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"
|
||||||
|
|||||||
@@ -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 ---",
|
||||||
|
|||||||
Reference in New Issue
Block a user