Веб-UI: логи kind create, старт/стоп кластеров, документация README

- Потоковые логи в 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
This commit is contained in:
Sergey Antropoff
2026-04-04 06:21:00 +03:00
parent 02f4c655b9
commit c1e867a01f
23 changed files with 1689 additions and 180 deletions
+2
View File
@@ -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 && pip3 install --no-cache-dir --break-system-packages -r /opt/kind-k8s/requirements.txt
COPY app/ /opt/kind-k8s/app/ 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 COPY scripts/run_uvicorn.sh /opt/kind-k8s/run_uvicorn.sh
RUN chmod +x /opt/kind-k8s/run_uvicorn.sh RUN chmod +x /opt/kind-k8s/run_uvicorn.sh
+9 -4
View File
@@ -4,7 +4,8 @@
# Все операции с Compose только с явным выбором среды: # Все операции с Compose только с явным выбором среды:
# make docker up | make docker down | make docker logs | … # make docker up | make docker down | make docker logs | …
# make podman up | make podman down | … # make podman up | make podman down | …
# Без префикса docker/podman цели up/down/logs/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 # Автор: Сергей Антропов — https://devops.org.ru
@@ -14,7 +15,7 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS)))
COMPOSE := docker compose COMPOSE := docker compose
endif 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)))) KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py
@@ -29,6 +30,7 @@ help: ## Справка по целям
@echo " make docker logs / make podman logs (follow -f)" @echo " make docker logs / make podman logs (follow -f)"
@echo " make docker ps / make podman ps (статус сервисов)" @echo " make docker ps / make podman ps (статус сервисов)"
@echo " make docker compose-build / make podman compose-build" @echo " make docker compose-build / make podman compose-build"
@echo " make docker rebuild / make podman rebuild (build --no-cache + up --force-recreate)"
@echo " make docker check-docker / make podman check-docker" @echo " make docker check-docker / make podman check-docker"
@echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)." @echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)."
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"} {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' @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) 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: _require_runtime:
@if [ -z "$(COMPOSE)" ]; then \ @if [ -z "$(COMPOSE)" ]; then \
echo >&2 "Укажите среду в той же команде, что и цель:"; \ echo >&2 "Укажите среду в той же команде, что и цель:"; \
echo >&2 " make docker up | make podman up"; \ echo >&2 " make docker up | make podman up"; \
echo >&2 " make docker down | make docker logs | make docker 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)"; \ echo >&2 " (или то же с префиксом podman)"; \
exit 1; \ exit 1; \
fi 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 compose-build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) 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
+15 -12
View File
@@ -47,7 +47,7 @@
**В образ не обязательно ставить на хост:** kind, Python приложения — они внутри контейнера. **В образ не обязательно ставить на хост:** 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`). После создания кластера 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 без пересборки образа ### Разработка 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`**. По умолчанию (**`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 logs` / `make podman logs` | Логи `kind-k8s-web` (stream, `-f`) |
| `make docker ps` / `make podman ps` | Статус контейнеров текущего compose-проекта | | `make docker ps` / `make podman ps` | Статус контейнеров текущего compose-проекта |
| `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` | | `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` |
| `make docker rebuild` / `make podman rebuild` | Пересборка образа **без кэша** (`build --no-cache`) и пересоздание контейнера (`up -d --force-recreate`) |
| `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` | | `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` |
| `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) | | `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) |
| `make clusters-dir` | Создать каталог `clusters/` | | `make clusters-dir` | Создать каталог `clusters/` |
| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `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_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов отдавать в API/UI |
| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub | | **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub |
| **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах | | **`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 | | **`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) ## 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/setup_env_interactive.py` | Интерактивное создание `.env` (все ключи и дефолты внутри скрипта) |
| `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` | | `scripts/run_uvicorn.sh` | Точка входа контейнера: uvicorn с опциональным `--reload` |
| `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI | | `Dockerfile` | Образ: kind, kubectl, docker-cli, FastAPI |
| `requirements.txt` | pip-зависимости веб-приложения | | `requirements.txt` | pip-зависимости веб-приложения |
| `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, сокет | | `docker-compose.yml` | Сервис `kind-k8s-web`, тома `./clusters`, `./app`, `./README.md`, сокет |
| `app/main.py` | FastAPI: дашборд `/`, редирект `/ui`, монтирование `/static` | | `app/main.py` | FastAPI: дашборд `/`, `/documentation`, редирект `/ui`, монтирование `/static` |
| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `clusters`) | | `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) |
| `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути | | `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
| `app/models/schemas.py` | Pydantic-схемы запросов/ответов API | | `app/models/schemas.py` | Pydantic-схемы запросов/ответов API |
| `app/templates/` | Jinja2: `base.html`, `dashboard.html` | | `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `documentation.html` |
| `app/static/` | `style.css`, `js/dashboard.js` | | `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) |
| `app/docs/` | `api_routes.md`, `README.md` | | `app/docs/` | `api_routes.md`, `README.md` |
| `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` | | `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. - На **Windows** без WSL удобнее WSL2 + Docker Desktop.
- Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера). - Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера).
- История заданий создания в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается. - История заданий создания в 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`).
+164 -4
View File
@@ -11,7 +11,7 @@ import logging
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from core.cluster_lifecycle import ( from core.cluster_lifecycle import (
KindClusterError, KindClusterError,
@@ -22,9 +22,18 @@ from core.cluster_lifecycle import (
kubectl_pods_all_namespaces, kubectl_pods_all_namespaces,
list_registered_kind_clusters, list_registered_kind_clusters,
read_meta_json, read_meta_json,
start_kind_cluster_containers,
stop_kind_cluster_containers,
validate_cluster_name, 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 core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir from kind_k8s_paths import clusters_dir
from models.schemas import ( from models.schemas import (
@@ -47,6 +56,13 @@ def _record_to_job_view(rec: JobRecord) -> JobView:
stage, pct = (None, None) stage, pct = (None, None)
if prog is not None: if prog is not None:
stage, pct = prog[0], prog[1] 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( return JobView(
job_id=rec.job_id, job_id=rec.job_id,
kind=rec.kind, kind=rec.kind,
@@ -57,6 +73,7 @@ def _record_to_job_view(rec: JobRecord) -> JobView:
result=rec.result, result=rec.result,
progress_stage=stage, progress_stage=stage,
progress_percent=pct, 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) 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( @router.post(
"/clusters", "/clusters",
response_model=ClusterCreateAccepted, 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} 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( @router.post(
"/jobs/{job_id}/cancel", "/jobs/{job_id}/cancel",
summary="Запросить отмену создания кластера", 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]: 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) rec = await job_store.get(job_id)
if not rec: if not rec:
+53
View File
@@ -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",
)
+2 -1
View File
@@ -8,9 +8,10 @@ from __future__ import annotations
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(health.router, prefix="") api_router.include_router(health.router, prefix="")
api_router.include_router(versions.router, prefix="") api_router.include_router(versions.router, prefix="")
api_router.include_router(docs_readme.router, prefix="")
api_router.include_router(clusters.router, prefix="") api_router.include_router(clusters.router, prefix="")
+165 -8
View File
@@ -14,6 +14,7 @@ import os
import re import re
import shutil import shutil
import subprocess import subprocess
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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) 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: def _run_capture_checked(cmd: list[str]) -> str:
p = subprocess.run(cmd, capture_output=True, text=True) p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0: if p.returncode != 0:
@@ -177,6 +214,7 @@ def create_cluster_non_interactive(
kubernetes_version_tag: str, kubernetes_version_tag: str,
workers: int, workers: int,
job_id: str | None = None, job_id: str | None = None,
use_existing_config: bool = False,
) -> CreateClusterResult: ) -> CreateClusterResult:
""" """
Создать кластер kind без диалогов. Создать кластер kind без диалогов.
@@ -184,12 +222,20 @@ def create_cluster_non_interactive(
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``. ``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``). ``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
``use_existing_config=True`` — не перезаписывать ``kind-config.yaml``, поднять кластер по уже
сохранённому файлу (каталог ``clusters/<имя>/`` должен существовать).
""" """
from core import job_store as _job_store from core import job_store as _job_store
def _progress(stage: str, pct: int) -> None: def _progress(stage: str, pct: int) -> None:
if job_id: if job_id:
_job_store.set_progress_sync(job_id, stage, pct) _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: def _cancelled() -> bool:
return bool(job_id and _job_store.is_cancelled_sync(job_id)) 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: if name in existing:
raise KindClusterError(f"Кластер «{name}» уже существует в kind.") 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.") raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag) ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
@@ -218,17 +264,39 @@ def create_cluster_non_interactive(
kube_path = out_dir / "kubeconfig" kube_path = out_dir / "kubeconfig"
meta_path = out_dir / "meta.json" meta_path = out_dir / "meta.json"
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) prev_meta_for_workers: dict[str, object] = {}
cfg_path.write_text(yaml_text, encoding="utf-8") 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(): if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем") 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) _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(): if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _rollback_after_cancel(cluster_name=name, out_dir=out_dir)
@@ -262,12 +330,22 @@ def create_cluster_non_interactive(
logger.info("Ноды готовы: %s", msg) logger.info("Ноды готовы: %s", msg)
else: else:
logger.warning("Ожидание нод не завершилось успешно: %s", msg) 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 = { meta = {
"cluster_name": name, "cluster_name": name,
"kubernetes_version_tag": ver_tag, "kubernetes_version_tag": ver_tag,
"node_image": node_image, "node_image": node_image,
"worker_nodes": workers, "worker_nodes": worker_nodes_meta,
"created_at_utc": datetime.now(timezone.utc).isoformat(), "created_at_utc": datetime.now(timezone.utc).isoformat(),
"kind_config_path": str(cfg_path.relative_to(root)), "kind_config_path": str(cfg_path.relative_to(root)),
"kubeconfig_path": str(kube_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(), "created_via_container": _in_container(),
"nodes_ready_after_create": nodes_ready, "nodes_ready_after_create": nodes_ready,
"nodes_ready_message": nodes_msg, "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") 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, cluster_name=name,
ver_tag=ver_tag, ver_tag=ver_tag,
node_image=node_image, node_image=node_image,
workers=workers, workers=worker_nodes_meta,
kubeconfig_path=kube_path, kubeconfig_path=kube_path,
meta_path=meta_path, meta_path=meta_path,
kubeconfig_patched_for_host=patched, 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) 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: def read_meta_json(cluster_name: str) -> dict[str, object] | None:
"""Прочитать ``clusters/<имя>/meta.json`` если есть.""" """Прочитать ``clusters/<имя>/meta.json`` если есть."""
p = clusters_dir() / cluster_name / "meta.json" p = clusters_dir() / cluster_name / "meta.json"
+52 -1
View File
@@ -12,9 +12,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
import threading import threading
import uuid import uuid
from dataclasses import dataclass from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -29,6 +31,46 @@ JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
_thread_lock = threading.Lock() _thread_lock = threading.Lock()
_cancel_events: dict[str, threading.Event] = {} _cancel_events: dict[str, threading.Event] = {}
_progress: dict[str, tuple[str, int]] = {} _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: def begin_job_tracking(job_id: str) -> None:
@@ -43,6 +85,7 @@ def end_job_tracking(job_id: str) -> None:
with _thread_lock: with _thread_lock:
_cancel_events.pop(job_id, None) _cancel_events.pop(job_id, None)
_progress.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: def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
@@ -88,6 +131,8 @@ class JobRecord:
created_at_utc: str created_at_utc: str
message: str | None = None message: str | None = None
result: dict[str, Any] | None = None result: dict[str, Any] | None = None
# Журнал после завершения (stdout/stderr kind create и этапы); пока задание активно — см. deque
log_lines: list[str] = field(default_factory=list)
class JobStore: class JobStore:
@@ -127,25 +172,31 @@ class JobStore:
set_progress_sync(job_id, "Запуск создания кластера…", 5) set_progress_sync(job_id, "Запуск создания кластера…", 5)
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None: async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "success" self._jobs[job_id].status = "success"
self._jobs[job_id].result = result self._jobs[job_id].result = result
self._jobs[job_id].message = message self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
set_progress_sync(job_id, "Готово", 100) set_progress_sync(job_id, "Готово", 100)
async def set_failed(self, job_id: str, message: str) -> None: async def set_failed(self, job_id: str, message: str) -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "failed" self._jobs[job_id].status = "failed"
self._jobs[job_id].message = message self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None: async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "cancelled" self._jobs[job_id].status = "cancelled"
self._jobs[job_id].message = message self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
logger.info("Задание %s отменено: %s", job_id, message) logger.info("Задание %s отменено: %s", job_id, message)
async def get(self, job_id: str) -> JobRecord | None: async def get(self, job_id: str) -> JobRecord | None:
+88
View File
@@ -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")
+80 -4
View File
@@ -10,19 +10,21 @@
| Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) | | Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) |
| ReDoc | `http://127.0.0.1:<порт>/redoc` | | ReDoc | `http://127.0.0.1:<порт>/redoc` |
| Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` | | 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` в репозитории | | Этот файл | `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) ## Веб-интерфейс и статика (не 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 /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
Шаблоны: `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/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/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` |
| GET | `/api/v1/stats` | Сводка для дашборда | | GET | `/api/v1/stats` | Сводка для дашборда |
| GET | `/api/v1/clusters` | Список кластеров | | GET | `/api/v1/clusters` | Список кластеров |
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) | | 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}` | Детали + `kubectl get nodes` при наличии kubeconfig |
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
@@ -49,6 +54,8 @@
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В ответе задания поля **`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` до завершения не прерывается. - Статус **`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 ## GET /api/v1/versions
Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI). Список стабильных тегов `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} ## GET /api/v1/jobs/{job_id}
Статус фонового задания создания. Статус фонового задания создания.
@@ -307,7 +374,15 @@
"cluster_name": "dev", "cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00", "created_at_utc": "2026-04-04T12:00:00+00:00",
"message": null, "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", "cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00", "created_at_utc": "2026-04-04T12:00:00+00:00",
"message": "Кластер создан", "message": "Кластер создан",
"progress_log": ["[95%] Финализация", "kubectl wait nodes: ..."],
"result": { "result": {
"cluster_name": "dev", "cluster_name": "dev",
"kubernetes_version_tag": "v1.29.4", "kubernetes_version_tag": "v1.29.4",
+15
View File
@@ -79,3 +79,18 @@ async def dashboard(request: Request) -> HTMLResponse:
async def ui_redirect() -> RedirectResponse: async def ui_redirect() -> RedirectResponse:
"""Удобный алиас на корень UI.""" """Удобный алиас на корень UI."""
return RedirectResponse(url="/", status_code=307) 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="<p>Шаблоны не найдены.</p>",
status_code=500,
)
return templates.TemplateResponse(
request,
"documentation.html",
{"app_title": settings.app_title},
)
+4
View File
@@ -43,6 +43,10 @@ class JobView(BaseModel):
result: dict[str, Any] | None = None result: dict[str, Any] | None = None
progress_stage: str | None = Field(default=None, description="Текущий этап создания (пока задание активно)") progress_stage: str | None = Field(default=None, description="Текущий этап создания (пока задание активно)")
progress_percent: int | None = Field(default=None, description="Прогресс 0–100 для индикатора в UI") 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): class ClusterSummary(BaseModel):
+428 -67
View File
@@ -1,6 +1,7 @@
/** /**
* Панель управления кластерами kind (REST /api/v1). * Панель управления кластерами kind (REST /api/v1).
* Автообновление списков и health; прогресс и отмена создания кластера. * Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная
* замена содержимого блоков (статистика, таблицы, плашка среды) SPA-поведение.
* *
* Автор: Сергей Антропов * Автор: Сергей Антропов
* Сайт: https://devops.org.ru * Сайт: https://devops.org.ru
@@ -23,6 +24,8 @@
var createInProgress = false; var createInProgress = false;
/** @type {string | null} */ /** @type {string | null} */
var currentPollJobId = null; var currentPollJobId = null;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
var currentModalClusterName = null;
function formatApiError(data, fallback) { function formatApiError(data, fallback) {
if (!data) return fallback; if (!data) return fallback;
@@ -66,6 +69,137 @@
return d.innerHTML; 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:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>',
play:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7 3 21 12 7 21 7 3"/></svg>',
stop:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>',
download:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
trash:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
};
/** @type {ReturnType<typeof setTimeout> | 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) { function showToast(message, isError) {
const el = document.getElementById("toast"); const el = document.getElementById("toast");
if (!el) return; if (!el) return;
@@ -78,17 +212,6 @@
}, 4500); }, 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) { function setStatusBannerClass(ok, degraded) {
const el = document.getElementById("status-banner"); const el = document.getElementById("status-banner");
if (!el) return; if (!el) return;
@@ -129,10 +252,9 @@
const dl = document.getElementById("stats-dl"); const dl = document.getElementById("stats-dl");
const errEl = document.getElementById("stats-err"); const errEl = document.getElementById("stats-err");
if (!dl) return; if (!dl) return;
errEl.classList.add("hidden");
dl.innerHTML = "";
try { try {
const s = await api("/stats"); const s = await api("/stats");
errEl.classList.add("hidden");
const rows = [ const rows = [
["Кластеров в kind", s.kind_clusters_count], ["Кластеров в kind", s.kind_clusters_count],
["Локальных каталогов", s.local_cluster_dirs_count], ["Локальных каталогов", s.local_cluster_dirs_count],
@@ -140,14 +262,16 @@
["Заданий в памяти", s.jobs_total], ["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed], ["Заданий с ошибкой", s.jobs_recent_failed],
]; ];
const frag = document.createDocumentFragment();
rows.forEach(function (kv) { rows.forEach(function (kv) {
const dt = document.createElement("dt"); const dt = document.createElement("dt");
dt.textContent = kv[0]; dt.textContent = kv[0];
const dd = document.createElement("dd"); const dd = document.createElement("dd");
dd.textContent = String(kv[1]); dd.textContent = String(kv[1]);
dl.appendChild(dt); frag.appendChild(dt);
dl.appendChild(dd); frag.appendChild(dd);
}); });
dl.replaceChildren(frag);
} catch (e) { } catch (e) {
errEl.textContent = "Статистика: " + e.message; errEl.textContent = "Статистика: " + e.message;
errEl.classList.remove("hidden"); errEl.classList.remove("hidden");
@@ -196,17 +320,14 @@
const tbody = document.querySelector("#tbl-clusters tbody"); const tbody = document.querySelector("#tbl-clusters tbody");
const msg = document.getElementById("list-msg"); const msg = document.getElementById("list-msg");
if (!tbody) return; if (!tbody) return;
setBusy("clusters", true);
tbody.innerHTML = "";
if (msg) msg.textContent = "";
try { try {
const rows = await api("/clusters"); const rows = await api("/clusters");
const frag = document.createDocumentFragment();
rows.forEach(function (c) { rows.forEach(function (c) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—"; 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 wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
const nameEsc = escapeHtml(c.name); const nameEsc = escapeHtml(c.name);
const dlHref = API + "/clusters/" + encodeURIComponent(c.name) + "/kubeconfig";
tr.innerHTML = tr.innerHTML =
"<td><code class=\"cluster-name\">" + "<td><code class=\"cluster-name\">" +
nameEsc + nameEsc +
@@ -223,40 +344,78 @@
"<td>" + "<td>" +
escapeHtml(String(wn)) + escapeHtml(String(wn)) +
"</td>" + "</td>" +
"<td class=\"actions\"></td>"; "<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
const td = tr.querySelector(".actions"); const td = tr.querySelector(".actions-toolbar");
const b1 = document.createElement("button"); td.appendChild(
b1.type = "button"; iconActionButton(
b1.className = "btn-small"; ICONS.state,
b1.textContent = "Состояние"; "Состояние: узлы и поды (kubectl get nodes / pods)",
b1.addEventListener("click", function () { "",
openWorkloadsModal(c.name); function () {
}); openWorkloadsModal(c.name);
td.appendChild(b1); },
if (c.has_local_kubeconfig) { false,
const a = document.createElement("a"); ),
a.href = dlHref; );
a.className = "btn-secondary btn-small"; td.appendChild(
a.download = "kubeconfig-" + c.name + ".yaml"; iconActionButton(
a.textContent = "kubeconfig"; ICONS.play,
a.title = "Скачать kubeconfig"; "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
td.appendChild(a); "",
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"); td.appendChild(
b2.type = "button"; iconActionButton(
b2.className = "btn-small btn-danger"; ICONS.download,
b2.textContent = "Удалить"; c.has_local_kubeconfig
b2.addEventListener("click", function () { ? "Скачать kubeconfig для kubectl на хосте"
deleteCluster(c.name); : "Kubeconfig ещё нет — появится после создания или подъёма кластера",
}); "icon-btn--secondary",
td.appendChild(b2); c.has_local_kubeconfig
tbody.appendChild(tr); ? 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) { } catch (e) {
if (msg) msg.textContent = "Ошибка списка: " + e.message; if (msg) msg.textContent = "Ошибка списка: " + e.message;
} finally {
setBusy("clusters", false);
} }
} }
@@ -264,17 +423,19 @@
const tbody = document.querySelector("#tbl-jobs tbody"); const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg"); const msg = document.getElementById("jobs-msg");
if (!tbody) return; if (!tbody) return;
setBusy("jobs", true);
tbody.innerHTML = "";
if (msg) msg.textContent = "";
try { try {
const rows = await api("/jobs?limit=30"); const rows = await api("/jobs?limit=30");
const frag = document.createDocumentFragment();
rows.forEach(function (j) { rows.forEach(function (j) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const st = escapeHtml(j.status || ""); const st = escapeHtml(j.status || "");
var cellMsg = (j.message || "").slice(0, 160); var kindTag = j.kind === "start_cluster" ? "[старт] " : "";
var cellMsg = kindTag + (j.message || "").slice(0, 140);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) { 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 = tr.innerHTML =
"<td><time datetime=\"" + "<td><time datetime=\"" +
@@ -293,15 +454,16 @@
"<td class=\"jobs-msg-cell\">" + "<td class=\"jobs-msg-cell\">" +
escapeHtml(cellMsg) + escapeHtml(cellMsg) +
"</td>"; "</td>";
tbody.appendChild(tr); frag.appendChild(tr);
}); });
if (!rows.length && msg) { tbody.replaceChildren(frag);
msg.textContent = "Заданий ещё не было (или контейнер перезапускали)."; if (msg) {
msg.textContent = rows.length
? ""
: "Заданий ещё не было (или контейнер перезапускали).";
} }
} catch (e) { } catch (e) {
if (msg) msg.textContent = "Задания: " + e.message; if (msg) msg.textContent = "Задания: " + e.message;
} finally {
setBusy("jobs", false);
} }
} }
@@ -311,11 +473,15 @@
const nodes = document.getElementById("modal-nodes"); const nodes = document.getElementById("modal-nodes");
const pods = document.getElementById("modal-pods"); const pods = document.getElementById("modal-pods");
const spin = document.getElementById("modal-spinner"); const spin = document.getElementById("modal-spinner");
const modalDlWrap = document.getElementById("modal-dl-wrap");
if (!overlay) return; if (!overlay) return;
hideActionTooltip();
currentModalClusterName = name;
document.getElementById("modal-title").textContent = "Кластер «" + name + "»"; document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
sub.textContent = ""; sub.textContent = "";
nodes.textContent = ""; nodes.textContent = "";
pods.textContent = ""; pods.textContent = "";
if (modalDlWrap) modalDlWrap.classList.add("hidden");
if (spin) spin.classList.remove("hidden"); if (spin) spin.classList.remove("hidden");
overlay.classList.remove("hidden"); overlay.classList.remove("hidden");
document.body.classList.add("modal-open"); document.body.classList.add("modal-open");
@@ -328,6 +494,7 @@
sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc; sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc;
nodes.textContent = w.nodes_output || "(пусто)"; nodes.textContent = w.nodes_output || "(пусто)";
pods.textContent = w.pods_output || "(пусто)"; pods.textContent = w.pods_output || "(пусто)";
if (modalDlWrap) modalDlWrap.classList.remove("hidden");
} catch (e) { } catch (e) {
sub.textContent = "Ошибка: " + e.message; sub.textContent = "Ошибка: " + e.message;
} finally { } finally {
@@ -335,14 +502,131 @@
} }
} }
/** Колбэк ожидающего Promise от openConfirmModal (одно окно за раз). */
var confirmModalResolver = null;
function isConfirmModalOpen() {
const el = document.getElementById("confirm-modal-overlay");
return !!(el && !el.classList.contains("hidden"));
}
/**
* Закрыть модалку подтверждения и вернуть результат в Promise.
* @param {boolean} confirmed
*/
function closeConfirmModal(confirmed) {
const ov = document.getElementById("confirm-modal-overlay");
if (ov) ov.classList.add("hidden");
document.body.classList.remove("modal-open");
if (confirmModalResolver) {
var fn = confirmModalResolver;
confirmModalResolver = null;
fn(!!confirmed);
}
}
/**
* Показать модальное подтверждение (вместо window.confirm).
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean }} opts
* @returns {Promise<boolean>}
*/
function openConfirmModal(opts) {
return new Promise(function (resolve) {
const ov = document.getElementById("confirm-modal-overlay");
const titleEl = document.getElementById("confirm-modal-title");
const msgEl = document.getElementById("confirm-modal-message");
const okBtn = document.getElementById("confirm-modal-ok");
if (!ov || !titleEl || !msgEl || !okBtn) {
resolve(false);
return;
}
if (confirmModalResolver) {
closeConfirmModal(false);
}
confirmModalResolver = resolve;
titleEl.textContent = opts.title || "Подтвердите действие";
msgEl.textContent = opts.message || "";
okBtn.textContent = opts.confirmLabel || "Подтвердить";
okBtn.className = opts.danger ? "btn-danger" : "";
ov.classList.remove("hidden");
document.body.classList.add("modal-open");
okBtn.focus();
});
}
function closeModal() { function closeModal() {
hideActionTooltip();
const overlay = document.getElementById("modal-overlay"); const overlay = document.getElementById("modal-overlay");
const modalDlWrap = document.getElementById("modal-dl-wrap");
if (modalDlWrap) modalDlWrap.classList.add("hidden");
currentModalClusterName = null;
if (overlay) overlay.classList.add("hidden"); if (overlay) overlay.classList.add("hidden");
document.body.classList.remove("modal-open"); document.body.classList.remove("modal-open");
} }
async function stopCluster(name) {
const ok = await openConfirmModal({
title: "Остановить узлы кластера?",
message:
"Кластер «" +
name +
"»: контейнеры будут остановлены (docker/podman stop). Запись в kind сохранится — позже можно снова нажать «Старт».",
confirmLabel: "Остановить",
danger: false,
});
if (!ok) return;
try {
const res = await api("/clusters/" + encodeURIComponent(name) + "/stop", { method: "POST" });
showToast(String(res.summary || "Узлы остановлены"), false);
await loadClusters();
await loadStats();
await loadHealth();
} catch (e) {
showToast(e.message, true);
}
}
/**
* Старт: либо docker start узлов (кластер уже в kind), либо фоновый kind create по kind-config.yaml.
*/
async function startCluster(name) {
const url = API + "/clusters/" + encodeURIComponent(name) + "/start";
const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
const text = await r.text();
var data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
if (r.status === 202 && data.job_id) {
setProgressHint(name);
pollJob(data.job_id);
return;
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
showToast(String(data.summary || "Готово"), false);
await loadClusters();
await loadStats();
await loadHealth();
}
async function deleteCluster(name) { async function deleteCluster(name) {
if (!confirm("Удалить кластер «" + name + "» и папку clusters/" + name + "?")) return; const ok = await openConfirmModal({
title: "Удалить кластер?",
message:
"Кластер «" +
name +
"»: будут выполнены kind delete и удаление каталога clusters/" +
name +
"/ на томе данных. Действие необратимо.",
confirmLabel: "Удалить",
danger: true,
});
if (!ok) return;
const msg = document.getElementById("list-msg"); const msg = document.getElementById("list-msg");
if (msg) msg.textContent = "Удаление…"; if (msg) msg.textContent = "Удаление…";
try { try {
@@ -376,6 +660,29 @@
if (!wrap) return; if (!wrap) return;
wrap.classList.toggle("hidden", !show); wrap.classList.toggle("hidden", !show);
wrap.setAttribute("aria-hidden", show ? "false" : "true"); wrap.setAttribute("aria-hidden", show ? "false" : "true");
if (!show) {
const logEl = document.getElementById("job-log-panel");
if (logEl) logEl.textContent = "";
}
}
/**
* Обновить текст подсказки над прогрессом (создание с нуля или старт по конфигу).
* @param {string | null} clusterName
*/
function setProgressHint(clusterName) {
const hint = document.getElementById("create-progress-hint");
if (!hint) return;
if (clusterName) {
hint.innerHTML =
"Кластер <code>" +
escapeHtml(clusterName) +
"</code>: подъём по конфигу или длительный <code>kind create</code> — ниже журнал (pull образов и ноды).";
} else {
hint.innerHTML =
"Создание кластера: шаг <code>kind create</code> может занять несколько минут при первом pull образов. " +
"Ниже — журнал в реальном времени.";
}
} }
function updateCreateProgressFromJob(j) { function updateCreateProgressFromJob(j) {
@@ -417,6 +724,8 @@
function pollJob(jobId) { function pollJob(jobId) {
const details = document.getElementById("job-details"); const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg"); const msg = document.getElementById("create-msg");
const logEl = document.getElementById("job-log-panel");
if (logEl) logEl.textContent = "";
if (details) { if (details) {
details.classList.remove("hidden"); details.classList.remove("hidden");
details.open = false; details.open = false;
@@ -434,20 +743,29 @@
const preEl = document.getElementById("job-json"); const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2); if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
updateCreateProgressFromJob(j); updateCreateProgressFromJob(j);
if (logEl && j.progress_log && j.progress_log.length) {
logEl.textContent = j.progress_log.join("\n");
logEl.scrollTop = logEl.scrollHeight;
}
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") { if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob(); stopPollJob();
setProgressHint(null);
if (msg) { if (msg) {
if (j.status === "success") msg.textContent = "Кластер создан."; if (j.status === "success") {
else if (j.status === "cancelled") msg.textContent = j.message || "Создание отменено."; msg.textContent =
j.kind === "start_cluster" ? "Кластер поднят по сохранённому конфигу." : "Кластер создан.";
} else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
else msg.textContent = "Ошибка: " + (j.message || ""); else msg.textContent = "Ошибка: " + (j.message || "");
} }
if (j.status === "success") showToast("Кластер создан", false); if (j.status === "success") {
else if (j.status === "cancelled") showToast(j.message || "Отменено", false); showToast(j.kind === "start_cluster" ? "Кластер запущен" : "Кластер создан", false);
else showToast(j.message || "Ошибка создания", true); } else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка", true);
await loadClusters(); await loadClusters();
await loadStats(); await loadStats();
await loadJobs(); await loadJobs();
await loadHealth();
} }
} catch (e) { } catch (e) {
if (msg) msg.textContent = "Ошибка опроса задания: " + e.message; if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
@@ -474,6 +792,7 @@
const details = document.getElementById("job-details"); const details = document.getElementById("job-details");
if (msg) msg.textContent = ""; if (msg) msg.textContent = "";
if (details) details.classList.add("hidden"); if (details) details.classList.add("hidden");
setProgressHint(null);
const fd = new FormData(form); const fd = new FormData(form);
const body = { const body = {
name: String(fd.get("name") || "").trim(), name: String(fd.get("name") || "").trim(),
@@ -505,6 +824,13 @@
const mClose = document.getElementById("modal-close"); const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal); if (mClose) mClose.addEventListener("click", closeModal);
const modalBtnKube = document.getElementById("modal-btn-kubeconfig");
if (modalBtnKube) {
modalBtnKube.addEventListener("click", function () {
if (currentModalClusterName) downloadKubeconfig(currentModalClusterName);
});
}
const overlay = document.getElementById("modal-overlay"); const overlay = document.getElementById("modal-overlay");
if (overlay) { if (overlay) {
overlay.addEventListener("click", function (ev) { overlay.addEventListener("click", function (ev) {
@@ -513,9 +839,43 @@
} }
document.addEventListener("keydown", function (ev) { document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeModal(); if (ev.key !== "Escape") return;
if (isConfirmModalOpen()) {
closeConfirmModal(false);
return;
}
hideActionTooltip();
closeModal();
}); });
const confirmOv = document.getElementById("confirm-modal-overlay");
const confirmCancel = document.getElementById("confirm-modal-cancel");
const confirmOk = document.getElementById("confirm-modal-ok");
if (confirmCancel) {
confirmCancel.addEventListener("click", function () {
closeConfirmModal(false);
});
}
if (confirmOk) {
confirmOk.addEventListener("click", function () {
closeConfirmModal(true);
});
}
if (confirmOv) {
confirmOv.addEventListener("click", function (ev) {
if (ev.target === confirmOv) closeConfirmModal(false);
});
}
window.addEventListener(
"scroll",
function () {
hideActionTooltip();
},
true,
);
window.addEventListener("resize", hideActionTooltip);
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS); autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
loadHealth(); loadHealth();
@@ -523,6 +883,7 @@
loadVersions(); loadVersions();
loadClusters(); loadClusters();
loadJobs(); loadJobs();
bindActionTooltipHosts(document.getElementById("modal-overlay"));
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
+75
View File
@@ -0,0 +1,75 @@
/**
* Страница /documentation: загрузка README через API и рендер Markdown.
* Зависимости из репозитория: /static/js/vendor/marked.min.js, purify.min.js
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
function showErr(el, msg) {
if (!el) return;
el.textContent = msg;
el.classList.remove("hidden");
}
async function run() {
const article = document.getElementById("readme-doc-article");
const errEl = document.getElementById("readme-error");
const loadEl = document.getElementById("readme-loading");
if (!article || !loadEl) return;
if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
loadEl.classList.add("hidden");
return;
}
try {
const r = await fetch(API + "/docs/readme", {
headers: { Accept: "text/markdown, text/plain, */*" },
});
const text = await r.text();
if (!r.ok) {
var detail = text;
try {
var j = JSON.parse(text);
if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
} catch (e) {
/* сырой текст */
}
showErr(errEl, "Не удалось загрузить README: " + (detail || r.statusText));
loadEl.classList.add("hidden");
return;
}
/* marked v15: переносы строк в параграфах (breaks). */
try {
if (marked.defaults && typeof marked.defaults === "object") {
marked.defaults.breaks = true;
marked.defaults.gfm = true;
}
} catch (e) {
/* игнорируем, если defaults защищены от записи */
}
var rawHtml =
typeof marked.parse === "function" ? marked.parse(text, { async: false }) : marked(text);
article.innerHTML = DOMPurify.sanitize(rawHtml);
article.removeAttribute("hidden");
loadEl.classList.add("hidden");
} catch (e) {
showErr(errEl, "Ошибка: " + (e.message || String(e)));
loadEl.classList.add("hidden");
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
})();
+8
View File
@@ -0,0 +1,8 @@
Вендорные скрипты для страницы /documentation (Markdown в браузере):
- marked v15.0.7 — https://github.com/markedjs/marked (лицензия MIT)
- DOMPurify v3.2.4 — https://github.com/cure53/DOMPurify (Apache-2.0 / MPL-2.0)
Файлы: marked.min.js, purify.min.js (без CDN, в репозитории).
Автор сводки: Сергей Антропов — https://devops.org.ru
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+362 -11
View File
@@ -196,6 +196,84 @@ body.modal-open {
font-weight: 600; font-weight: 600;
} }
/* Верхний ряд: панель управления слева, статистика справа */
.top-dashboard-row {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
align-items: stretch;
}
@media (min-width: 960px) {
.top-dashboard-row {
grid-template-columns: 1fr minmax(17rem, 24rem);
}
}
.top-dashboard-row .hero-panel {
margin-bottom: 0;
}
.stats-side-card {
display: flex;
flex-direction: column;
}
.stats-side-card h2 {
margin-bottom: 0.5rem;
}
.stats-dl--compact {
flex: 1;
margin: 0;
font-size: 0.88rem;
}
.create-cluster-card {
margin-bottom: 1rem;
}
/* Форма создания: две колонки, кнопка на всю ширину */
.create-form-grid {
display: grid;
gap: 0.75rem 1.5rem;
margin-top: 0.25rem;
}
@media (min-width: 640px) {
.create-form-grid {
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.create-form-col > label:first-of-type {
margin-top: 0;
}
.create-form-span {
grid-column: 1 / -1;
}
.create-ver-hint {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
.create-form-grid .create-actions {
margin-top: 0.25rem;
}
.create-form-grid .create-actions button {
margin-top: 0;
}
.create-form-col input,
.create-form-col select {
max-width: none;
width: 100%;
}
/* Одна карточка: заголовок + описание + строка состояния среды */ /* Одна карточка: заголовок + описание + строка состояния среды */
.hero-panel { .hero-panel {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -262,6 +340,29 @@ body.modal-open {
margin-top: 0; margin-top: 0;
} }
/* Журнал kind create в панели прогресса (потоковые строки из API) */
.job-log-panel-wrap {
margin: 0 0 0.65rem;
}
.job-log-label {
display: block;
font-size: 0.8rem;
margin-bottom: 0.25rem;
}
.job-log-panel {
margin: 0;
max-height: min(40vh, 280px);
overflow: auto;
padding: 0.5rem 0.65rem;
font-size: 0.78rem;
line-height: 1.35;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
}
.git-hint { .git-hint {
font-size: 0.85rem; font-size: 0.85rem;
margin: 0 0 0.65rem; margin: 0 0 0.65rem;
@@ -336,13 +437,6 @@ body.modal-open {
} }
/* Состояние загрузки таблиц */ /* Состояние загрузки таблиц */
/* Пока идёт запрос к API, секция слегка приглушается */
[data-busy].is-loading {
opacity: 0.55;
pointer-events: none;
transition: opacity 0.2s ease;
}
.job-details { .job-details {
margin-top: 0.75rem; margin-top: 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -365,6 +459,16 @@ body.modal-open {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
align-items: flex-start; align-items: flex-start;
} }
.modal-dl-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0 0 0.65rem;
}
.modal-dl-hint {
font-size: 0.8rem;
}
.modal-title-text { .modal-title-text {
margin: 0; margin: 0;
font-size: 1.05rem; font-size: 1.05rem;
@@ -599,12 +703,113 @@ button.btn-small {
font-size: 0.8rem; font-size: 0.8rem;
} }
td.actions { /* Колонка «Действия»: ширина по содержимому (иконки в ряд) */
#tbl-clusters th.col-actions,
#tbl-clusters td.actions {
width: 1%;
padding: 0.3rem 0.25rem;
text-align: center;
vertical-align: middle;
}
#tbl-clusters td.actions {
white-space: nowrap; white-space: nowrap;
} }
td.actions button { .actions-toolbar {
margin-top: 0; display: inline-flex;
margin-right: 0.25rem; flex-wrap: nowrap;
align-items: center;
gap: 0.12rem;
}
/* Обёртка иконки; текст подсказки — в #action-tooltip (JS, fixed), иначе режется overflow таблицы */
.icon-tooltip-host {
position: relative;
display: inline-flex;
vertical-align: middle;
}
/* Плавающая подсказка над/под иконкой (полный текст, переносы строк) */
.action-tooltip-floating {
position: fixed;
z-index: 400;
left: 0;
top: 0;
max-width: min(22rem, calc(100vw - 20px));
max-height: min(70vh, 22rem);
overflow-y: auto;
padding: 0.55rem 0.7rem;
box-sizing: border-box;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
hyphens: auto;
color: var(--fg);
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.action-tooltip-floating.hidden {
display: none;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.95rem;
height: 1.95rem;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(0, 0, 0, 0.15);
color: var(--fg);
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.icon-btn:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.2);
border-color: var(--accent);
color: var(--accent);
}
.icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.icon-btn svg {
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
}
.icon-btn--secondary {
background: transparent;
}
.icon-btn--danger {
color: #f87171;
border-color: rgba(185, 28, 28, 0.5);
background: rgba(185, 28, 28, 0.08);
}
.icon-btn--danger:hover:not(:disabled) {
color: #fca5a5;
border-color: #ef4444;
background: rgba(239, 68, 68, 0.14);
}
@media (prefers-color-scheme: light) {
.icon-btn {
background: rgba(0, 0, 0, 0.04);
}
.icon-btn--danger {
color: #b91c1c;
}
} }
/* Модальное окно «Состояние кластера» */ /* Модальное окно «Состояние кластера» */
@@ -622,6 +827,28 @@ td.actions button {
.modal-overlay.hidden { .modal-overlay.hidden {
display: none; display: none;
} }
/* Поверх модалки «Состояние» (z-index 100), если когда-либо понадобится стек */
.confirm-modal-overlay {
z-index: 150;
}
.modal-box--confirm {
max-width: 24rem;
}
.confirm-modal-title {
margin: 0 0 0.5rem;
font-size: 1.05rem;
}
.confirm-modal-message {
margin: 0 0 0.25rem;
white-space: pre-wrap;
}
.confirm-modal-actions {
margin-top: 1rem;
justify-content: flex-end;
}
.confirm-modal-actions button {
margin-top: 0;
}
.modal-box { .modal-box {
background: var(--card); background: var(--card);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -659,3 +886,127 @@ a.btn-danger {
button.btn-danger:hover { button.btn-danger:hover {
filter: brightness(1.12); filter: brightness(1.12);
} }
/* Страница «Документация»: README.md → HTML (Markdown) */
.readme-doc-card {
max-width: 52rem;
margin: 0 auto 2rem;
}
.readme-doc-toolbar {
align-items: center;
margin-bottom: 0.35rem;
}
.readme-doc-toolbar .page-title {
margin: 0;
}
.readme-doc-lead {
margin: 0 0 1rem;
font-size: 0.88rem;
}
.readme-doc-loading {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.readme-doc.markdown-body {
font-size: 0.95rem;
line-height: 1.55;
}
.readme-doc.markdown-body h1,
.readme-doc.markdown-body h2,
.readme-doc.markdown-body h3,
.readme-doc.markdown-body h4 {
margin: 1.25rem 0 0.5rem;
line-height: 1.25;
font-weight: 650;
}
.readme-doc.markdown-body h1 {
font-size: 1.45rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.35rem;
}
.readme-doc.markdown-body h2 {
font-size: 1.2rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.25rem;
}
.readme-doc.markdown-body h3 {
font-size: 1.05rem;
}
.readme-doc.markdown-body p {
margin: 0.5rem 0;
}
.readme-doc.markdown-body ul,
.readme-doc.markdown-body ol {
margin: 0.5rem 0;
padding-left: 1.35rem;
}
.readme-doc.markdown-body li {
margin: 0.2rem 0;
}
.readme-doc.markdown-body a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.readme-doc.markdown-body a:hover {
filter: brightness(1.15);
}
.readme-doc.markdown-body code {
font-size: 0.88em;
padding: 0.12em 0.35em;
border-radius: 4px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border);
}
.readme-doc.markdown-body pre {
margin: 0.65rem 0;
padding: 0.65rem 0.85rem;
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.25);
font-size: 0.82rem;
line-height: 1.4;
}
.readme-doc.markdown-body pre code {
padding: 0;
border: none;
background: transparent;
font-size: inherit;
}
.readme-doc.markdown-body blockquote {
margin: 0.65rem 0;
padding: 0.35rem 0.75rem;
border-left: 4px solid var(--accent);
background: rgba(59, 130, 246, 0.08);
color: var(--muted);
}
.readme-doc.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.88rem;
}
.readme-doc.markdown-body th,
.readme-doc.markdown-body td {
border: 1px solid var(--border);
padding: 0.4rem 0.55rem;
text-align: left;
}
.readme-doc.markdown-body th {
background: rgba(0, 0, 0, 0.15);
font-weight: 600;
}
.readme-doc.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.25rem 0;
}
@media (prefers-color-scheme: light) {
.readme-doc.markdown-body code {
background: rgba(0, 0, 0, 0.06);
}
.readme-doc.markdown-body pre {
background: rgba(0, 0, 0, 0.04);
}
}
+3 -1
View File
@@ -21,10 +21,10 @@
<div class="nav-brand"> <div class="nav-brand">
<span class="nav-logo" aria-hidden="true"></span> <span class="nav-logo" aria-hidden="true"></span>
<span class="nav-title">{{ app_title }}</span> <span class="nav-title">{{ app_title }}</span>
<span class="nav-tag muted">kind · локальные кластеры</span>
</div> </div>
<nav class="nav-links" aria-label="Разделы"> <nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link nav-pill nav-pill--home">Панель</a> <a href="/" class="nav-link nav-pill nav-pill--home">Панель</a>
<a href="/documentation" class="nav-link nav-pill">Документация</a>
<a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a> <a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a>
<a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a> <a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
<a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a> <a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
@@ -33,6 +33,8 @@
</header> </header>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div> <div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
{# Плавающие подсказки для иконок (position:fixed; не режутся overflow таблицы). #}
<div id="action-tooltip" class="action-tooltip-floating hidden" role="tooltip"></div>
<main id="main-content" class="app-main" tabindex="-1"> <main id="main-content" class="app-main" tabindex="-1">
{% block content %}{% endblock %} {% block content %}{% endblock %}
+118 -67
View File
@@ -9,81 +9,87 @@
Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code> (не в Git) Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code> (не в Git)
</p> </p>
<p class="footer-copyright"> <p class="footer-copyright">
© kind-k8s-develop · © {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a> <a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p> </p>
</div> </div>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title"> <div class="top-dashboard-row">
<div class="hero-panel-main"> <section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
<h1 class="page-title" id="hero-title">Панель управления</h1> <div class="hero-panel-main">
<p class="muted page-lead"> <h1 class="page-title" id="hero-title">Панель управления</h1>
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера. <p class="muted page-lead">
Данные обновляются автоматически. Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
</p> Данные обновляются автоматически.
</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>
<div class="card">
<h2>Создать кластер</h2>
<form id="form-create" novalidate>
<label for="fld-name">Имя (a-z0-9-)</label>
<input
id="fld-name"
name="name"
required
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
maxlength="63"
placeholder="dev"
autocomplete="off"
/>
<label for="version-select">Тег образа kindest/node (подсказка)</label>
<div class="row">
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
<option value="">— загрузить список —</option>
</select>
</div>
<p id="ver-hint" class="muted" style="margin:0.25rem 0 0;font-size:0.85rem">
Можно ввести версию вручную ниже.
</p> </p>
<label for="kubernetes_version">Версия Kubernetes / тег node</label> </div>
<input <div
name="kubernetes_version" id="status-banner"
id="kubernetes_version" class="hero-panel-status muted"
required role="status"
placeholder="1.29.4 или v1.29.4" aria-live="polite"
autocomplete="off" >
/> Проверка среды…
</div>
</section>
<label for="fld-workers">Worker-ноды (020)</label> <aside class="card stats-side-card" aria-labelledby="stats-heading">
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" /> <h2 id="stats-heading">Статистика</h2>
<dl id="stats-dl" class="stats-dl stats-dl--compact" aria-label="Сводка по кластерам и заданиям"></dl>
<p id="stats-err" class="msg hidden" role="alert"></p>
</aside>
</div>
<div class="row create-actions"> <section class="card create-cluster-card">
<button type="submit">Создать кластер</button> <h2>Создать кластер</h2>
<form id="form-create" novalidate>
<div class="create-form-grid">
<div class="create-form-col">
<label for="fld-name">Имя (a-z0-9-)</label>
<input
id="fld-name"
name="name"
required
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
maxlength="63"
placeholder="dev"
autocomplete="off"
/>
<label for="fld-workers">Worker-ноды (020)</label>
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
</div> </div>
</form>
<div class="create-form-col">
<label for="version-select">Тег образа kindest/node (подсказка) или введите версию вручную ниже</label>
<div class="row">
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
<option value="">— загрузить список —</option>
</select>
</div>
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
<input
name="kubernetes_version"
id="kubernetes_version"
required
placeholder="1.29.4 или v1.29.4"
autocomplete="off"
/>
</div>
<div class="create-form-span">
<div class="row create-actions">
<button type="submit">Создать кластер</button>
</div>
</div>
</div>
<div id="create-progress-wrap" class="create-progress-wrap hidden" aria-hidden="true"> <div id="create-progress-wrap" class="create-progress-wrap hidden" aria-hidden="true">
<p class="create-progress-hint muted" id="create-progress-hint"> <p class="create-progress-hint muted" id="create-progress-hint">
Идёт создание — шаг <code>kind create</code> может занять несколько минут при первом pull образов. Идёт операция — шаг <code>kind create</code> может занять несколько минут при первом pull образов.
Ниже выводится журнал (скачивание образов и подъём нод).
</p> </p>
<div <div
class="progress-track" class="progress-track"
@@ -97,7 +103,17 @@
<div class="progress-bar" id="create-progress-bar" style="width:0%"></div> <div class="progress-bar" id="create-progress-bar" style="width:0%"></div>
</div> </div>
<p class="progress-label" id="create-progress-label"></p> <p class="progress-label" id="create-progress-label"></p>
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить создание</button> <div class="job-log-panel-wrap">
<label for="job-log-panel" class="job-log-label muted">Журнал задания</label>
<pre
id="job-log-panel"
class="mono job-log-panel"
role="log"
aria-live="polite"
aria-relevant="additions"
></pre>
</div>
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить между этапами</button>
</div> </div>
<p id="create-msg" class="msg" aria-live="polite"></p> <p id="create-msg" class="msg" aria-live="polite"></p>
@@ -105,7 +121,7 @@
<summary>Технические детали (JSON)</summary> <summary>Технические детали (JSON)</summary>
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre> <pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
</details> </details>
</div> </form>
</section> </section>
<section class="card clusters-card"> <section class="card clusters-card">
@@ -113,7 +129,7 @@
<p class="muted git-hint"> <p class="muted git-hint">
Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta. Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
</p> </p>
<div class="table-wrap" data-busy="clusters"> <div class="table-wrap">
<table id="tbl-clusters" aria-label="Список кластеров"> <table id="tbl-clusters" aria-label="Список кластеров">
<thead> <thead>
<tr> <tr>
@@ -122,7 +138,7 @@
<th>kubeconfig</th> <th>kubeconfig</th>
<th>Версия</th> <th>Версия</th>
<th>Workers</th> <th>Workers</th>
<th>Действия</th> <th class="col-actions">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@@ -133,7 +149,7 @@
<section class="card jobs-card"> <section class="card jobs-card">
<h2>Последние задания</h2> <h2>Последние задания</h2>
<div class="table-wrap" data-busy="jobs"> <div class="table-wrap">
<table id="tbl-jobs" aria-label="Последние задания создания"> <table id="tbl-jobs" aria-label="Последние задания создания">
<thead> <thead>
<tr> <tr>
@@ -161,6 +177,22 @@
<h3 id="modal-title" class="modal-title-text">Состояние кластера</h3> <h3 id="modal-title" class="modal-title-text">Состояние кластера</h3>
<button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button> <button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button>
</div> </div>
<div id="modal-dl-wrap" class="modal-dl-wrap hidden">
<span
class="icon-tooltip-host"
data-tooltip="Скачать kubeconfig для использования с kubectl на хосте"
>
<button
type="button"
class="icon-btn"
id="modal-btn-kubeconfig"
aria-label="Скачать kubeconfig для kubectl на хосте"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</span>
<span class="muted modal-dl-hint">kubeconfig</span>
</div>
<div id="modal-spinner" class="modal-spinner hidden" aria-hidden="true"> <div id="modal-spinner" class="modal-spinner hidden" aria-hidden="true">
<span class="spinner" aria-label="Загрузка"></span> <span class="spinner" aria-label="Загрузка"></span>
<span class="muted">Запрос kubectl…</span> <span class="muted">Запрос kubectl…</span>
@@ -172,6 +204,25 @@
<pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre> <pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre>
</div> </div>
</div> </div>
{# Модальное подтверждение вместо window.confirm (удаление, остановка узлов). #}
<div
id="confirm-modal-overlay"
class="modal-overlay confirm-modal-overlay hidden"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-message"
>
<div class="modal-box modal-box--confirm">
<h3 id="confirm-modal-title" class="modal-title-text confirm-modal-title">Подтвердите действие</h3>
<p id="confirm-modal-message" class="modal-sub confirm-modal-message"></p>
<div class="confirm-modal-actions row spread">
<button type="button" class="btn-secondary" id="confirm-modal-cancel">Отмена</button>
<button type="button" id="confirm-modal-ok">Подтвердить</button>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+27
View File
@@ -0,0 +1,27 @@
{# Документация: README.md через GET /api/v1/docs/readme + marked + DOMPurify из static/vendor.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block page_title %}Документация{% endblock %}
{% block content %}
<section class="card readme-doc-card" aria-labelledby="readme-doc-heading">
<div class="readme-doc-toolbar row spread">
<h1 class="page-title" id="readme-doc-heading">Документация</h1>
<a href="/" class="nav-link nav-pill nav-pill--home">← Панель</a>
</div>
<p class="muted readme-doc-lead">
Текст загружается из <code>README.md</code> (эндпоинт <code>/api/v1/docs/readme</code>).
Разбор Markdown выполняется в браузере библиотеками из каталога <code>app/static/js/vendor/</code> (без внешних CDN).
</p>
<p id="readme-loading" class="muted readme-doc-loading">Загрузка документации…</p>
<p id="readme-error" class="msg hidden" role="alert"></p>
<article id="readme-doc-article" class="readme-doc markdown-body" hidden></article>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/vendor/marked.min.js"></script>
<script src="/static/js/vendor/purify.min.js"></script>
<script src="/static/js/documentation.js" defer></script>
{% endblock %}
+4
View File
@@ -22,6 +22,8 @@ services:
- ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock - ${CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
# Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна). # Локальная разработка: код с хоста сразу в контейнере (пересборка образа не нужна).
- ./app:/opt/kind-k8s/app - ./app:/opt/kind-k8s/app
# README для /documentation и GET /api/v1/docs/readme (без пересборки образа).
- ./README.md:/opt/kind-k8s/README.md:ro
working_dir: /opt/kind-k8s/app working_dir: /opt/kind-k8s/app
# Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000. # Хост по умолчанию 8080: порт 6000 в Chrome/Edge даёт ERR_UNSAFE_PORT; внутри контейнера слушает 6000.
ports: ports:
@@ -37,6 +39,8 @@ services:
KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-} KIND_K8S_VERSION_LIST_DISPLAY: ${KIND_K8S_VERSION_LIST_DISPLAY:-}
KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-} KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-}
KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-} KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-}
KIND_K8S_JOB_LOG_MAX_LINES: ${KIND_K8S_JOB_LOG_MAX_LINES:-}
KIND_K8S_README_PATH: ${KIND_K8S_README_PATH:-}
KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-} KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-}
KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-} KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-}
KIND_K8S_APP_TITLE: ${KIND_K8S_APP_TITLE:-} KIND_K8S_APP_TITLE: ${KIND_K8S_APP_TITLE:-}
+6
View File
@@ -41,6 +41,7 @@ _SETUP_DEFAULTS: dict[str, str] = {
"KIND_K8S_WAIT_NODES": "1", "KIND_K8S_WAIT_NODES": "1",
"KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300", "KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300",
"KIND_K8S_APP_TITLE": "Kind Clusters Dashboard", "KIND_K8S_APP_TITLE": "Kind Clusters Dashboard",
"KIND_K8S_README_PATH": "",
"KIND_K8S_UVICORN_RELOAD": "1", "KIND_K8S_UVICORN_RELOAD": "1",
"KIND_K8S_WEB_HOST": "0.0.0.0", "KIND_K8S_WEB_HOST": "0.0.0.0",
"KIND_K8S_WORKDIR": "", "KIND_K8S_WORKDIR": "",
@@ -115,6 +116,11 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
), ),
_EnvPrompt(None, "KIND_K8S_WAIT_NODES_TIMEOUT_SEC", "Таймаут kubectl wait (секунды)."), _EnvPrompt(None, "KIND_K8S_WAIT_NODES_TIMEOUT_SEC", "Таймаут kubectl wait (секунды)."),
_EnvPrompt(None, "KIND_K8S_APP_TITLE", "Заголовок OpenAPI и веб-интерфейса."), _EnvPrompt(None, "KIND_K8S_APP_TITLE", "Заголовок OpenAPI и веб-интерфейса."),
_EnvPrompt(
None,
"KIND_K8S_README_PATH",
"Путь к README.md для страницы /documentation; пусто — README рядом с app/ (в образе /opt/kind-k8s/README.md).",
),
_EnvPrompt( _EnvPrompt(
None, None,
"KIND_K8S_UVICORN_RELOAD", "KIND_K8S_UVICORN_RELOAD",