Веб-UI FastAPI, REST API v1, интерактивный setup без env.example

- Дашборд (Jinja2 + static), управление кластерами kind, задания и kubeconfig.
- API: health, stats, clusters CRUD, versions, jobs; документация app/docs/api_routes.md.
- Docker Compose: том app, uvicorn reload, KIND_K8S_PATCH_KUBECONFIG по умолчанию 1.
- setup_env_interactive.py: список переменных в скрипте, удалён env.example.
- Makefile: явный префикс docker/podman; прочие правки CLI и ядра кластеров.
This commit is contained in:
Sergey Antropoff
2026-04-04 05:39:53 +03:00
parent ae961ef5fe
commit e46a62cfdb
31 changed files with 2507 additions and 393 deletions

5
app/api/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""HTTP-слой (FastAPI).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

5
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Версия API v1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

View File

@@ -0,0 +1,5 @@
"""Маршруты API v1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

View File

@@ -0,0 +1,280 @@
"""CRUD-операции над кластерами kind и сводная статистика.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import FileResponse
from core.cluster_lifecycle import (
KindClusterError,
cluster_summary_for_api,
create_cluster_non_interactive,
delete_kind_cluster_and_data,
kubectl_nodes_wide,
kubectl_pods_all_namespaces,
list_registered_kind_clusters,
read_meta_json,
validate_cluster_name,
)
from core.job_store import job_store
from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir
from models.schemas import (
ClusterCreateAccepted,
ClusterCreateRequest,
ClusterSummary,
ClusterWorkloadsResponse,
JobView,
StatsResponse,
)
logger = logging.getLogger("kind_k8s.api.clusters")
router = APIRouter(tags=["clusters"])
def _stats_sync() -> StatsResponse:
"""Собрать статистику (синхронно; вызывать из thread при необходимости)."""
kind_names = list_registered_kind_clusters()
cdir = clusters_dir()
subdirs: list[str] = []
if cdir.is_dir():
subdirs = sorted(p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith("."))
total_workers = 0
counted = False
for name in subdirs:
meta = read_meta_json(name)
if not meta:
continue
w = meta.get("worker_nodes")
if w is None:
continue
try:
total_workers += int(w)
counted = True
except (TypeError, ValueError):
continue
jobs = job_store.snapshot_all()
failed = sum(1 for j in jobs if j.status == "failed")
return StatsResponse(
kind_clusters_count=len(kind_names),
local_cluster_dirs_count=len(subdirs),
total_workers_from_meta=total_workers if counted else None,
jobs_total=len(jobs),
jobs_recent_failed=failed,
)
@router.get("/stats", response_model=StatsResponse, summary="Статистика")
async def get_stats() -> StatsResponse:
"""Число кластеров kind, локальных каталогов, сумма workers из meta (если есть), счётчики заданий."""
return await asyncio.to_thread(_stats_sync)
@router.get("/jobs", response_model=list[JobView], summary="Список заданий")
async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]:
"""История создания кластеров (в памяти процесса; после перезапуска контейнера пусто)."""
items = job_store.snapshot_recent_sorted(limit=limit)
return [
JobView(
job_id=r.job_id,
kind=r.kind,
status=r.status,
cluster_name=r.cluster_name,
created_at_utc=r.created_at_utc,
message=r.message,
result=r.result,
)
for r in items
]
@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров")
async def list_clusters() -> list[ClusterSummary]:
"""Объединение: зарегистрированные в kind + каталоги в ``clusters/`` (без дубликатов в выдаче)."""
kind_names = set(list_registered_kind_clusters())
cdir = clusters_dir()
dir_names: set[str] = set()
if cdir.is_dir():
dir_names = {p.name for p in cdir.iterdir() if p.is_dir() and not p.name.startswith(".")}
all_names = sorted(kind_names | dir_names)
out: list[ClusterSummary] = []
for name in all_names:
summary = cluster_summary_for_api(name)
out.append(
ClusterSummary(
name=str(summary["name"]),
registered_in_kind=bool(summary["registered_in_kind"]),
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
)
)
logger.debug("list_clusters: %s записей", len(out))
return out
@router.get(
"/clusters/{name}/kubeconfig",
summary="Скачать kubeconfig",
responses={404: {"description": "Файл не найден"}},
)
async def download_kubeconfig(name: str) -> FileResponse:
"""Файл ``clusters/<имя>/kubeconfig`` для использования с хоста (локальная dev-среда)."""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
path = clusters_dir() / name / "kubeconfig"
if not path.is_file():
raise HTTPException(status_code=404, detail="kubeconfig не найден")
logger.info("Отдача kubeconfig для кластера %s", name)
return FileResponse(
path=path,
filename=f"kubeconfig-{name}.yaml",
media_type="application/x-yaml",
)
@router.get(
"/clusters/{name}/workloads",
response_model=ClusterWorkloadsResponse,
summary="Узлы и поды (kubectl)",
)
async def cluster_workloads(name: str) -> ClusterWorkloadsResponse:
"""``kubectl get nodes`` и ``kubectl get pods -A`` по сохранённому kubeconfig."""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
kc = clusters_dir() / name / "kubeconfig"
if not kc.is_file():
return ClusterWorkloadsResponse(cluster_name=name, error="Нет сохранённого kubeconfig в clusters/<имя>/")
nodes_rc, nodes_out = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=kc)
pods_rc, pods_out = await asyncio.to_thread(kubectl_pods_all_namespaces, kubeconfig=kc)
return ClusterWorkloadsResponse(
cluster_name=name,
nodes_rc=nodes_rc,
nodes_output=nodes_out,
pods_rc=pods_rc,
pods_output=pods_out,
)
@router.get("/clusters/{name}", summary="Детали кластера")
async def get_cluster(name: str) -> dict[str, object]:
"""Сводка и попытка ``kubectl get nodes`` (предпочтительно сохранённый kubeconfig)."""
summary = cluster_summary_for_api(name)
saved = clusters_dir() / name / "kubeconfig"
kubectl_rc: int | None = None
kubectl_msg: str | None = None
if saved.is_file():
rc, msg = await asyncio.to_thread(kubectl_nodes_wide, kubeconfig=saved)
kubectl_rc = rc
kubectl_msg = msg
return {
**summary,
"kubectl_get_nodes_rc": kubectl_rc,
"kubectl_get_nodes": kubectl_msg,
}
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
async with kind_cluster_lock:
await job_store.set_running(job_id)
try:
result = await asyncio.to_thread(
create_cluster_non_interactive,
name=body.name.strip(),
kubernetes_version_tag=body.kubernetes_version.strip(),
workers=body.workers,
)
except KindClusterError as e:
await job_store.set_failed(job_id, str(e))
logger.warning("create job %s: %s", job_id, e)
return
except Exception as e:
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
logger.exception("create job %s: непредвиденная ошибка", job_id)
return
payload: dict[str, Any] = {
"cluster_name": result.cluster_name,
"kubernetes_version_tag": result.ver_tag,
"node_image": result.node_image,
"workers": result.workers,
"kubeconfig_path": str(result.kubeconfig_path),
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
"nodes_ready": result.nodes_ready,
"nodes_ready_message": result.nodes_ready_message,
}
await job_store.set_success(job_id, result=payload, message="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
@router.post(
"/clusters",
response_model=ClusterCreateAccepted,
status_code=202,
summary="Создать кластер (фон)",
)
async def post_create_cluster(
body: ClusterCreateRequest,
background_tasks: BackgroundTasks,
) -> ClusterCreateAccepted:
"""Поставить создание кластера в фон; идентификатор задания — в ответе."""
if not validate_cluster_name(body.name.strip()):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
existing = await asyncio.to_thread(list_registered_kind_clusters)
if body.name.strip() in existing:
raise HTTPException(status_code=409, detail="Кластер с таким именем уже есть в kind")
rec = await job_store.create_job("create_cluster", cluster_name=body.name.strip())
background_tasks.add_task(_run_create_job, rec.job_id, body)
logger.info("Принят запрос на создание кластера %s, job_id=%s", body.name, rec.job_id)
return ClusterCreateAccepted(job_id=rec.job_id)
@router.delete("/clusters/{name}", summary="Удалить кластер")
async def delete_cluster(name: str) -> dict[str, object]:
"""``kind delete`` и удаление локальной папки ``clusters/<имя>/``."""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
def _do() -> tuple[bool, str]:
return delete_kind_cluster_and_data(name=name, log_to_stdout=False)
try:
kind_ok, summary = await asyncio.to_thread(_do)
except KindClusterError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
logger.info("Удаление кластера %s: kind_ok=%s", name, kind_ok)
return {"name": name, "kind_delete_ok": kind_ok, "summary": summary}
@router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания")
async def get_job(job_id: str) -> JobView:
"""Узнать состояние фонового создания кластера."""
rec = await job_store.get(job_id)
if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено")
return JobView(
job_id=rec.job_id,
kind=rec.kind,
status=rec.status,
cluster_name=rec.cluster_name,
created_at_utc=rec.created_at_utc,
message=rec.message,
result=rec.result,
)

View File

@@ -0,0 +1,50 @@
"""Проверка живости сервиса и доступности движка контейнеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
import shutil
from fastapi import APIRouter
from core.cluster_lifecycle import container_engine_ping
logger = logging.getLogger("kind_k8s.api.health")
router = APIRouter(tags=["health"])
@router.get("/health", summary="Состояние сервиса и среды")
async def health() -> dict[str, object]:
"""
Проверка: процесс отвечает, в PATH есть kind/kubectl, доступен Docker/Podman API (``info``).
Без рабочего сокета создание kind-кластеров из UI невозможно.
"""
kind_ok = shutil.which("kind") is not None
kubectl_ok = shutil.which("kubectl") is not None
engine_ok, engine_msg, cli = await asyncio.to_thread(container_engine_ping)
logger.debug(
"health: kind=%s kubectl=%s engine=%s cli=%s",
kind_ok,
kubectl_ok,
engine_ok,
cli,
)
overall = "ok" if (kind_ok and kubectl_ok and engine_ok) else "degraded"
return {
"status": overall,
"kind_in_path": kind_ok,
"kubectl_in_path": kubectl_ok,
"container_cli": cli,
"container_engine_ok": engine_ok,
"container_engine_detail": engine_msg if not engine_ok else None,
}

View File

@@ -0,0 +1,36 @@
"""Список доступных тегов kindest/node (Docker Hub) для выпадающего списка в UI.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
import os
from fastapi import APIRouter
from kindest_node_tags import fetch_kindest_node_tags
logger = logging.getLogger("kind_k8s.api.versions")
router = APIRouter(tags=["versions"])
@router.get("/versions", summary="Теги kindest/node")
async def list_kindest_versions() -> dict[str, object]:
"""
Вернуть отсортированный список стабильных тегов (как при интерактивном выборе версии в UI/CLI).
При ``KIND_K8S_SKIP_VERSION_LIST=1`` возвращает пустой список — UI может предложить ввод вручную.
"""
skip = os.environ.get("KIND_K8S_SKIP_VERSION_LIST", "").strip().lower() in ("1", "true", "yes", "да")
if skip:
logger.info("Список версий отключён (KIND_K8S_SKIP_VERSION_LIST)")
return {"tags": [], "skipped": True, "reason": "KIND_K8S_SKIP_VERSION_LIST"}
tags = await asyncio.to_thread(fetch_kindest_node_tags)
logger.info("Отдано тегов kindest/node: %s", len(tags))
return {"tags": tags, "skipped": False}

16
app/api/v1/router.py Normal file
View File

@@ -0,0 +1,16 @@
"""Сборка маршрутов API v1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
from fastapi import APIRouter
from api.v1.endpoints import clusters, health, versions
api_router = APIRouter()
api_router.include_router(health.router, prefix="")
api_router.include_router(versions.router, prefix="")
api_router.include_router(clusters.router, prefix="")

View File

@@ -104,7 +104,7 @@ def _print_cluster(name: str, *, kube_path_saved: Path | None) -> None:
use_path: str | None = None
if kube_path_saved and kube_path_saved.is_file():
use_path = str(kube_path_saved)
print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после make create).")
print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после создания кластера).")
tmp_kc: str | None = None
if not use_path:
@@ -141,7 +141,7 @@ def main() -> None:
if not shutil.which("kind"):
print("Не найден kind.", file=sys.stderr)
print(" Обычно запускают: make -C kind-k8s-develop status (kind внутри образа).", file=sys.stderr)
print(" Статус узлов — в веб-интерфейсе (make docker up) или внутри контейнера kind-k8s-web.", file=sys.stderr)
print(
" Либо установите kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation",
file=sys.stderr,

View File

@@ -303,6 +303,37 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None:
return None
def _container_cli_bin() -> str:
"""Имя CLI к сокету (docker / podman), как в kubeconfig_patch."""
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]:
"""
Проверить доступ к движку контейнеров (``docker info`` / ``podman info``).
Возвращает (успех, краткое сообщение или stderr, имя CLI).
"""
cli = _container_cli_bin()
if not shutil.which(cli):
return False, f"«{cli}» не найден в PATH", cli
try:
p = subprocess.run(
[cli, "info"],
capture_output=True,
text=True,
timeout=timeout_sec,
)
except subprocess.TimeoutExpired:
logger.warning("%s info: таймаут %s с", cli, timeout_sec)
return False, f"таймаут {timeout_sec} с", cli
if p.returncode == 0:
return True, "OK", cli
err = (p.stderr or p.stdout or "").strip() or f"код {p.returncode}"
logger.info("%s info неуспешно: %s", cli, err[:200])
return False, err[:800], cli
def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get nodes -o wide``; возвращает (код, объединённый вывод)."""
p = subprocess.run(
@@ -325,6 +356,27 @@ def kubectl_nodes_wide(*, kubeconfig: str | Path) -> tuple[int, str]:
return p.returncode, msg
def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
"""``kubectl get pods -A``; сводка подов по кластеру."""
p = subprocess.run(
[
"kubectl",
"--kubeconfig",
str(kubeconfig),
"get",
"pods",
"-A",
"--request-timeout=20s",
],
capture_output=True,
text=True,
)
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
msg = out if out else err
return p.returncode, msg
def cluster_summary_for_api(name: str) -> dict[str, object]:
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
meta = read_meta_json(name) or {}

View File

@@ -1,4 +1,7 @@
"""Настройки веб-приложения из переменных окружения (и опционально ``.env`` в рабочем каталоге).
"""Настройки веб-приложения из переменных окружения.
Переменные задаются в ``docker-compose`` и/или в ``.env`` в корне репозитория
(Compose подставляет их в ``environment`` процесса — отдельный ``env_file`` в коде не требуется).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
@@ -6,34 +9,34 @@
from __future__ import annotations
from pathlib import Path
from pydantic import Field
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Каталог пакета app/ — для поиска .env рядом с кодом (в образе: /opt/kind-k8s/app).
_APP_DIR = Path(__file__).resolve().parents[1]
_REPO_ROOT = _APP_DIR.parent
_DEFAULT_TITLE = "kind-k8s-develop"
class Settings(BaseSettings):
"""Параметры HTTP-сервера и поведения UI."""
model_config = SettingsConfigDict(
env_file=(
str(_REPO_ROOT / ".env"),
str(_APP_DIR / ".env"),
),
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
# Пустая строка из docker-compose (${VAR:-}) не должна затирать заголовок OpenAPI.
env_ignore_empty=True,
)
kind_k8s_web_host: str = Field(default="0.0.0.0", validation_alias="KIND_K8S_WEB_HOST")
kind_k8s_web_port: int = Field(default=6000, validation_alias="KIND_K8S_WEB_PORT")
# Заголовок в OpenAPI / HTML (без хардкода в шаблонах).
app_title: str = Field(default="kind-k8s-develop", validation_alias="KIND_K8S_APP_TITLE")
# Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI.
app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE")
@field_validator("app_title", mode="before")
@classmethod
def _non_empty_title(cls, v: object) -> object:
if v is None or (isinstance(v, str) and not v.strip()):
return _DEFAULT_TITLE
return v
def get_settings() -> Settings:

View File

@@ -11,12 +11,15 @@ from __future__ import annotations
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal
logger = logging.getLogger("kind_k8s.job_store")
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
_MAX_JOBS = 200
JobStatus = Literal["queued", "running", "success", "failed"]
@@ -53,6 +56,10 @@ class JobStore:
)
async with self._lock:
self._jobs[jid] = rec
while len(self._jobs) > _MAX_JOBS:
oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc)
del self._jobs[oldest_id]
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec
@@ -84,6 +91,12 @@ class JobStore:
"""Снимок всех заданий (для отладки; без блокировки — eventual consistency)."""
return list(self._jobs.values())
def snapshot_recent_sorted(self, *, limit: int) -> list[JobRecord]:
"""Задания от новых к старым, не более ``limit``."""
items = self.snapshot_all()
items.sort(key=lambda r: r.created_at_utc, reverse=True)
return items[: max(1, limit)]
# Синглтон на процесс uvicorn
job_store = JobStore()

13
app/core/kind_guard.py Normal file
View File

@@ -0,0 +1,13 @@
"""Глобальная блокировка для операций kind (последовательное создание/удаление).
Параллельные ``kind create`` на одном Docker-движке часто нежелательны.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
kind_cluster_lock = asyncio.Lock()

View File

@@ -7,7 +7,7 @@
Сайт: https://devops.org.ru
Требования: kind, клиент контейнеров (``docker`` к сокету Docker/Podman) и kubectl в PATH.
Рекомендуется: ``make create`` из каталога kind-k8s-develop — всё внутри Docker, на хосте только Docker.
Рекомендуется: веб-интерфейс (``make docker up``) — всё внутри Docker, на хосте только Docker и make.
Пакетный режим: ``--non-interactive --name X --kubernetes-version 1.29.4 [--workers N]``.
"""
@@ -21,7 +21,6 @@ import os
import shutil
import subprocess
import sys
from pathlib import Path
from core.cluster_lifecycle import (
CreateClusterResult,
@@ -154,7 +153,7 @@ def _run_interactive() -> None:
if not _which("kind"):
print("Не найден бинарник kind.", file=sys.stderr)
print(" Установка kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", file=sys.stderr)
print(" Через Docker: make -C kind-k8s-develop create (или make create из каталога репозитория).", file=sys.stderr)
print(" Через Docker: make -C kind-k8s-develop docker up и веб-интерфейс.", file=sys.stderr)
sys.exit(127)
cli = _container_cli_bin()
if not _which(cli):
@@ -173,7 +172,7 @@ def _run_interactive() -> None:
print("Некорректное имя: только строчные буквы, цифры, дефис; не длиннее 63 символов.")
continue
if name in existing:
print(f"Кластер «{name}» уже существует в kind. Выберите другое имя или удалите его (make delete).")
print(f"Кластер «{name}» уже существует в kind. Удалите его в веб-интерфейсе или другое имя.")
continue
break

View File

@@ -25,15 +25,6 @@ def _configure_logging() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
def _run(cmd: list[str]) -> int:
p = subprocess.run(cmd, capture_output=True, text=True)
if p.stdout:
print(p.stdout, end="")
if p.stderr:
print(p.stderr, end="", file=sys.stderr)
return p.returncode
def _list_kind_clusters() -> list[str]:
p = subprocess.run(["kind", "get", "clusters"], capture_output=True, text=True)
if p.returncode != 0:
@@ -51,7 +42,7 @@ def _interactive() -> None:
CLUSTERS_DIR = clusters_dir()
if not shutil.which("kind"):
print("Не найден kind.", file=sys.stderr)
print(" Через Docker: make -C kind-k8s-develop delete (или make delete из каталога репозитория).", file=sys.stderr)
print(" Через Docker: make -C kind-k8s-develop docker up и удаление в веб-интерфейсе.", file=sys.stderr)
sys.exit(127)
clusters = _list_kind_clusters()

290
app/docs/api_routes.md Normal file
View File

@@ -0,0 +1,290 @@
# Описание REST API веб-интерфейса kind-k8s-develop
**Базовый префикс:** `/api/v1`
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
Интерактивная документация OpenAPI: после запуска `make docker up` откройте [http://127.0.0.1:6000/docs](http://127.0.0.1:6000/docs).
---
## GET /api/v1/health
Проверка: `kind`/`kubectl` в PATH и ответ движка контейнеров (`docker info` / `podman info` по `CONTAINER_CLI`).
`status`: `ok` — всё готово к созданию кластеров; `degraded` — чего-то не хватает (см. поля ниже).
**Пример ответа 200 (JSON):**
```json
{
"status": "ok",
"kind_in_path": true,
"kubectl_in_path": true,
"container_cli": "docker",
"container_engine_ok": true,
"container_engine_detail": null
}
```
**Если сокет Docker недоступен:**
```json
{
"status": "degraded",
"kind_in_path": true,
"kubectl_in_path": true,
"container_cli": "docker",
"container_engine_ok": false,
"container_engine_detail": "Cannot connect to the Docker daemon..."
}
```
---
## GET /api/v1/versions
Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI).
При `KIND_K8S_SKIP_VERSION_LIST=1` список пустой.
**Пример ответа 200:**
```json
{
"tags": ["v1.32.0", "v1.31.4"],
"skipped": false
}
```
**Пример при пропуске загрузки:**
```json
{
"tags": [],
"skipped": true,
"reason": "KIND_K8S_SKIP_VERSION_LIST"
}
```
---
## GET /api/v1/stats
Сводная статистика для дашборда.
**Пример ответа 200:**
```json
{
"kind_clusters_count": 2,
"local_cluster_dirs_count": 2,
"total_workers_from_meta": 4,
"jobs_total": 5,
"jobs_recent_failed": 1
}
```
Поле `total_workers_from_meta` может быть `null`, если ни в одном `meta.json` нет `worker_nodes`.
---
## GET /api/v1/clusters
Список имён: объединение `kind get clusters` и подкаталогов `clusters/*`.
**Пример ответа 200 (массив):**
```json
[
{
"name": "dev",
"registered_in_kind": true,
"has_local_kubeconfig": true,
"meta": {
"cluster_name": "dev",
"kubernetes_version_tag": "v1.29.4",
"node_image": "kindest/node:v1.29.4",
"worker_nodes": 2,
"kubeconfig_patched_for_host": true
}
}
]
```
---
## GET /api/v1/jobs
Список последних фоновых заданий (создание кластера), от новых к старым. Данные только в памяти процесса.
**Query:** `limit` (1200, по умолчанию 30).
**Пример ответа 200 (массив `JobView`):**
```json
[
{
"job_id": "abc123",
"kind": "create_cluster",
"status": "success",
"cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00",
"message": "Кластер создан",
"result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" }
}
]
```
---
## GET /api/v1/clusters/{name}/kubeconfig
Скачать файл `kubeconfig` (ответ — тело файла, `Content-Disposition` с именем `kubeconfig-{name}.yaml`).
**Ошибка 404:** файла нет в `clusters/{name}/`.
---
## GET /api/v1/clusters/{name}/workloads
`kubectl get nodes -o wide` и `kubectl get pods -A` по сохранённому kubeconfig.
**Пример ответа 200:**
```json
{
"cluster_name": "dev",
"nodes_rc": 0,
"nodes_output": "NAME STATUS ROLES ...",
"pods_rc": 0,
"pods_output": "NAMESPACE NAME READY STATUS ...",
"error": null
}
```
Если kubeconfig нет: `"error": "Нет сохранённого kubeconfig..."`, остальные поля подов/узлов — `null`.
---
## GET /api/v1/clusters/{name}
Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть).
**Пример ответа 200:**
```json
{
"name": "dev",
"registered_in_kind": true,
"has_local_kubeconfig": true,
"kubeconfig_path": "/work/clusters/dev/kubeconfig",
"meta": { "worker_nodes": 2 },
"kubectl_get_nodes_rc": 0,
"kubectl_get_nodes": "NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME\n..."
}
```
---
## POST /api/v1/clusters
Создание кластера **в фоне** (ответ 202).
**Тело запроса (JSON):**
```json
{
"name": "dev",
"kubernetes_version": "1.29.4",
"workers": 2
}
```
**Пример ответа 202:**
```json
{
"job_id": "a1b2c3d4e5f6...",
"status": "queued",
"message": "Создание кластера выполняется в фоне; опросите GET /api/v1/jobs/{job_id}"
}
```
**Ошибка 409 (кластер уже есть в kind):**
```json
{
"detail": "Кластер с таким именем уже есть в kind"
}
```
---
## GET /api/v1/jobs/{job_id}
Статус фонового задания создания.
**В процессе (пример 200):**
```json
{
"job_id": "a1b2...",
"kind": "create_cluster",
"status": "running",
"cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00",
"message": null,
"result": null
}
```
**Успех (пример 200):**
```json
{
"job_id": "a1b2...",
"kind": "create_cluster",
"status": "success",
"cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00",
"message": "Кластер создан",
"result": {
"cluster_name": "dev",
"kubernetes_version_tag": "v1.29.4",
"node_image": "kindest/node:v1.29.4",
"workers": 2,
"kubeconfig_path": "/work/clusters/dev/kubeconfig",
"kubeconfig_patched_for_host": true,
"nodes_ready": true,
"nodes_ready_message": "..."
}
}
```
**Ошибка 404:**
```json
{
"detail": "Задание не найдено"
}
```
---
## DELETE /api/v1/clusters/{name}
`kind delete cluster` и удаление каталога `clusters/{name}/`.
**Пример ответа 200:**
```json
{
"name": "dev",
"kind_delete_ok": true,
"summary": "kind delete: OK; удалена папка /work/clusters/dev"
}
```
---
## GET /
HTML-дашборд (не JSON): форма создания, таблица кластеров, ссылки на Swagger.

80
app/main.py Normal file
View File

@@ -0,0 +1,80 @@
"""Веб-интерфейс и REST API для управления локальными кластерами kind (порт по умолчанию 6000).
Запуск в контейнере: ``python3 -m uvicorn main:app --host 0.0.0.0 --port 6000`` из каталога ``/opt/kind-k8s/app``
или через ``make docker up`` / ``make podman up``.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from api.v1.router import api_router
from core.config import get_settings
_BASE = Path(__file__).resolve().parent
logger = logging.getLogger("kind_k8s.web")
def _configure_logging() -> None:
"""Единая настройка логов для uvicorn и модулей kind-k8s."""
if logging.root.handlers:
return
level = logging.DEBUG if os.environ.get("KIND_K8S_DEBUG", "").strip().lower() in ("1", "true", "yes", "да") else logging.INFO
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
logger.info("Логирование инициализировано, уровень=%s", logging.getLevelName(level))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Старт и остановка приложения."""
_configure_logging()
settings = get_settings()
logger.info("Запуск FastAPI «%s»", settings.app_title)
yield
logger.info("Остановка FastAPI")
settings = get_settings()
app = FastAPI(title=settings.app_title, lifespan=lifespan)
app.include_router(api_router, prefix="/api/v1")
_templates_dir = _BASE / "templates"
_static_dir = _BASE / "static"
if _static_dir.is_dir():
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
templates = Jinja2Templates(directory=str(_templates_dir))
@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс")
async def dashboard(request: Request) -> HTMLResponse:
"""Главная страница с формой создания и таблицей кластеров."""
if not _templates_dir.is_dir():
return HTMLResponse(
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
status_code=500,
)
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"app_title": settings.app_title,
},
)
@app.get("/ui", include_in_schema=False)
async def ui_redirect() -> RedirectResponse:
"""Удобный алиас на корень UI."""
return RedirectResponse(url="/", status_code=307)

73
app/models/schemas.py Normal file
View File

@@ -0,0 +1,73 @@
"""Модели запросов/ответов REST API веб-интерфейса.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class ClusterCreateRequest(BaseModel):
"""Тело POST /api/v1/clusters — создание кластера."""
name: str = Field(..., min_length=1, max_length=63, description="DNS-имя кластера (a-z0-9-)")
kubernetes_version: str = Field(
...,
min_length=1,
description="Версия Kubernetes / тег kindest/node, например 1.29.4 или v1.29.4",
)
workers: int = Field(2, ge=0, le=20, description="Число worker-нод (020)")
class ClusterCreateAccepted(BaseModel):
"""Ответ 202 — задание поставлено в очередь."""
job_id: str
status: Literal["queued"] = "queued"
message: str = "Создание кластера выполняется в фоне; опросите GET /api/v1/jobs/{job_id}"
class JobView(BaseModel):
"""Статус фонового задания."""
job_id: str
kind: str
status: Literal["queued", "running", "success", "failed"]
cluster_name: str | None
created_at_utc: str
message: str | None = None
result: dict[str, Any] | None = None
class ClusterSummary(BaseModel):
"""Элемент списка кластеров."""
name: str
registered_in_kind: bool
has_local_kubeconfig: bool
meta: dict[str, Any] = Field(default_factory=dict)
class StatsResponse(BaseModel):
"""Краткая статистика для дашборда."""
kind_clusters_count: int
local_cluster_dirs_count: int
total_workers_from_meta: int | None
jobs_total: int
jobs_recent_failed: int
class ClusterWorkloadsResponse(BaseModel):
"""Вывод kubectl по кластеру (узлы и поды)."""
cluster_name: str
nodes_rc: int | None = None
nodes_output: str | None = None
pods_rc: int | None = None
pods_output: str | None = None
error: str | None = None

484
app/static/js/dashboard.js Normal file
View File

@@ -0,0 +1,484 @@
/**
* Панель управления кластерами kind (REST /api/v1).
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** @type {ReturnType<typeof setInterval> | null} */
let autoTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */
let pollTimer = null;
let createInProgress = false;
function formatApiError(data, fallback) {
if (!data) return fallback;
if (typeof data.detail === "string") return data.detail;
if (Array.isArray(data.detail)) {
return data.detail
.map(function (x) {
return (x.msg || x) + (x.loc ? " (" + x.loc.join(".") + ")" : "");
})
.join("; ");
}
return fallback;
}
/**
* @param {string} path
* @param {RequestInit} [opts]
*/
async function api(path, opts) {
const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, opts);
const text = await r.text();
let data;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!r.ok) {
const msg = formatApiError(data, text || r.statusText);
const err = new Error(msg);
err.status = r.status;
throw err;
}
return data;
}
function escapeHtml(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function showToast(message, isError) {
const el = document.getElementById("toast");
if (!el) return;
el.textContent = message;
el.classList.remove("hidden", "toast-error", "toast-ok");
el.classList.add(isError ? "toast-error" : "toast-ok");
clearTimeout(el._hideT);
el._hideT = setTimeout(function () {
el.classList.add("hidden");
}, 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);
}
async function loadHealth() {
const el = document.getElementById("status-banner");
if (!el) return;
try {
const h = await api("/health");
const ok =
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
el.className = "status-banner " + (ok ? "ok" : "degraded");
let lines = "<strong>Среда:</strong> ";
lines += escapeHtml(String(h.container_cli || "?"));
lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен");
lines += " · kind: " + (h.kind_in_path ? "да" : "нет");
lines += " · kubectl: " + (h.kubectl_in_path ? "да" : "нет");
if (!h.container_engine_ok && h.container_engine_detail) {
lines +=
"<br/><span class=\"muted\">" +
escapeHtml(String(h.container_engine_detail).slice(0, 400)) +
"</span>";
}
el.innerHTML = lines;
} catch (e) {
el.className = "status-banner err";
el.textContent = "Не удалось запросить health: " + e.message;
}
}
async function loadStats() {
const dl = document.getElementById("stats-dl");
const errEl = document.getElementById("stats-err");
if (!dl) return;
errEl.classList.add("hidden");
dl.innerHTML = "";
try {
const s = await api("/stats");
const rows = [
["Кластеров в kind", s.kind_clusters_count],
["Локальных каталогов", s.local_cluster_dirs_count],
["Сумма workers (meta)", s.total_workers_from_meta != null ? s.total_workers_from_meta : "—"],
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
];
rows.forEach(function (kv) {
const dt = document.createElement("dt");
dt.textContent = kv[0];
const dd = document.createElement("dd");
dd.textContent = String(kv[1]);
dl.appendChild(dt);
dl.appendChild(dd);
});
} catch (e) {
errEl.textContent = "Статистика: " + e.message;
errEl.classList.remove("hidden");
}
}
async function loadVersions() {
const sel = document.getElementById("version-select");
const verInput = document.getElementById("kubernetes_version");
if (!sel || !verInput) return;
sel.innerHTML = "<option value=\"\">— загрузка —</option>";
try {
const data = await api("/versions");
sel.innerHTML = "";
if (!data.tags || !data.tags.length) {
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
return;
}
const opt0 = document.createElement("option");
opt0.value = "";
opt0.textContent = "— выберите тег —";
sel.appendChild(opt0);
data.tags.slice(0, 100).forEach(function (t) {
const o = document.createElement("option");
o.value = t;
o.textContent = t;
sel.appendChild(o);
});
sel.onchange = function () {
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
};
} catch {
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
}
}
function jobBadgeClass(status) {
if (status === "success") return "badge badge-ok";
if (status === "failed") return "badge badge-err";
if (status === "running") return "badge badge-run";
return "badge";
}
async function loadClusters() {
const tbody = document.querySelector("#tbl-clusters tbody");
const msg = document.getElementById("list-msg");
if (!tbody) return;
setBusy("clusters", true);
tbody.innerHTML = "";
if (msg) msg.textContent = "";
try {
const rows = await api("/clusters");
rows.forEach(function (c) {
const tr = document.createElement("tr");
const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—";
const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
const nameEsc = escapeHtml(c.name);
const dlHref = API + "/clusters/" + encodeURIComponent(c.name) + "/kubeconfig";
tr.innerHTML =
"<td><code class=\"cluster-name\">" +
nameEsc +
"</code></td>" +
"<td>" +
(c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
escapeHtml(String(ver)) +
"</td>" +
"<td>" +
escapeHtml(String(wn)) +
"</td>" +
"<td class=\"actions\"></td>";
const td = tr.querySelector(".actions");
const b1 = document.createElement("button");
b1.type = "button";
b1.className = "btn-small";
b1.textContent = "Состояние";
b1.addEventListener("click", function () {
openWorkloadsModal(c.name);
});
td.appendChild(b1);
if (c.has_local_kubeconfig) {
const a = document.createElement("a");
a.href = dlHref;
a.className = "btn-secondary btn-small";
a.download = "kubeconfig-" + c.name + ".yaml";
a.textContent = "kubeconfig";
a.title = "Скачать kubeconfig";
td.appendChild(a);
}
const b2 = document.createElement("button");
b2.type = "button";
b2.className = "btn-small btn-danger";
b2.textContent = "Удалить";
b2.addEventListener("click", function () {
deleteCluster(c.name);
});
td.appendChild(b2);
tbody.appendChild(tr);
});
if (!rows.length && msg) msg.textContent = "Кластеров пока нет.";
} catch (e) {
if (msg) msg.textContent = "Ошибка списка: " + e.message;
} finally {
setBusy("clusters", false);
}
}
async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg");
if (!tbody) return;
setBusy("jobs", true);
tbody.innerHTML = "";
if (msg) msg.textContent = "";
try {
const rows = await api("/jobs?limit=30");
rows.forEach(function (j) {
const tr = document.createElement("tr");
const st = escapeHtml(j.status || "");
tr.innerHTML =
"<td><time datetime=\"" +
escapeHtml(j.created_at_utc || "") +
"\">" +
escapeHtml(j.created_at_utc || "") +
"</time></td>" +
"<td>" +
escapeHtml(j.cluster_name || "—") +
"</td>" +
"<td><span class=\"" +
jobBadgeClass(j.status) +
"\">" +
st +
"</span></td>" +
"<td class=\"jobs-msg-cell\">" +
escapeHtml((j.message || "").slice(0, 160)) +
"</td>";
tbody.appendChild(tr);
});
if (!rows.length && msg) {
msg.textContent = "Заданий ещё не было (или контейнер перезапускали).";
}
} catch (e) {
if (msg) msg.textContent = "Задания: " + e.message;
} finally {
setBusy("jobs", false);
}
}
async function openWorkloadsModal(name) {
const overlay = document.getElementById("modal-overlay");
const sub = document.getElementById("modal-sub");
const nodes = document.getElementById("modal-nodes");
const pods = document.getElementById("modal-pods");
const spin = document.getElementById("modal-spinner");
if (!overlay) return;
document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
sub.textContent = "";
nodes.textContent = "";
pods.textContent = "";
if (spin) spin.classList.remove("hidden");
overlay.classList.remove("hidden");
document.body.classList.add("modal-open");
try {
const w = await api("/clusters/" + encodeURIComponent(name) + "/workloads");
if (w.error) {
sub.textContent = w.error;
return;
}
sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc;
nodes.textContent = w.nodes_output || "(пусто)";
pods.textContent = w.pods_output || "(пусто)";
} catch (e) {
sub.textContent = "Ошибка: " + e.message;
} finally {
if (spin) spin.classList.add("hidden");
}
}
function closeModal() {
const overlay = document.getElementById("modal-overlay");
if (overlay) overlay.classList.add("hidden");
document.body.classList.remove("modal-open");
}
async function deleteCluster(name) {
if (!confirm("Удалить кластер «" + name + "» и папку clusters/" + name + "?")) return;
const msg = document.getElementById("list-msg");
if (msg) msg.textContent = "Удаление…";
try {
const res = await api("/clusters/" + encodeURIComponent(name), { method: "DELETE" });
if (msg) msg.textContent = res.summary || "Готово.";
showToast("Кластер «" + name + "» удалён", false);
await loadClusters();
await loadStats();
await loadJobs();
} catch (e) {
if (msg) msg.textContent = "Ошибка удаления: " + e.message;
showToast(e.message, true);
}
}
function setCreateFormDisabled(disabled) {
const form = document.getElementById("form-create");
if (!form) return;
const btn = form.querySelector('[type="submit"]');
if (btn) {
btn.disabled = disabled;
btn.textContent = disabled ? "Создание…" : "Создать кластер";
}
form.querySelectorAll("input, select").forEach(function (el) {
el.disabled = disabled;
});
}
function pollJob(jobId) {
const pre = document.getElementById("job-status");
const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg");
if (pre) pre.classList.add("hidden");
if (details) {
details.classList.remove("hidden");
details.open = true;
}
if (pollTimer) clearInterval(pollTimer);
createInProgress = true;
setCreateFormDisabled(true);
const tick = async function () {
try {
const j = await api("/jobs/" + jobId);
const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
if (j.status === "success" || j.status === "failed") {
clearInterval(pollTimer);
pollTimer = null;
createInProgress = false;
setCreateFormDisabled(false);
if (msg) {
msg.textContent =
j.status === "success" ? "Кластер создан." : "Ошибка: " + (j.message || "");
}
if (j.status === "success") {
showToast("Кластер создан", false);
} else {
showToast(j.message || "Ошибка создания", true);
}
await loadClusters();
await loadStats();
await loadJobs();
}
} catch (e) {
if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
}
};
tick();
pollTimer = setInterval(tick, 2000);
}
function refreshAll() {
loadHealth();
loadStats();
loadClusters();
loadJobs();
}
function init() {
const form = document.getElementById("form-create");
if (form) {
form.addEventListener("submit", async function (ev) {
ev.preventDefault();
if (createInProgress) return;
const msg = document.getElementById("create-msg");
const details = document.getElementById("job-details");
if (msg) msg.textContent = "";
if (details) details.classList.add("hidden");
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
kubernetes_version: String(fd.get("kubernetes_version") || "").trim(),
workers: parseInt(String(fd.get("workers") || "0"), 10),
};
try {
const res = await api("/clusters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (msg) msg.textContent = "Задание: " + res.job_id;
pollJob(res.job_id);
} catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message;
showToast(e.message, true);
}
});
}
const bStats = document.getElementById("btn-refresh-stats");
if (bStats) bStats.addEventListener("click", function () { loadHealth(); loadStats(); });
const bList = document.getElementById("btn-refresh-list");
if (bList) bList.addEventListener("click", loadClusters);
const bJobs = document.getElementById("btn-refresh-jobs");
if (bJobs) bJobs.addEventListener("click", loadJobs);
const bAll = document.getElementById("btn-refresh-all");
if (bAll) bAll.addEventListener("click", refreshAll);
const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal);
const overlay = document.getElementById("modal-overlay");
if (overlay) {
overlay.addEventListener("click", function (ev) {
if (ev.target === overlay) closeModal();
});
}
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeModal();
});
const auto = document.getElementById("auto-refresh");
if (auto) {
auto.addEventListener("change", function (ev) {
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
}
if (ev.target.checked) {
autoTimer = setInterval(refreshAll, 15000);
}
});
}
loadHealth();
loadStats();
loadVersions();
loadClusters();
loadJobs();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

504
app/static/style.css Normal file
View File

@@ -0,0 +1,504 @@
/* Минимальные стили веб-интерфейса kind-k8s-develop.
Автор: Сергей Антропов — https://devops.org.ru */
:root {
color-scheme: light dark;
--bg: #0f1419;
--fg: #e7ecf1;
--muted: #8b98a5;
--card: #1a2332;
--accent: #3b82f6;
--border: #2f3d52;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f6f9;
--fg: #111827;
--muted: #6b7280;
--card: #ffffff;
--border: #e5e7eb;
}
}
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
line-height: 1.45;
}
body.modal-open {
overflow: hidden;
}
/* Пропуск к основному содержимому (a11y) */
.skip-link {
position: absolute;
left: -9999px;
top: 0.5rem;
z-index: 200;
padding: 0.5rem 1rem;
background: var(--accent);
color: #fff;
border-radius: 6px;
font-weight: 600;
}
.skip-link:focus {
left: 0.5rem;
outline: 2px solid var(--fg);
outline-offset: 2px;
}
/* Верхняя навигация */
.top-nav {
border-bottom: 1px solid var(--border);
background: var(--card);
}
.top-nav-inner {
max-width: 72rem;
margin: 0 auto;
padding: 0.65rem 1.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.nav-brand {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
}
.nav-logo {
color: var(--accent);
font-size: 1.25rem;
line-height: 1;
}
.nav-title {
font-weight: 700;
font-size: 1.05rem;
}
.nav-tag {
font-size: 0.8rem;
}
.nav-links {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1rem;
}
.nav-link {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
}
.nav-link:hover {
text-decoration: underline;
}
.app-main {
max-width: 72rem;
margin: 0 auto;
padding: 1.25rem;
outline: none;
}
.app-footer {
max-width: 72rem;
margin: 0 auto;
padding: 1rem 1.25rem 2rem;
border-top: 1px solid var(--border);
font-size: 0.85rem;
}
.page-intro {
margin-bottom: 1rem;
}
.page-title {
margin: 0 0 0.35rem;
font-size: 1.35rem;
}
.page-lead {
margin: 0;
max-width: 42rem;
}
.toolbar {
margin-bottom: 1rem;
}
/* Тост уведомлений */
.toast {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 150;
max-width: min(22rem, calc(100vw - 2rem));
padding: 0.65rem 1rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
font-size: 0.9rem;
}
.toast-ok {
border-color: #15803d;
}
.toast-error {
border-color: #b91c1c;
}
/* Бейджи статусов */
.badge {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.15);
}
.badge-ok {
border-color: #15803d;
color: #4ade80;
}
.badge-err {
border-color: #b91c1c;
color: #f87171;
}
.badge-run {
border-color: #b45309;
color: #fbbf24;
}
.cluster-name {
font-weight: 600;
}
/* Состояние загрузки таблиц */
/* Пока идёт запрос к API, секция слегка приглушается */
[data-busy].is-loading {
opacity: 0.55;
pointer-events: none;
transition: opacity 0.2s ease;
}
.job-details {
margin-top: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.12);
}
.job-details summary {
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
}
.job-json-pre {
max-height: 12rem;
margin-top: 0.5rem;
}
/* Спиннер в модалке */
.modal-head {
margin-bottom: 0.5rem;
align-items: flex-start;
}
.modal-title-text {
margin: 0;
font-size: 1.05rem;
flex: 1;
padding-right: 0.5rem;
}
.modal-sub {
margin: 0 0 0.75rem;
}
.modal-section-title {
margin: 0.5rem 0 0;
font-size: 0.85rem;
}
.modal-spinner {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 1rem 0;
}
.spinner {
width: 1.5rem;
height: 1.5rem;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
button:disabled,
input:disabled,
select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.header h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.muted {
color: var(--muted);
}
.grid {
display: grid;
gap: 1rem;
margin: 1.25rem 0;
}
@media (min-width: 900px) {
.grid {
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.1rem;
}
.card h2 {
margin-top: 0;
font-size: 1.1rem;
}
label {
display: block;
margin: 0.65rem 0 0.25rem;
font-size: 0.9rem;
}
input,
select,
button {
font: inherit;
}
input,
select {
width: 100%;
max-width: 28rem;
padding: 0.45rem 0.55rem;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: inherit;
}
button {
margin-top: 0.75rem;
padding: 0.45rem 0.85rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:hover {
filter: brightness(1.08);
}
.row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.row.spread {
justify-content: space-between;
align-items: center;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-word;
background: rgba(0, 0, 0, 0.2);
padding: 0.6rem;
border-radius: 6px;
max-height: 14rem;
overflow: auto;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
border-bottom: 1px solid var(--border);
padding: 0.45rem 0.35rem;
text-align: left;
vertical-align: top;
}
.msg {
margin-top: 0.5rem;
}
.hidden {
display: none;
}
.footer {
margin-top: 2rem;
font-size: 0.9rem;
}
.footer a {
color: var(--accent);
}
/* Плашка состояния среды (Docker/Podman, kind, kubectl) */
.status-banner {
border-radius: 8px;
padding: 0.65rem 0.85rem;
margin-bottom: 1rem;
font-size: 0.9rem;
border: 1px solid var(--border);
}
.status-banner.ok {
border-color: #15803d;
background: rgba(34, 197, 94, 0.12);
}
.status-banner.degraded {
border-color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.status-banner.err {
border-color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
}
.stats-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0;
font-size: 0.9rem;
}
.stats-dl dt {
margin: 0;
color: var(--muted);
}
.stats-dl dd {
margin: 0;
}
button.btn-secondary,
a.btn-secondary {
background: transparent;
color: var(--accent);
border-color: var(--accent);
text-decoration: none;
display: inline-block;
margin-top: 0;
margin-right: 0.35rem;
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
}
button.btn-small {
margin-top: 0;
padding: 0.3rem 0.55rem;
font-size: 0.8rem;
}
td.actions {
white-space: nowrap;
}
td.actions button {
margin-top: 0;
margin-right: 0.25rem;
}
/* Модальное окно «Состояние кластера» */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2rem 1rem;
z-index: 100;
overflow-y: auto;
}
.modal-overlay.hidden {
display: none;
}
.modal-box {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
max-width: 52rem;
width: 100%;
padding: 1rem 1.15rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.modal-box h3 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.modal-box pre {
max-height: 40vh;
margin: 0.5rem 0 1rem;
}
.modal-close {
flex-shrink: 0;
margin-top: 0;
}
button.btn-danger,
a.btn-danger {
background: transparent;
color: #f87171;
border-color: #b91c1c;
}
@media (prefers-color-scheme: light) {
button.btn-danger,
a.btn-danger {
color: #b91c1c;
}
}
button.btn-danger:hover {
filter: brightness(1.12);
}

49
app/templates/base.html Normal file
View File

@@ -0,0 +1,49 @@
{# Общий каркас страниц веб-интерфейса kind-k8s-develop.
Автор: Сергей Антропов — https://devops.org.ru #}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block page_title %}{{ app_title }}{% endblock %} — kind</title>
<link rel="stylesheet" href="/static/style.css" />
{% block head_extra %}{% endblock %}
</head>
<body
class="app-body"
data-api-base="/api/v1"
data-app-title="{{ app_title | e }}"
>
<a class="skip-link" href="#main-content">К основному содержимому</a>
<header class="top-nav" role="banner">
<div class="top-nav-inner">
<div class="nav-brand">
<span class="nav-logo" aria-hidden="true"></span>
<span class="nav-title">{{ app_title }}</span>
<span class="nav-tag muted">kind · локальные кластеры</span>
</div>
<nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link">Панель</a>
<a href="/docs" class="nav-link" target="_blank" rel="noopener">Swagger</a>
<a href="/redoc" class="nav-link" target="_blank" rel="noopener">ReDoc</a>
<a href="/api/v1/health" class="nav-link" target="_blank" rel="noopener">Health</a>
</nav>
</div>
</header>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
<main id="main-content" class="app-main" tabindex="-1">
{% block content %}{% endblock %}
</main>
<footer class="footer muted app-footer">
{% block footer %}
<span>Данные: том <code>clusters/</code> на хосте · <code>app/docs/api_routes.md</code></span>
{% endblock %}
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,158 @@
{# Главная панель: кластеры, задания, создание.
Расширяет base.html; логика в /static/js/dashboard.js.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block footer %}
<span>Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code></span>
{% endblock %}
{% block content %}
<div class="page-intro">
<h1 class="page-title">Панель управления</h1>
<p class="muted page-lead">
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
</p>
</div>
<div id="status-banner" class="status-banner muted" role="status" aria-live="polite">
Проверка среды…
</div>
<div class="toolbar row spread">
<div class="row" style="margin-top:0;align-items:center;gap:0.75rem">
<button type="button" id="btn-refresh-all" class="btn-secondary">Обновить всё</button>
<label class="row" style="margin-top:0;align-items:center;gap:0.35rem">
<input type="checkbox" id="auto-refresh" />
авто каждые 15 с
</label>
</div>
</div>
<section class="grid">
<div class="card">
<h2>Статистика</h2>
<dl id="stats-dl" class="stats-dl" aria-label="Сводка по кластерам и заданиям"></dl>
<p id="stats-err" class="msg hidden" role="alert"></p>
<div class="row">
<button type="button" id="btn-refresh-stats">Обновить статистику</button>
</div>
</div>
<div class="card">
<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>
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
<input
name="kubernetes_version"
id="kubernetes_version"
required
placeholder="1.29.4 или v1.29.4"
autocomplete="off"
/>
<label for="fld-workers">Worker-ноды (020)</label>
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
<button type="submit">Создать кластер</button>
</form>
<p id="create-msg" class="msg" aria-live="polite"></p>
<details id="job-details" class="job-details hidden">
<summary>Ход задания (JSON)</summary>
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
</details>
</div>
</section>
<section class="card clusters-card">
<div class="row spread">
<h2>Кластеры</h2>
<button type="button" class="btn-secondary" id="btn-refresh-list">Обновить список</button>
</div>
<div class="table-wrap" data-busy="clusters">
<table id="tbl-clusters" aria-label="Список кластеров">
<thead>
<tr>
<th>Имя</th>
<th>kind</th>
<th>kubeconfig</th>
<th>Версия</th>
<th>Workers</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="list-msg" class="msg muted"></p>
</section>
<section class="card jobs-card">
<div class="row spread">
<h2>Последние задания</h2>
<button type="button" class="btn-secondary" id="btn-refresh-jobs">Обновить</button>
</div>
<div class="table-wrap" data-busy="jobs">
<table id="tbl-jobs" aria-label="Последние задания создания">
<thead>
<tr>
<th>Время (UTC)</th>
<th>Кластер</th>
<th>Статус</th>
<th>Сообщение</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<p id="jobs-msg" class="msg muted"></p>
</section>
<div
id="modal-overlay"
class="modal-overlay hidden"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div class="modal-box">
<div class="modal-head row spread">
<h3 id="modal-title" class="modal-title-text">Состояние кластера</h3>
<button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button>
</div>
<div id="modal-spinner" class="modal-spinner hidden" aria-hidden="true">
<span class="spinner" aria-label="Загрузка"></span>
<span class="muted">Запрос kubectl…</span>
</div>
<p id="modal-sub" class="muted modal-sub"></p>
<h4 class="modal-section-title muted">Узлы</h4>
<pre id="modal-nodes" class="mono" role="region" aria-label="Вывод kubectl get nodes"></pre>
<h4 class="modal-section-title muted">Поды (все namespace)</h4>
<pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js" defer></script>
{% endblock %}