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
.env
+12 -8
View File
@@ -1,10 +1,10 @@
# 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 только с явным выбором среды:
# make docker up | make docker down | make docker logs | …
# 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
@@ -14,7 +14,7 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS)))
COMPOSE := docker compose
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))))
SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py
@@ -24,9 +24,10 @@ COMPOSE_BUILD_FLAGS ?=
help: ## Справка по целям
@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 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 check-docker / make podman check-docker"
@echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)."
@@ -38,12 +39,12 @@ docker: ## Маркер среды: задайте вторую цель (нап
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:
@if [ -z "$(COMPOSE)" ]; then \
echo >&2 "Укажите среду в той же команде, что и цель:"; \
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)"; \
exit 1; \
fi
@@ -54,9 +55,12 @@ up: _require_runtime clusters-dir compose-build ## (с docker/podman) Подня
down: _require_runtime ## (с docker/podman) Остановить compose в этом каталоге
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
ps: _require_runtime ## (с docker/podman) Статус контейнеров compose-проекта
cd "$(KIND_K8S_DIR)" && $(COMPOSE) ps
setup: ## Интерактивно создать .env (scripts/setup_env_interactive.py; нужен python3 на хосте)
@$(PYTHON) "$(SETUP_ENV_SCRIPT)"
+16 -11
View File
@@ -1,6 +1,6 @@
# 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)
@@ -10,7 +10,7 @@
|--------|----------|
| **[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/` |
| **`/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`).
@@ -23,14 +23,18 @@
## Веб-интерфейс
- Плашка **состояния среды**: наличие `kind`/`kubectl`, доступность Docker/Podman API по сокету.
- Верхняя **единая карточка**: заголовок, краткое описание и строка **состояния среды** (`kind` / `kubectl` / Docker или Podman API).
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
- **Таблица кластеров**: признаки регистрации в 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 docker check-docker # или: make podman check-docker
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 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 без пересборки образа
@@ -92,14 +96,15 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \
| `make help` | Краткая справка |
| `make docker up` / `make podman up` | Поднять веб-UI (`kind-k8s-web`) |
| `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 check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` |
| `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) |
| `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 при сборке образа |
| **`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_UVICORN_RELOAD`** | контейнер | `1` (по умолчанию) — hot-reload при правках в `./app`; `0` — без reload |
| **`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,
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 kind_k8s_paths import clusters_dir
from models.schemas import (
@@ -41,6 +41,25 @@ logger = logging.getLogger("kind_k8s.api.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:
"""Собрать статистику (синхронно; вызывать из thread при необходимости)."""
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]:
"""История создания кластеров (в памяти процесса; после перезапуска контейнера пусто)."""
items = job_store.snapshot_recent_sorted(limit=limit)
return [
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
]
return [_record_to_job_view(r) for r in items]
@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 with kind_cluster_lock:
await job_store.set_running(job_id)
try:
result = await asyncio.to_thread(
create_cluster_non_interactive,
name=body.name.strip(),
kubernetes_version_tag=body.kubernetes_version.strip(),
workers=body.workers,
)
except KindClusterError as e:
await job_store.set_failed(job_id, str(e))
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
try:
async with kind_cluster_lock:
await job_store.set_running(job_id)
try:
result = await asyncio.to_thread(
create_cluster_non_interactive,
name=body.name.strip(),
kubernetes_version_tag=body.kubernetes_version.strip(),
workers=body.workers,
job_id=job_id,
)
except KindClusterError as 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)
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] = {
"cluster_name": result.cluster_name,
"kubernetes_version_tag": result.ver_tag,
"node_image": result.node_image,
"workers": result.workers,
"kubeconfig_path": str(result.kubeconfig_path),
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
"nodes_ready": result.nodes_ready,
"nodes_ready_message": result.nodes_ready_message,
}
await job_store.set_success(job_id, result=payload, message="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
payload: dict[str, Any] = {
"cluster_name": result.cluster_name,
"kubernetes_version_tag": result.ver_tag,
"node_image": result.node_image,
"workers": result.workers,
"kubeconfig_path": str(result.kubeconfig_path),
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
"nodes_ready": result.nodes_ready,
"nodes_ready_message": result.nodes_ready_message,
}
await job_store.set_success(job_id, result=payload, message="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
end_job_tracking(job_id)
@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}
@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="Статус одного задания")
async def get_job(job_id: str) -> JobView:
"""Узнать состояние фонового создания кластера."""
rec = await job_store.get(job_id)
if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено")
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,
)
return _record_to_job_view(rec)
+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")
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)
_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,
kubernetes_version_tag: str,
workers: int,
job_id: str | None = None,
) -> CreateClusterResult:
"""
Создать кластер kind без диалогов.
``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"):
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)
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)
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
_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_path.write_text(kube, encoding="utf-8")
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
patched = False
if should_patch_after_create():
_progress("Патч kubeconfig для доступа к API с хоста", 72)
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_msg: str | None = None
if _wait_nodes_enabled():
_progress("Ожидание готовности нод (kubectl wait …)", 82)
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
nodes_ready = ok
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")
_progress("Финализация", 95)
return CreateClusterResult(
cluster_name=name,
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_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.
app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE")
+66 -1
View File
@@ -2,6 +2,8 @@
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
@@ -10,6 +12,7 @@ from __future__ import annotations
import asyncio
import logging
import threading
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -20,7 +23,58 @@ logger = logging.getLogger("kind_k8s.job_store")
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
_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
@@ -58,8 +112,10 @@ class JobStore:
self._jobs[jid] = rec
while len(self._jobs) > _MAX_JOBS:
oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc)
end_job_tracking(oldest_id)
del self._jobs[oldest_id]
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
begin_job_tracking(jid)
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec
@@ -68,6 +124,7 @@ class JobStore:
if job_id in self._jobs:
self._jobs[job_id].status = "running"
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 with self._lock:
@@ -75,6 +132,7 @@ class JobStore:
self._jobs[job_id].status = "success"
self._jobs[job_id].result = result
self._jobs[job_id].message = message
set_progress_sync(job_id, "Готово", 100)
async def set_failed(self, job_id: str, message: str) -> None:
async with self._lock:
@@ -83,6 +141,13 @@ class JobStore:
self._jobs[job_id].message = 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 with self._lock:
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 (ориентир для фронтенда и клиентов). |
После запуска сервиса доступны интерактивно: **Swagger**`/docs`, **ReDoc**`/redoc` (тот же порт, что и веб-UI).
После запуска: **Swagger**`/docs`, **ReDoc**`/redoc`, **Health**`/api/v1/health` (тот же порт, что и UI). С дашборда эти ссылки открываются в **отдельном окне** браузера.
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
+31 -4
View File
@@ -7,15 +7,18 @@
| Способ | 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` |
| Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` |
| Этот файл | `app/docs/api_routes.md` в репозитории |
С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно).
## Веб-интерфейс и статика (не JSON)
| Маршрут | Описание |
|---------|----------|
| `GET /` | HTML-панель: статус среды (kind, kubectl, Docker/Podman), статистика, форма создания кластера, таблицы кластеров и заданий, модальное окно «узлы / поды», ссылки на API. |
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, отмена), таблицы (автообновление ~3,5 с), модалка узлов/подов; в шапке — меню-пилюли и отдельные окна для Swagger / ReDoc / Health. |
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
| `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`) |
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| 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)
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Создание кластера: `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",
"created_at_utc": "2026-04-04T12:00:00+00:00",
"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
Скачать файл `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``
или через ``make docker up`` / ``make podman up``.
@@ -65,12 +67,11 @@ async def dashboard(request: Request) -> HTMLResponse:
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
status_code=500,
)
# Starlette ≥0.37: первым аргументом обязателен Request (иначе dict уйдёт в get_template → unhashable type).
return templates.TemplateResponse(
request,
"dashboard.html",
{
"request": request,
"app_title": settings.app_title,
},
{"app_title": settings.app_title},
)
+3 -1
View File
@@ -36,11 +36,13 @@ class JobView(BaseModel):
job_id: str
kind: str
status: Literal["queued", "running", "success", "failed"]
status: Literal["queued", "running", "success", "failed", "cancelled"]
cluster_name: str | None
created_at_utc: str
message: str | 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):
+99 -50
View File
@@ -1,5 +1,6 @@
/**
* Панель управления кластерами kind (REST /api/v1).
* Автообновление списков и health; прогресс и отмена создания кластера.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
@@ -10,11 +11,18 @@
const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Интервал опроса списков и среды (мс) */
var AUTO_REFRESH_MS = 3500;
/** Интервал опроса задания создания (мс) */
var JOB_POLL_MS = 1500;
/** @type {ReturnType<typeof setInterval> | null} */
let autoTimer = null;
var autoTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */
let pollTimer = null;
let createInProgress = false;
var pollTimer = null;
var createInProgress = false;
/** @type {string | null} */
var currentPollJobId = null;
function formatApiError(data, fallback) {
if (!data) return fallback;
@@ -37,10 +45,10 @@
const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, opts);
const text = await r.text();
let data;
var data;
try {
data = text ? JSON.parse(text) : null;
} catch {
} catch (e) {
data = { raw: text };
}
if (!r.ok) {
@@ -81,6 +89,16 @@
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() {
const el = document.getElementById("status-banner");
if (!el) return;
@@ -88,8 +106,8 @@
const h = await api("/health");
const ok =
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
el.className = "status-banner " + (ok ? "ok" : "degraded");
let lines = "<strong>Среда:</strong> ";
setStatusBannerClass(ok, !ok);
var lines = "<strong>Среда:</strong> ";
lines += escapeHtml(String(h.container_cli || "?"));
lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен");
lines += " · kind: " + (h.kind_in_path ? "да" : "нет");
@@ -102,7 +120,7 @@
}
el.innerHTML = lines;
} catch (e) {
el.className = "status-banner err";
setStatusBannerClass(false, false);
el.textContent = "Не удалось запросить health: " + e.message;
}
}
@@ -161,7 +179,7 @@
sel.onchange = function () {
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
};
} catch {
} catch (e) {
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
}
}
@@ -170,6 +188,7 @@
if (status === "success") return "badge badge-ok";
if (status === "failed") return "badge badge-err";
if (status === "running") return "badge badge-run";
if (status === "cancelled") return "badge badge-cancelled";
return "badge";
}
@@ -253,6 +272,10 @@
rows.forEach(function (j) {
const tr = document.createElement("tr");
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 =
"<td><time datetime=\"" +
escapeHtml(j.created_at_utc || "") +
@@ -268,7 +291,7 @@
st +
"</span></td>" +
"<td class=\"jobs-msg-cell\">" +
escapeHtml((j.message || "").slice(0, 160)) +
escapeHtml(cellMsg) +
"</td>";
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) {
const pre = document.getElementById("job-status");
const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg");
if (pre) pre.classList.add("hidden");
if (details) {
details.classList.remove("hidden");
details.open = true;
details.open = false;
}
if (pollTimer) clearInterval(pollTimer);
createInProgress = true;
currentPollJobId = jobId;
setCreateFormDisabled(true);
showCreateProgress(true);
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
const tick = async function () {
try {
const j = await api("/jobs/" + jobId);
const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
if (j.status === "success" || j.status === "failed") {
clearInterval(pollTimer);
pollTimer = null;
createInProgress = false;
setCreateFormDisabled(false);
updateCreateProgressFromJob(j);
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob();
if (msg) {
msg.textContent =
j.status === "success" ? "Кластер создан." : "Ошибка: " + (j.message || "");
}
if (j.status === "success") {
showToast("Кластер создан", false);
} else {
showToast(j.message || "Ошибка создания", true);
if (j.status === "success") msg.textContent = "Кластер создан.";
else if (j.status === "cancelled") msg.textContent = j.message || "Создание отменено.";
else msg.textContent = "Ошибка: " + (j.message || "");
}
if (j.status === "success") showToast("Кластер создан", false);
else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка создания", true);
await loadClusters();
await loadStats();
await loadJobs();
@@ -389,10 +454,10 @@
}
};
tick();
pollTimer = setInterval(tick, 2000);
pollTimer = setInterval(tick, JOB_POLL_MS);
}
function refreshAll() {
function refreshLists() {
loadHealth();
loadStats();
loadClusters();
@@ -430,17 +495,12 @@
});
}
const bStats = document.getElementById("btn-refresh-stats");
if (bStats) bStats.addEventListener("click", function () { loadHealth(); loadStats(); });
const bList = document.getElementById("btn-refresh-list");
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 btnCancel = document.getElementById("btn-cancel-create");
if (btnCancel) {
btnCancel.addEventListener("click", function () {
requestCancelJob();
});
}
const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal);
@@ -456,18 +516,7 @@
if (ev.key === "Escape") closeModal();
});
const auto = document.getElementById("auto-refresh");
if (auto) {
auto.addEventListener("change", function (ev) {
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
}
if (ev.target.checked) {
autoTimer = setInterval(refreshAll, 15000);
}
});
}
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
loadHealth();
loadStats();
+160 -3
View File
@@ -88,14 +88,80 @@ body.modal-open {
.nav-links {
display: flex;
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);
text-decoration: none;
font-size: 0.9rem;
}
.nav-link:hover {
.nav-link:not(.nav-pill):hover {
text-decoration: underline;
}
@@ -114,6 +180,93 @@ body.modal-open {
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 {
margin-bottom: 1rem;
}
@@ -173,6 +326,10 @@ body.modal-open {
border-color: #b45309;
color: #fbbf24;
}
.badge-cancelled {
border-color: #6b7280;
color: #d1d5db;
}
.cluster-name {
font-weight: 600;
+25 -6
View File
@@ -24,10 +24,10 @@
<span class="nav-tag muted">kind · локальные кластеры</span>
</div>
<nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link">Панель</a>
<a href="/docs" class="nav-link" target="_blank" rel="noopener">Swagger</a>
<a href="/redoc" class="nav-link" target="_blank" rel="noopener">ReDoc</a>
<a href="/api/v1/health" class="nav-link" target="_blank" rel="noopener">Health</a>
<a href="/" class="nav-link nav-pill nav-pill--home">Панель</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 nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
<a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
</nav>
</div>
</header>
@@ -38,12 +38,31 @@
{% block content %}{% endblock %}
</main>
<footer class="footer muted app-footer">
<footer class="footer app-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 %}
</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 %}
</body>
</html>
+54 -33
View File
@@ -4,39 +4,41 @@
{% extends "base.html" %}
{% 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 %}
{% block content %}
<div class="page-intro">
<h1 class="page-title">Панель управления</h1>
<p class="muted page-lead">
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
</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>
<section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
<div class="hero-panel-main">
<h1 class="page-title" id="hero-title">Панель управления</h1>
<p class="muted page-lead">
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
Данные обновляются автоматически.
</p>
</div>
</div>
<div
id="status-banner"
class="hero-panel-status muted"
role="status"
aria-live="polite"
>
Проверка среды…
</div>
</section>
<section class="grid">
<div class="card">
<h2>Статистика</h2>
<dl id="stats-dl" class="stats-dl" aria-label="Сводка по кластерам и заданиям"></dl>
<p id="stats-err" class="msg hidden" role="alert"></p>
<div class="row">
<button type="button" id="btn-refresh-stats">Обновить статистику</button>
</div>
</div>
<div class="card">
@@ -74,21 +76,43 @@
<label for="fld-workers">Worker-ноды (020)</label>
<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>
<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>
<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>
</details>
</div>
</section>
<section class="card clusters-card">
<div class="row spread">
<h2>Кластеры</h2>
<button type="button" class="btn-secondary" id="btn-refresh-list">Обновить список</button>
</div>
<h2>Кластеры</h2>
<p class="muted git-hint">
Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
</p>
<div class="table-wrap" data-busy="clusters">
<table id="tbl-clusters" aria-label="Список кластеров">
<thead>
@@ -108,10 +132,7 @@
</section>
<section class="card jobs-card">
<div class="row spread">
<h2>Последние задания</h2>
<button type="button" class="btn-secondary" id="btn-refresh-jobs">Обновить</button>
</div>
<h2>Последние задания</h2>
<div class="table-wrap" data-busy="jobs">
<table id="tbl-jobs" aria-label="Последние задания создания">
<thead>
+2 -1
View File
@@ -23,8 +23,9 @@ services:
# Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна).
- ./app:/opt/kind-k8s/app
working_dir: /opt/kind-k8s/app
# Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000.
ports:
- "${KIND_K8S_WEB_PORT:-6000}:6000"
- "${KIND_K8S_WEB_PORT:-8080}:6000"
environment:
DOCKER_HOST: unix:///var/run/docker.sock
KIND_K8S_IN_CONTAINER: "1"
+4 -4
View File
@@ -31,7 +31,7 @@ _SETUP_DEFAULTS: dict[str, str] = {
"KIND_VERSION": "0.24.0",
"KUBECTL_VERSION": "v1.32.0",
"CONTAINER_SOCKET": "/var/run/docker.sock",
"KIND_K8S_WEB_PORT": "6000",
"KIND_K8S_WEB_PORT": "8080",
"KIND_K8S_PATCH_KUBECONFIG": "1",
"CONTAINER_CLI": "docker",
"KIND_K8S_SKIP_VERSION_LIST": "",
@@ -40,7 +40,7 @@ _SETUP_DEFAULTS: dict[str, str] = {
"KIND_K8S_DEBUG": "",
"KIND_K8S_WAIT_NODES": "1",
"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_WEB_HOST": "0.0.0.0",
"KIND_K8S_WORKDIR": "",
@@ -88,9 +88,9 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
"Путь к сокету на хосте для volume (Docker: /var/run/docker.sock; Podman rootless — см. README).",
),
_EnvPrompt(
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000) ---",
"# --- docker-compose.yml: публикация веб-UI (хост:контейнер → …:6000 внутри) ---",
"KIND_K8S_WEB_PORT",
"Порт на хосте для веб-интерфейса; внутри контейнера всегда 6000.",
"Порт на хосте для браузера (дефолт 8080 — 6000 на хосте блокирует Chrome как ERR_UNSAFE_PORT); внутри контейнера uvicorn слушает 6000.",
),
_EnvPrompt(
"# --- docker-compose.yml: environment сервиса kind-k8s-web ---",