From c1e867a01f0e652c70e8b76847aae3a5eea6b313 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 06:21:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-UI:=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=20kind=20create,=20=D1=81=D1=82=D0=B0=D1=80=D1=82/=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=BF=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Потоковые логи в job_store и UI; kind create через Popen с построчным выводом - POST /clusters/{name}/start|stop; create по сохранённому kind-config.yaml - Страница /documentation: GET /api/v1/docs/readme, marked+DOMPurify из static/vendor - Иконки действий, плавающие подсказки, модалка подтверждения вместо confirm - Makefile: make docker|podman rebuild; compose: монтирование README.md - Dockerfile: COPY README.md; readme_doc: несколько путей к README Автор: Сергей Антропов — https://devops.org.ru --- Dockerfile | 2 + Makefile | 13 +- README.md | 27 +- app/api/v1/endpoints/clusters.py | 168 ++++++++- app/api/v1/endpoints/docs_readme.py | 53 +++ app/api/v1/router.py | 3 +- app/core/cluster_lifecycle.py | 173 +++++++++- app/core/job_store.py | 53 ++- app/core/readme_doc.py | 88 +++++ app/docs/api_routes.md | 84 ++++- app/main.py | 15 + app/models/schemas.py | 4 + app/static/js/dashboard.js | 495 +++++++++++++++++++++++---- app/static/js/documentation.js | 75 ++++ app/static/js/vendor/ATTRIBUTION.txt | 8 + app/static/js/vendor/marked.min.js | 6 + app/static/js/vendor/purify.min.js | 3 + app/static/style.css | 373 +++++++++++++++++++- app/templates/base.html | 4 +- app/templates/dashboard.html | 185 ++++++---- app/templates/documentation.html | 27 ++ docker-compose.yml | 4 + scripts/setup_env_interactive.py | 6 + 23 files changed, 1689 insertions(+), 180 deletions(-) create mode 100644 app/api/v1/endpoints/docs_readme.py create mode 100644 app/core/readme_doc.py create mode 100644 app/static/js/documentation.js create mode 100644 app/static/js/vendor/ATTRIBUTION.txt create mode 100644 app/static/js/vendor/marked.min.js create mode 100644 app/static/js/vendor/purify.min.js create mode 100644 app/templates/documentation.html diff --git a/Dockerfile b/Dockerfile index b60a1c1..b092997 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,8 @@ RUN apk add --no-cache python3 py3-pip docker-cli curl bash ca-certificates \ && pip3 install --no-cache-dir --break-system-packages -r /opt/kind-k8s/requirements.txt COPY app/ /opt/kind-k8s/app/ +# README для страницы «Документация» в веб-UI (см. app/core/readme_doc.py) +COPY README.md /opt/kind-k8s/README.md COPY scripts/run_uvicorn.sh /opt/kind-k8s/run_uvicorn.sh RUN chmod +x /opt/kind-k8s/run_uvicorn.sh diff --git a/Makefile b/Makefile index de0d712..c32c026 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ # Все операции с Compose только с явным выбором среды: # make docker up | make docker down | make docker logs | … # make podman up | make podman down | … -# Без префикса docker/podman цели up/down/logs/ps/compose-build/check-docker завершатся с подсказкой. +# make docker rebuild / make podman rebuild — образ без кэша и пересоздание контейнера +# Без префикса docker/podman цели up/down/logs/ps/compose-build/rebuild/check-docker завершатся с подсказкой. # # Автор: Сергей Антропов — https://devops.org.ru @@ -14,7 +15,7 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS))) COMPOSE := docker compose endif -.PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build +.PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build rebuild KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py @@ -29,6 +30,7 @@ help: ## Справка по целям @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 rebuild / make podman rebuild (build --no-cache + up --force-recreate)" @echo " make docker check-docker / make podman check-docker" @echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)." @grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"} {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' @@ -39,12 +41,12 @@ docker: ## Маркер среды: задайте вторую цель (нап podman: ## Маркер среды: задайте вторую цель (например: make podman up) @: -# Общая проверка: цели up/down/logs/ps/compose-build/check-docker — только make docker … / make podman … +# Общая проверка: цели up/down/logs/ps/compose-build/rebuild/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 ps | 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 rebuild | make docker check-docker"; \ echo >&2 " (или то же с префиксом podman)"; \ exit 1; \ fi @@ -77,3 +79,6 @@ check-docker: _require_runtime ## (с docker/podman) Проверить CLI и c compose-build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) + +rebuild: _require_runtime clusters-dir ## (с docker/podman) Пересобрать образ без кэша и пересоздать контейнер kind-k8s-web + cd "$(KIND_K8S_DIR)" && $(COMPOSE) build --no-cache $(COMPOSE_BUILD_FLAGS) && $(COMPOSE) up -d --force-recreate kind-k8s-web diff --git a/README.md b/README.md index 004b793..61f3713 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ **В образ не обязательно ставить на хост:** kind, Python приложения — они внутри контейнера. -Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. +Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. Файл **`./README.md`** монтируется в **`/opt/kind-k8s/README.md`** (страница **«Документация»** и **`GET /api/v1/docs/readme`** без пересборки образа). После создания кластера kubeconfig по умолчанию **патчится** на `https://127.0.0.1:<порт>` для доступа с хоста (`KIND_K8S_PATCH_KUBECONFIG`, см. `app/kubeconfig_patch.py`). @@ -67,7 +67,7 @@ make docker up # или: make podman up ### Разработка UI и API без пересборки образа -В **`docker-compose.yml`** каталог **`./app`** смонтирован в контейнер как **`/opt/kind-k8s/app`**. +В **`docker-compose.yml`** смонтированы **`./app`** → **`/opt/kind-k8s/app`** и **`./README.md`** → **`/opt/kind-k8s/README.md`** (только чтение). По умолчанию (**`KIND_K8S_UVICORN_RELOAD=1`**) uvicorn запускается с **`--reload`** (см. **`scripts/run_uvicorn.sh`**) и перезапускает процесс при изменении `*.py`, `*.html`, `*.css`, `*.js` в `app/`. Пересобирать образ нужно после изменений **Dockerfile**, **`requirements.txt`** или **`scripts/run_uvicorn.sh`**. @@ -99,12 +99,13 @@ docker compose run --rm --entrypoint python3 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 rebuild` / `make podman rebuild` | Пересборка образа **без кэша** (`build --no-cache`) и пересоздание контейнера (`up -d --force-recreate`) | | `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`, `ps`, `compose-build`, `check-docker` | +| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild`, `check-docker` | -Цели `up`, `down`, `logs`, `ps`, `compose-build` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. +Цели `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. ## Переменные окружения @@ -129,8 +130,10 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \ | **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов отдавать в API/UI | | **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub | | **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах | +| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Размер буфера строк журнала фонового задания (`kind create`) для поля `progress_log` в API/UI; по умолчанию **500** (задаётся в коде, при необходимости передайте в compose) | +| **`KIND_K8S_README_PATH`** | контейнер / приложение | Абсолютный путь к **README.md** для страницы **`/documentation`**; если пусто — используется `README.md` рядом с каталогом `app/` (в образе: `/opt/kind-k8s/README.md`) | | **`KIND_K8S_WORKDIR`** | локальный запуск | Корень данных на машине разработчика без compose | -| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` | +| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (то же для **`make docker rebuild`**) | ## Podman (пример rootless) @@ -143,18 +146,18 @@ make podman up | Путь | Назначение | |------|------------| -| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен для compose-целей | +| `Makefile` | Запуск веб-UI; префикс `docker` или `podman` обязателен; цели `up`, `rebuild`, `compose-build` и др. | | `scripts/setup_env_interactive.py` | Интерактивное создание `.env` (все ключи и дефолты внутри скрипта) | | `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` | | `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI | | `requirements.txt` | pip-зависимости веб-приложения | -| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, сокет | -| `app/main.py` | FastAPI: дашборд `/`, редирект `/ui`, монтирование `/static` | -| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `clusters`) | +| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет | +| `app/main.py` | FastAPI: дашборд `/`, `/documentation`, редирект `/ui`, монтирование `/static` | +| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) | | `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути | | `app/models/schemas.py` | Pydantic-схемы запросов/ответов API | -| `app/templates/` | Jinja2: `base.html`, `dashboard.html` | -| `app/static/` | `style.css`, `js/dashboard.js` | +| `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `documentation.html` | +| `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) | | `app/docs/` | `api_routes.md`, `README.md` | | `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` | @@ -178,4 +181,4 @@ make podman up - На **Windows** без WSL удобнее WSL2 + Docker Desktop. - Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера). - История заданий создания в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается. -- При **`exec format error`** у kind пересоберите образ: `make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `linux/amd64`). +- При **`exec format error`** у kind пересоберите образ: `make docker rebuild COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `compose-build` без `--no-cache`, или `linux/amd64`). diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index 51e9551..b80428b 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -11,7 +11,7 @@ import logging from typing import Any from fastapi import APIRouter, BackgroundTasks, HTTPException, Query -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from core.cluster_lifecycle import ( KindClusterError, @@ -22,9 +22,18 @@ from core.cluster_lifecycle import ( kubectl_pods_all_namespaces, list_registered_kind_clusters, read_meta_json, + start_kind_cluster_containers, + stop_kind_cluster_containers, validate_cluster_name, ) -from core.job_store import JobRecord, end_job_tracking, get_progress_sync, job_store, request_cancel_sync +from core.job_store import ( + JobRecord, + end_job_tracking, + get_logs_snapshot_sync, + 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 ( @@ -47,6 +56,13 @@ def _record_to_job_view(rec: JobRecord) -> JobView: stage, pct = (None, None) if prog is not None: stage, pct = prog[0], prog[1] + if rec.status in ("queued", "running"): + log_tail = get_logs_snapshot_sync(rec.job_id) + else: + log_tail = list(rec.log_lines or []) + max_log = 400 + if len(log_tail) > max_log: + log_tail = log_tail[-max_log:] return JobView( job_id=rec.job_id, kind=rec.kind, @@ -57,6 +73,7 @@ def _record_to_job_view(rec: JobRecord) -> JobView: result=rec.result, progress_stage=stage, progress_percent=pct, + progress_log=log_tail, ) @@ -235,6 +252,49 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None: end_job_tracking(job_id) +async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: str, workers: int) -> None: + """Фоновое создание кластера по уже сохранённому ``kind-config.yaml`` (без kind в списке).""" + try: + async with kind_cluster_lock: + await job_store.set_running(job_id) + try: + result = await asyncio.to_thread( + create_cluster_non_interactive, + name=name.strip(), + kubernetes_version_tag=kubernetes_version_tag.strip(), + workers=workers, + job_id=job_id, + use_existing_config=True, + ) + 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("start_cluster 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("start_cluster 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("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name) + finally: + end_job_tracking(job_id) + + @router.post( "/clusters", response_model=ClusterCreateAccepted, @@ -279,6 +339,104 @@ async def delete_cluster(name: str) -> dict[str, object]: return {"name": name, "kind_delete_ok": kind_ok, "summary": summary} +@router.post( + "/clusters/{name}/stop", + summary="Остановить узлы кластера (docker stop)", + responses={400: {"description": "Некорректное имя"}}, +) +async def stop_cluster_nodes(name: str) -> dict[str, object]: + """ + Остановить контейнеры узлов kind; запись кластера в kind сохраняется. + + После этого API «Старт» запустит те же контейнеры без ``kind create``. + """ + if not validate_cluster_name(name): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + + async with kind_cluster_lock: + + def _do() -> tuple[bool, str]: + return stop_kind_cluster_containers(name=name) + + try: + ok, summary = await asyncio.to_thread(_do) + except KindClusterError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + logger.info("Остановка узлов %s: ok=%s", name, ok) + return {"name": name, "containers_stopped_ok": ok, "summary": summary} + + +@router.post( + "/clusters/{name}/start", + summary="Запустить кластер (контейнеры или kind create по конфигу)", + responses={400: {"description": "Нет kind и нет kind-config.yaml"}}, +) +async def start_cluster_nodes( + name: str, + background_tasks: BackgroundTasks, +) -> JSONResponse: + """ + Если кластер есть в ``kind get clusters`` — ``docker start`` всех узлов. + + Если в kind нет, но есть ``clusters/<имя>/kind-config.yaml`` — фоновое ``kind create`` + (как при создании, с журналом в GET /jobs/{job_id}). + """ + if not validate_cluster_name(name): + raise HTTPException(status_code=400, detail="Некорректное имя кластера") + + n = name.strip() + + async with kind_cluster_lock: + in_kind = n in await asyncio.to_thread(list_registered_kind_clusters) + if in_kind: + + def _start() -> tuple[bool, str]: + return start_kind_cluster_containers(name=n) + + try: + ok, summary = await asyncio.to_thread(_start) + except KindClusterError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + logger.info("Запуск контейнеров кластера %s: ok=%s", n, ok) + return JSONResponse( + status_code=200, + content={ + "name": n, + "mode": "containers", + "containers_started_ok": ok, + "summary": summary, + }, + ) + + cfg = clusters_dir() / n / "kind-config.yaml" + if not cfg.is_file(): + raise HTTPException( + status_code=400, + detail="Кластер не в kind и нет файла clusters/<имя>/kind-config.yaml — создайте кластер или восстановите конфиг.", + ) + + meta = read_meta_json(n) or {} + ver_raw = str(meta.get("kubernetes_version_tag") or "v1.29.4").strip() or "v1.29.4" + w_raw = meta.get("worker_nodes") + try: + w = int(w_raw) if w_raw is not None else 0 + except (TypeError, ValueError): + w = 0 + + rec = await job_store.create_job("start_cluster", cluster_name=n) + background_tasks.add_task(_run_start_cluster_job, rec.job_id, n, ver_raw, w) + logger.info("Фоновый старт кластера %s по конфигу, job_id=%s", n, rec.job_id) + return JSONResponse( + status_code=202, + content={ + "job_id": rec.job_id, + "status": "queued", + "message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}", + }, + ) + + @router.post( "/jobs/{job_id}/cancel", summary="Запросить отмену создания кластера", @@ -286,8 +444,10 @@ async def delete_cluster(name: str) -> dict[str, object]: ) async def cancel_create_job(job_id: str) -> dict[str, object]: """ - Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения; - после него отмена удалит кластер и данные (если успели создать). + Установить флаг отмены для задания ``create_cluster`` или ``start_cluster``. + + Этап ``kind create cluster`` нельзя прервать до его завершения; после него отмена удалит + кластер и данные (если успели создать). """ rec = await job_store.get(job_id) if not rec: diff --git a/app/api/v1/endpoints/docs_readme.py b/app/api/v1/endpoints/docs_readme.py new file mode 100644 index 0000000..64e05a6 --- /dev/null +++ b/app/api/v1/endpoints/docs_readme.py @@ -0,0 +1,53 @@ +"""Отдача сырого README.md для клиентского рендера Markdown (marked в static). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, HTTPException +from fastapi.responses import PlainTextResponse + +from core.readme_doc import read_readme_text + +logger = logging.getLogger("kind_k8s.api.docs_readme") + +router = APIRouter(tags=["documentation"]) + + +@router.get( + "/docs/readme", + response_class=PlainTextResponse, + summary="README.md как текст (Markdown)", + responses={404: {"description": "Файл не найден"}}, +) +async def get_readme_markdown() -> PlainTextResponse: + """ + Тело ответа — содержимое README в кодировке UTF-8. + + Разбор Markdown выполняется в браузере скриптами из ``/static/js/vendor/`` (marked + DOMPurify). + """ + try: + + def _read() -> str: + return read_readme_text() + + text = await asyncio.to_thread(_read) + except FileNotFoundError: + logger.info("GET /docs/readme: файл README не найден") + raise HTTPException( + status_code=404, + detail=( + "README.md не найден. Укажите KIND_K8S_README_PATH, смонтируйте в compose " + "./README.md:/opt/kind-k8s/README.md:ro или пересоберите образ (COPY README.md)." + ), + ) from None + logger.debug("GET /docs/readme: отдано %s символов", len(text)) + return PlainTextResponse( + content=text, + media_type="text/markdown; charset=utf-8", + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index f3f0078..77475dc 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -8,9 +8,10 @@ from __future__ import annotations from fastapi import APIRouter -from api.v1.endpoints import clusters, health, versions +from api.v1.endpoints import clusters, docs_readme, health, versions api_router = APIRouter() api_router.include_router(health.router, prefix="") api_router.include_router(versions.router, prefix="") +api_router.include_router(docs_readme.router, prefix="") api_router.include_router(clusters.router, prefix="") diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py index 873cdb1..8af0919 100644 --- a/app/core/cluster_lifecycle.py +++ b/app/core/cluster_lifecycle.py @@ -14,6 +14,7 @@ import os import re import shutil import subprocess +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -106,6 +107,42 @@ def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None: raise KindClusterError(f"Команда завершилась с кодом {p.returncode}: {err}", exit_code=p.returncode) +def _run_checked_stream( + cmd: list[str], + *, + cwd: Path | None = None, + on_line: Callable[[str], None] | None = None, +) -> None: + """ + Выполнить команду с построчным выводом в колбэк (stdout+stderr объединены). + + Нужен для ``kind create cluster``: pull образов и подъём нод видны в UI по опросу job. + """ + logger.info("Выполнение (поток): %s", " ".join(cmd)) + p = subprocess.Popen( + cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + if p.stdout is None: + raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1) + try: + for raw in p.stdout: + line = raw.rstrip("\n\r") + if on_line and line: + on_line(line) + if line: + logger.debug("stream: %s", line[:800]) + rc = p.wait() + finally: + p.stdout.close() + if rc != 0: + raise KindClusterError(f"Команда завершилась с кодом {rc} (см. журнал задания выше)", exit_code=rc) + + def _run_capture_checked(cmd: list[str]) -> str: p = subprocess.run(cmd, capture_output=True, text=True) if p.returncode != 0: @@ -177,6 +214,7 @@ def create_cluster_non_interactive( kubernetes_version_tag: str, workers: int, job_id: str | None = None, + use_existing_config: bool = False, ) -> CreateClusterResult: """ Создать кластер kind без диалогов. @@ -184,12 +222,20 @@ def create_cluster_non_interactive( ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. ``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``). + + ``use_existing_config=True`` — не перезаписывать ``kind-config.yaml``, поднять кластер по уже + сохранённому файлу (каталог ``clusters/<имя>/`` должен существовать). """ 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) + _job_store.append_log_sync(job_id, f"[{pct}%] {stage}") + + def _log(line: str) -> None: + if job_id: + _job_store.append_log_sync(job_id, line) def _cancelled() -> bool: return bool(job_id and _job_store.is_cancelled_sync(job_id)) @@ -204,7 +250,7 @@ def create_cluster_non_interactive( if name in existing: raise KindClusterError(f"Кластер «{name}» уже существует в kind.") - if workers < 0 or workers > 20: + if not use_existing_config and (workers < 0 or workers > 20): raise KindClusterError("Количество worker-нод должно быть от 0 до 20.") ver_tag = normalize_tag_v_prefix(kubernetes_version_tag) @@ -218,17 +264,39 @@ def create_cluster_non_interactive( kube_path = out_dir / "kubeconfig" meta_path = out_dir / "meta.json" - yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) - cfg_path.write_text(yaml_text, encoding="utf-8") + prev_meta_for_workers: dict[str, object] = {} + if use_existing_config: + if not cfg_path.is_file(): + raise KindClusterError(f"Нет сохранённого kind-config.yaml: {cfg_path}") + prev = read_meta_json(name) or {} + prev_meta_for_workers = prev + if prev.get("node_image"): + node_image = str(prev["node_image"]) + if prev.get("kubernetes_version_tag"): + ver_tag = str(prev["kubernetes_version_tag"]) + _progress("Используется существующий kind-config.yaml", 10) + else: + 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) - _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, existing_cfg=%s", + name, + node_image, + workers, + use_existing_config, + ) _progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28) - _run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)]) + _log("--- kind create cluster ---") + _run_checked_stream( + ["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)], + on_line=_log, + ) if _cancelled(): _rollback_after_cancel(cluster_name=name, out_dir=out_dir) @@ -262,12 +330,22 @@ def create_cluster_non_interactive( logger.info("Ноды готовы: %s", msg) else: logger.warning("Ожидание нод не завершилось успешно: %s", msg) + _log(f"kubectl wait nodes: {msg}"[:4000]) + + worker_nodes_meta = workers + if use_existing_config: + prev_w = prev_meta_for_workers.get("worker_nodes") + if prev_w is not None: + try: + worker_nodes_meta = int(prev_w) + except (TypeError, ValueError): + worker_nodes_meta = workers meta = { "cluster_name": name, "kubernetes_version_tag": ver_tag, "node_image": node_image, - "worker_nodes": workers, + "worker_nodes": worker_nodes_meta, "created_at_utc": datetime.now(timezone.utc).isoformat(), "kind_config_path": str(cfg_path.relative_to(root)), "kubeconfig_path": str(kube_path.relative_to(root)), @@ -275,6 +353,7 @@ def create_cluster_non_interactive( "created_via_container": _in_container(), "nodes_ready_after_create": nodes_ready, "nodes_ready_message": nodes_msg, + "provisioned_from_existing_config": use_existing_config, } meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") @@ -284,7 +363,7 @@ def create_cluster_non_interactive( cluster_name=name, ver_tag=ver_tag, node_image=node_image, - workers=workers, + workers=worker_nodes_meta, kubeconfig_path=kube_path, meta_path=meta_path, kubeconfig_patched_for_host=patched, @@ -340,6 +419,84 @@ def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> t return kind_ok, "; ".join(parts) +def _sort_kind_node_containers(names: list[str]) -> list[str]: + """Сначала control-plane, затем остальные — удобнее для ``docker start``.""" + + def sort_key(n: str) -> tuple[int, str]: + if n.endswith("-control-plane"): + return (0, n) + return (1, n) + + return sorted(names, key=sort_key) + + +def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]: + """Имена контейнеров узлов kind (все с префиксом ``<имя>-``).""" + cli = _container_cli_bin() + if not shutil.which(cli): + raise KindClusterError(f"CLI контейнеров «{cli}» не найден в PATH.", exit_code=127) + p = subprocess.run( + [cli, "ps", "-a", "--format", "{{.Names}}"], + capture_output=True, + text=True, + ) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() + raise KindClusterError(f"{cli} ps: {err}", exit_code=p.returncode) + prefix = f"{cluster_name}-" + raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()] + matched = [n for n in raw if n.startswith(prefix)] + return _sort_kind_node_containers(matched) + + +def stop_kind_cluster_containers(*, name: str) -> tuple[bool, str]: + """ + Остановить контейнеры узлов (``docker stop`` / ``podman stop``). + + Запись kind о кластере сохраняется; позже можно вызвать ``start_kind_cluster_containers``. + """ + names = list_kind_cluster_container_names(cluster_name=name) + if not names: + return True, "Нет контейнеров с префиксом «%s-» (уже остановлены или удалены)" % name + cli = _container_cli_bin() + ok_all = True + parts: list[str] = [] + for ctr in names: + p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True) + if p.returncode != 0: + ok_all = False + err = (p.stderr or p.stdout or "").strip() or str(p.returncode) + parts.append(f"{ctr}: ошибка ({err})") + logger.warning("%s stop %s: %s", cli, ctr, err) + else: + parts.append(f"{ctr}: OK") + return ok_all, "; ".join(parts) + + +def start_kind_cluster_containers(*, name: str) -> tuple[bool, str]: + """Запустить контейнеры узлов kind (после ``stop`` или рестарта движка).""" + names = list_kind_cluster_container_names(cluster_name=name) + if not names: + return False, ( + "Не найдены контейнеры «%s-*». Если кластера нет в kind — используйте «Старт» " + "из UI (создание по сохранённому kind-config.yaml) или создайте кластер заново." + % name + ) + cli = _container_cli_bin() + ok_all = True + parts: list[str] = [] + for ctr in names: + p = subprocess.run([cli, "start", ctr], capture_output=True, text=True) + if p.returncode != 0: + ok_all = False + err = (p.stderr or p.stdout or "").strip() or str(p.returncode) + parts.append(f"{ctr}: ошибка ({err})") + logger.warning("%s start %s: %s", cli, ctr, err) + else: + parts.append(f"{ctr}: OK") + return ok_all, "; ".join(parts) + + def read_meta_json(cluster_name: str) -> dict[str, object] | None: """Прочитать ``clusters/<имя>/meta.json`` если есть.""" p = clusters_dir() / cluster_name / "meta.json" diff --git a/app/core/job_store.py b/app/core/job_store.py index d951107..9443ff5 100644 --- a/app/core/job_store.py +++ b/app/core/job_store.py @@ -12,9 +12,11 @@ from __future__ import annotations import asyncio import logging +import os import threading import uuid -from dataclasses import dataclass +from collections import deque +from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, Literal @@ -29,6 +31,46 @@ JobStatus = Literal["queued", "running", "success", "failed", "cancelled"] _thread_lock = threading.Lock() _cancel_events: dict[str, threading.Event] = {} _progress: dict[str, tuple[str, int]] = {} +# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines +_job_log_deques: dict[str, deque[str]] = {} + + +def _max_job_log_lines() -> int: + raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "500").strip() + try: + return max(50, min(int(raw), 5000)) + except ValueError: + return 500 + + +def append_log_sync(job_id: str, line: str) -> None: + """Добавить строку в журнал задания (вызывается из worker-thread во время долгих команд).""" + text = (line or "").rstrip() + if not text: + return + cap = _max_job_log_lines() + with _thread_lock: + if job_id not in _job_log_deques: + _job_log_deques[job_id] = deque(maxlen=cap) + _job_log_deques[job_id].append(text) + + +def get_logs_snapshot_sync(job_id: str) -> list[str]: + """Снимок текущего журнала (для API во время running/queued).""" + with _thread_lock: + d = _job_log_deques.get(job_id) + return list(d) if d else [] + + +def take_logs_finalize_sync(job_id: str) -> list[str]: + """ + Забрать журнал в список и удалить deque (после успеха/ошибки/отмены). + + Вызывать перед или внутри обновления JobRecord. + """ + with _thread_lock: + d = _job_log_deques.pop(job_id, None) + return list(d) if d else [] def begin_job_tracking(job_id: str) -> None: @@ -43,6 +85,7 @@ def end_job_tracking(job_id: str) -> None: with _thread_lock: _cancel_events.pop(job_id, None) _progress.pop(job_id, None) + _job_log_deques.pop(job_id, None) def set_progress_sync(job_id: str, stage: str, percent: int) -> None: @@ -88,6 +131,8 @@ class JobRecord: created_at_utc: str message: str | None = None result: dict[str, Any] | None = None + # Журнал после завершения (stdout/stderr kind create и этапы); пока задание активно — см. deque + log_lines: list[str] = field(default_factory=list) class JobStore: @@ -127,25 +172,31 @@ class JobStore: 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: + logs = take_logs_finalize_sync(job_id) async with self._lock: if job_id in self._jobs: self._jobs[job_id].status = "success" self._jobs[job_id].result = result self._jobs[job_id].message = message + self._jobs[job_id].log_lines = logs set_progress_sync(job_id, "Готово", 100) async def set_failed(self, job_id: str, message: str) -> None: + logs = take_logs_finalize_sync(job_id) async with self._lock: if job_id in self._jobs: self._jobs[job_id].status = "failed" self._jobs[job_id].message = message + self._jobs[job_id].log_lines = logs logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None: + logs = take_logs_finalize_sync(job_id) async with self._lock: if job_id in self._jobs: self._jobs[job_id].status = "cancelled" self._jobs[job_id].message = message + self._jobs[job_id].log_lines = logs logger.info("Задание %s отменено: %s", job_id, message) async def get(self, job_id: str) -> JobRecord | None: diff --git a/app/core/readme_doc.py b/app/core/readme_doc.py new file mode 100644 index 0000000..e31a665 --- /dev/null +++ b/app/core/readme_doc.py @@ -0,0 +1,88 @@ +"""Чтение README.md для API ``GET /api/v1/docs/readme`` и страницы «Документация». + +Разметка Markdown преобразуется в браузере: ``/static/js/vendor/marked.min.js`` и +``purify.min.js`` (файлы входят в репозиторий, без CDN). + +Путь к файлу: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``; +в Docker-образе — ``/opt/kind-k8s/README.md``. + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +logger = logging.getLogger("kind_k8s.readme_doc") + +# app/core/readme_doc.py: parents[2] = корень репозитория (рядом с app/) или /opt/kind-k8s в образе +_LIB_FILE = Path(__file__).resolve() + + +def _candidates_without_env() -> list[Path]: + """ + Возможные пути к README без KIND_K8S_README_PATH. + + Порядок: родитель каталога app/ (типично репозиторий), затем фиксированный путь образа. + В compose рекомендуется монтировать ./README.md → /opt/kind-k8s/README.md (см. docker-compose.yml). + """ + out: list[Path] = [] + seen: set[Path] = set() + try: + repo_readme = (_LIB_FILE.parents[2] / "README.md").resolve() + if repo_readme not in seen: + seen.add(repo_readme) + out.append(repo_readme) + except (IndexError, OSError): + pass + fixed = Path("/opt/kind-k8s/README.md") + try: + fixed_r = fixed.resolve() + if fixed_r not in seen: + seen.add(fixed_r) + out.append(fixed_r) + except OSError: + out.append(fixed) + return out + + +def get_readme_path() -> Path | None: + """Первый существующий путь к README или ``None``.""" + raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip() + if raw: + p = Path(raw).expanduser().resolve() + return p if p.is_file() else None + for p in _candidates_without_env(): + if p.is_file(): + return p + return None + + +def read_readme_text() -> str: + """Прочитать README как UTF-8; ``FileNotFoundError`` если файла нет.""" + raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip() + if raw: + p = Path(raw).expanduser().resolve() + if not p.is_file(): + logger.warning("KIND_K8S_README_PATH: файл не найден: %s", p) + raise FileNotFoundError(str(p)) + text = p.read_text(encoding="utf-8") + logger.debug("README из KIND_K8S_README_PATH, %s символов", len(text)) + return text + + for p in _candidates_without_env(): + if p.is_file(): + text = p.read_text(encoding="utf-8") + logger.info("README прочитан: %s (%s символов)", p, len(text)) + return text + + logger.warning( + "README.md не найден. Проверены пути: %s. " + "В Docker Compose добавьте монтирование ./README.md:/opt/kind-k8s/README.md " + "или пересоберите образ (COPY README.md в Dockerfile).", + [str(x) for x in _candidates_without_env()], + ) + raise FileNotFoundError("README.md") diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 1288d6b..e8f12d5 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -10,19 +10,21 @@ | 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` | +| Документация проекта | `http://127.0.0.1:<порт>/documentation` — **README.md**: текст с `GET /api/v1/docs/readme`, рендер **Markdown** в браузере (**marked** + **DOMPurify** из `app/static/js/vendor/`, без CDN) | | Этот файл | `app/docs/api_routes.md` в репозитории | -С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). +С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). Пункт **Документация** открывает `GET /documentation` в той же вкладке. ## Веб-интерфейс и статика (не JSON) | Маршрут | Описание | |---------|----------| -| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, отмена), таблицы (автообновление ~3,5 с), модалка узлов/подов; в шапке — меню-пилюли и отдельные окна для Swagger / ReDoc / Health. | +| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** `kind create`, отмена), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | +| `GET /documentation` | HTML-оболочка; контент — запрос к **`GET /api/v1/docs/readme`** и разбор Markdown скриптами из **`/static/js/vendor/`** (marked, DOMPurify). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). | | `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | -Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели). +Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/documentation.html` (README). --- @@ -31,10 +33,13 @@ | Метод | Путь | Кратко | |-------|------|--------| | GET | `/api/v1/health` | Среда: kind, kubectl, движок контейнеров | +| GET | `/api/v1/docs/readme` | Текст **README.md** (`text/markdown`; для страницы `/documentation`) | | GET | `/api/v1/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` | | GET | `/api/v1/stats` | Сводка для дашборда | | GET | `/api/v1/clusters` | Список кластеров | | POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) | +| POST | `/api/v1/clusters/{name}/start` | Запуск: **200** — `docker start` узлов (кластер в kind); **202** + `job_id` — фоновый `kind create` по сохранённому `kind-config.yaml` | +| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов (`docker`/`podman` **stop**), запись в kind сохраняется | | GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | @@ -49,6 +54,8 @@ - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. +- Поле **`progress_log`** — массив последних строк журнала (вывод `kind create`: pull образов, подъём нод и т.д.); размер ограничен (см. `KIND_K8S_JOB_LOG_MAX_LINES` в коде `job_store`, по умолчанию до **500** строк в буфере, в JSON отдаётся хвост). +- Тип задания **`kind`**: `create_cluster` или `start_cluster` (повторный подъём по `clusters/<имя>/kind-config.yaml`). - Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается. --- @@ -86,6 +93,16 @@ --- +## GET /api/v1/docs/readme + +Сырое содержимое **README.md** проекта в кодировке UTF-8, заголовок **`Content-Type: text/markdown; charset=utf-8`**. + +Используется страницей **`GET /documentation`**: скрипт `documentation.js` загружает текст и превращает его в HTML через **marked** и **DOMPurify** (файлы лежат в репозитории: `app/static/js/vendor/`, без внешних CDN). + +**Ошибка 404:** файл не найден. В Compose смонтируйте `./README.md:/opt/kind-k8s/README.md:ro`, задайте `KIND_K8S_README_PATH` или пересоберите образ (`COPY README.md`). См. `app/core/readme_doc.py`. + +--- + ## GET /api/v1/versions Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI). @@ -293,6 +310,56 @@ --- +## POST /api/v1/clusters/{name}/start + +Запуск кластера двумя сценариями: + +1. Кластер **есть** в `kind get clusters` (узлы когда-либо создавались) — выполняется **`docker start`** / **`podman start`** для всех контейнеров с именами вида `<имя>-control-plane`, `<имя>-worker`, … Ответ **200**. +2. В **kind** кластера **нет**, но в `clusters/<имя>/kind-config.yaml` файл **есть** — ставится фоновое задание **`start_cluster`** (как при создании: `kind create` по сохранённому конфигу, журнал в `GET /jobs/{job_id}`). Ответ **202** + `job_id`. + +**Пример ответа 200 (контейнеры запущены):** + +```json +{ + "name": "dev", + "mode": "containers", + "containers_started_ok": true, + "summary": "dev-control-plane: OK; dev-worker: OK; dev-worker2: OK" +} +``` + +**Пример ответа 202 (подъём по конфигу):** + +```json +{ + "job_id": "cafebabe...", + "status": "queued", + "message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}" +} +``` + +**Ошибка 400:** некорректное имя или нет ни кластера в kind, ни `kind-config.yaml` в `clusters/<имя>/`. + +--- + +## POST /api/v1/clusters/{name}/stop + +Остановка **всех** контейнеров узлов кластера (`docker stop` / `podman stop` по префиксу имени). Запись кластера в kind **не удаляется**; позже можно снова вызвать **POST …/start** (режим `containers`). + +**Пример ответа 200:** + +```json +{ + "name": "dev", + "containers_stopped_ok": true, + "summary": "dev-control-plane: OK; dev-worker: OK" +} +``` + +**Ошибка 400:** некорректное имя кластера. + +--- + ## GET /api/v1/jobs/{job_id} Статус фонового задания создания. @@ -307,7 +374,15 @@ "cluster_name": "dev", "created_at_utc": "2026-04-04T12:00:00+00:00", "message": null, - "result": null + "result": null, + "progress_stage": "kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", + "progress_percent": 28, + "progress_log": [ + "[12%] Подготовка каталога и kind-config", + "--- kind create cluster ---", + "Creating cluster \"dev\" ...", + " • Ensuring node image (kindest/node:v1.29.4) 🖼 ..." + ] } ``` @@ -321,6 +396,7 @@ "cluster_name": "dev", "created_at_utc": "2026-04-04T12:00:00+00:00", "message": "Кластер создан", + "progress_log": ["[95%] Финализация", "kubectl wait nodes: ..."], "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4", diff --git a/app/main.py b/app/main.py index 8515f21..fc043b5 100644 --- a/app/main.py +++ b/app/main.py @@ -79,3 +79,18 @@ async def dashboard(request: Request) -> HTMLResponse: async def ui_redirect() -> RedirectResponse: """Удобный алиас на корень UI.""" return RedirectResponse(url="/", status_code=307) + + +@app.get("/documentation", response_class=HTMLResponse, summary="Документация (README)") +async def documentation_page(request: Request) -> HTMLResponse: + """Оболочка страницы: Markdown подгружается с ``GET /api/v1/docs/readme``, рендер в браузере (marked + DOMPurify из ``/static/js/vendor/``).""" + if not _templates_dir.is_dir(): + return HTMLResponse( + content="

Шаблоны не найдены.

", + status_code=500, + ) + return templates.TemplateResponse( + request, + "documentation.html", + {"app_title": settings.app_title}, + ) diff --git a/app/models/schemas.py b/app/models/schemas.py index df3e06f..441de48 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -43,6 +43,10 @@ class JobView(BaseModel): 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") + progress_log: list[str] = Field( + default_factory=list, + description="Хвост лога (kind create, этапы); обновляется при опросе GET /jobs/{id}", + ) class ClusterSummary(BaseModel): diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 9dac2db..18acc5e 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -1,6 +1,7 @@ /** * Панель управления кластерами kind (REST /api/v1). - * Автообновление списков и health; прогресс и отмена создания кластера. + * Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная + * замена содержимого блоков (статистика, таблицы, плашка среды) — SPA-поведение. * * Автор: Сергей Антропов * Сайт: https://devops.org.ru @@ -23,6 +24,8 @@ var createInProgress = false; /** @type {string | null} */ var currentPollJobId = null; + /** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */ + var currentModalClusterName = null; function formatApiError(data, fallback) { if (!data) return fallback; @@ -66,6 +69,137 @@ return d.innerHTML; } + /** + * Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig). + * @param {string} clusterName + */ + function downloadKubeconfig(clusterName) { + const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig"; + const a = document.createElement("a"); + a.href = url; + a.download = "kubeconfig-" + clusterName + ".yaml"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + /** SVG-иконки действий (stroke, currentColor). */ + var ICONS = { + state: + '', + play: + '', + stop: + '', + download: + '', + trash: + '', + }; + + /** @type {ReturnType | null} */ + var actionTooltipHideTimer = null; + + function getActionTooltipEl() { + return document.getElementById("action-tooltip"); + } + + /** Скрыть плавающую подсказку (#action-tooltip, position: fixed). */ + function hideActionTooltip() { + if (actionTooltipHideTimer) { + clearTimeout(actionTooltipHideTimer); + actionTooltipHideTimer = null; + } + const el = getActionTooltipEl(); + if (!el) return; + el.classList.add("hidden"); + el.textContent = ""; + el.style.visibility = ""; + } + + /** + * Показать подсказку у иконки (полный текст, без обрезки overflow таблицы). + * @param {HTMLElement} host элемент .icon-tooltip-host + */ + function showActionTooltip(host) { + const el = getActionTooltipEl(); + const text = host.getAttribute("data-tooltip"); + if (!el || !text) return; + if (actionTooltipHideTimer) { + clearTimeout(actionTooltipHideTimer); + actionTooltipHideTimer = null; + } + el.textContent = text; + const margin = 10; + const maxW = Math.min(352, window.innerWidth - margin * 2); + el.style.maxWidth = maxW + "px"; + el.classList.remove("hidden"); + var w = el.offsetWidth; + var h = el.offsetHeight; + var r = host.getBoundingClientRect(); + var left = r.left + r.width / 2 - w / 2; + if (left < margin) left = margin; + if (left + w > window.innerWidth - margin) left = Math.max(margin, window.innerWidth - w - margin); + var top = r.top - h - margin; + if (top < margin) top = r.bottom + margin; + if (top + h > window.innerHeight - margin) { + top = Math.max(margin, window.innerHeight - h - margin); + } + el.style.left = left + "px"; + el.style.top = top + "px"; + } + + /** + * Подписать .icon-tooltip-host внутри root на hover/focus (повторно безопасно). + * @param {ParentNode | null} root + */ + function bindActionTooltipHosts(root) { + if (!root) return; + root.querySelectorAll(".icon-tooltip-host").forEach(function (host) { + if (host.dataset.actionTooltipBound === "1") return; + host.dataset.actionTooltipBound = "1"; + host.addEventListener("mouseenter", function () { + showActionTooltip(host); + }); + host.addEventListener("mouseleave", function () { + actionTooltipHideTimer = setTimeout(hideActionTooltip, 120); + }); + host.addEventListener("focusin", function () { + showActionTooltip(host); + }); + host.addEventListener("focusout", function (ev) { + var rel = ev.relatedTarget; + if (!rel || !host.contains(rel)) hideActionTooltip(); + }); + }); + } + + /** + * Кнопка-иконка в обёртке; подсказка — data-tooltip (см. #action-tooltip в base.html). + * @param {string} svgHtml + * @param {string} tooltip + * @param {string} [classExtra] например icon-btn--danger + * @param {((ev: Event) => void) | null} onClick + * @param {boolean} [isDisabled] + */ + function iconActionButton(svgHtml, tooltip, classExtra, onClick, isDisabled) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "icon-btn" + (classExtra ? " " + classExtra : ""); + btn.innerHTML = svgHtml; + btn.setAttribute("aria-label", tooltip); + if (isDisabled) { + btn.disabled = true; + } else if (onClick) { + btn.addEventListener("click", onClick); + } + const host = document.createElement("span"); + host.className = "icon-tooltip-host"; + host.setAttribute("data-tooltip", tooltip); + host.appendChild(btn); + return host; + } + function showToast(message, isError) { const el = document.getElementById("toast"); if (!el) return; @@ -78,17 +212,6 @@ }, 4500); } - function setBusy(section, busy) { - const el = document.querySelector("[data-busy='" + section + "']"); - if (!el) return; - if (busy) { - el.setAttribute("aria-busy", "true"); - } else { - el.removeAttribute("aria-busy"); - } - el.classList.toggle("is-loading", busy); - } - function setStatusBannerClass(ok, degraded) { const el = document.getElementById("status-banner"); if (!el) return; @@ -129,10 +252,9 @@ const dl = document.getElementById("stats-dl"); const errEl = document.getElementById("stats-err"); if (!dl) return; - errEl.classList.add("hidden"); - dl.innerHTML = ""; try { const s = await api("/stats"); + errEl.classList.add("hidden"); const rows = [ ["Кластеров в kind", s.kind_clusters_count], ["Локальных каталогов", s.local_cluster_dirs_count], @@ -140,14 +262,16 @@ ["Заданий в памяти", s.jobs_total], ["Заданий с ошибкой", s.jobs_recent_failed], ]; + const frag = document.createDocumentFragment(); rows.forEach(function (kv) { const dt = document.createElement("dt"); dt.textContent = kv[0]; const dd = document.createElement("dd"); dd.textContent = String(kv[1]); - dl.appendChild(dt); - dl.appendChild(dd); + frag.appendChild(dt); + frag.appendChild(dd); }); + dl.replaceChildren(frag); } catch (e) { errEl.textContent = "Статистика: " + e.message; errEl.classList.remove("hidden"); @@ -196,17 +320,14 @@ const tbody = document.querySelector("#tbl-clusters tbody"); const msg = document.getElementById("list-msg"); if (!tbody) return; - setBusy("clusters", true); - tbody.innerHTML = ""; - if (msg) msg.textContent = ""; try { const rows = await api("/clusters"); + const frag = document.createDocumentFragment(); rows.forEach(function (c) { const tr = document.createElement("tr"); const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—"; const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—"; const nameEsc = escapeHtml(c.name); - const dlHref = API + "/clusters/" + encodeURIComponent(c.name) + "/kubeconfig"; tr.innerHTML = "" + nameEsc + @@ -223,40 +344,78 @@ "" + escapeHtml(String(wn)) + "" + - ""; - const td = tr.querySelector(".actions"); - const b1 = document.createElement("button"); - b1.type = "button"; - b1.className = "btn-small"; - b1.textContent = "Состояние"; - b1.addEventListener("click", function () { - openWorkloadsModal(c.name); - }); - td.appendChild(b1); - if (c.has_local_kubeconfig) { - const a = document.createElement("a"); - a.href = dlHref; - a.className = "btn-secondary btn-small"; - a.download = "kubeconfig-" + c.name + ".yaml"; - a.textContent = "kubeconfig"; - a.title = "Скачать kubeconfig"; - td.appendChild(a); + ""; + const td = tr.querySelector(".actions-toolbar"); + td.appendChild( + iconActionButton( + ICONS.state, + "Состояние: узлы и поды (kubectl get nodes / pods)", + "", + function () { + openWorkloadsModal(c.name); + }, + false, + ), + ); + td.appendChild( + iconActionButton( + ICONS.play, + "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне", + "", + function () { + startCluster(c.name); + }, + false, + ), + ); + if (c.registered_in_kind) { + td.appendChild( + iconActionButton( + ICONS.stop, + "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется", + "icon-btn--secondary", + function () { + stopCluster(c.name); + }, + false, + ), + ); } - const b2 = document.createElement("button"); - b2.type = "button"; - b2.className = "btn-small btn-danger"; - b2.textContent = "Удалить"; - b2.addEventListener("click", function () { - deleteCluster(c.name); - }); - td.appendChild(b2); - tbody.appendChild(tr); + td.appendChild( + iconActionButton( + ICONS.download, + c.has_local_kubeconfig + ? "Скачать kubeconfig для kubectl на хосте" + : "Kubeconfig ещё нет — появится после создания или подъёма кластера", + "icon-btn--secondary", + c.has_local_kubeconfig + ? function () { + downloadKubeconfig(c.name); + } + : null, + !c.has_local_kubeconfig, + ), + ); + td.appendChild( + iconActionButton( + ICONS.trash, + "Удалить кластер (kind delete) и каталог clusters/<имя>/", + "icon-btn--danger", + function () { + deleteCluster(c.name); + }, + false, + ), + ); + frag.appendChild(tr); }); - if (!rows.length && msg) msg.textContent = "Кластеров пока нет."; + tbody.replaceChildren(frag); + bindActionTooltipHosts(document.getElementById("tbl-clusters")); + if (msg) { + msg.textContent = rows.length ? "" : "Кластеров пока нет."; + } } catch (e) { if (msg) msg.textContent = "Ошибка списка: " + e.message; - } finally { - setBusy("clusters", false); } } @@ -264,17 +423,19 @@ const tbody = document.querySelector("#tbl-jobs tbody"); const msg = document.getElementById("jobs-msg"); if (!tbody) return; - setBusy("jobs", true); - tbody.innerHTML = ""; - if (msg) msg.textContent = ""; try { const rows = await api("/jobs?limit=30"); + const frag = document.createDocumentFragment(); rows.forEach(function (j) { const tr = document.createElement("tr"); const st = escapeHtml(j.status || ""); - var cellMsg = (j.message || "").slice(0, 160); + var kindTag = j.kind === "start_cluster" ? "[старт] " : ""; + var cellMsg = kindTag + (j.message || "").slice(0, 140); if ((j.status === "running" || j.status === "queued") && j.progress_stage) { - cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); + cellMsg = + kindTag + + j.progress_stage + + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); } tr.innerHTML = "