- app/docs/screenshots.md и каталог app/docs/images/*.png - раздача /static/docs-images/* из FastAPI; documentation.js переписывает src картинок - стили .markdown-body img; строка в api_routes.md; превью в README
220 lines
9.5 KiB
Python
220 lines
9.5 KiB
Python
"""Веб-интерфейс и REST API для управления локальными кластерами kind.
|
||
|
||
В контейнере uvicorn слушает порт 6000; на хост публикация по умолчанию 8080 (``KIND_K8S_WEB_PORT``), т.к. 6000 на хосте блокируется Chrome (ERR_UNSAFE_PORT).
|
||
|
||
Запуск в контейнере: ``python3 -m uvicorn main:app --host 0.0.0.0 --port 6000`` из каталога ``/opt/kind-k8s/app``
|
||
или через ``make docker up`` / ``make podman up``.
|
||
|
||
Автор: Сергей Антропов
|
||
Сайт: https://devops.org.ru
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
|
||
from fastapi import FastAPI, HTTPException, Request
|
||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
|
||
from api.v1.router import api_router
|
||
from core.cluster_lifecycle import validate_cluster_name
|
||
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")
|
||
|
||
# PNG-скриншоты для app/docs/screenshots.md и др.: относительные пути images/* в Markdown → /static/docs-images/*
|
||
_docs_images_dir = _BASE / "docs" / "images"
|
||
if _docs_images_dir.is_dir():
|
||
app.mount(
|
||
"/static/docs-images",
|
||
StaticFiles(directory=str(_docs_images_dir)),
|
||
name="docs_images",
|
||
)
|
||
|
||
templates = Jinja2Templates(directory=str(_templates_dir))
|
||
|
||
|
||
@app.get("/clusters", response_class=HTMLResponse, summary="Список кластеров и сводка узлов")
|
||
async def clusters_page(request: Request) -> HTMLResponse:
|
||
"""Страница «Кластеры»: донаты ресурсов узлов и таблица (как раньше на панели)."""
|
||
if not _templates_dir.is_dir():
|
||
return HTMLResponse(
|
||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||
status_code=500,
|
||
)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"clusters.html",
|
||
{"app_title": settings.app_title, "nav_active": "clusters"},
|
||
)
|
||
|
||
|
||
@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс")
|
||
async def dashboard(request: Request) -> HTMLResponse:
|
||
"""Главная панель: CTA создания, статистика среды (без таблицы кластеров — см. /clusters)."""
|
||
if not _templates_dir.is_dir():
|
||
return HTMLResponse(
|
||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||
status_code=500,
|
||
)
|
||
# Starlette ≥0.37: первым аргументом обязателен Request (иначе dict уйдёт в get_template → unhashable type).
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"dashboard.html",
|
||
{"app_title": settings.app_title, "nav_active": "panel"},
|
||
)
|
||
|
||
|
||
@app.get("/cluster/{cluster_name}", response_class=HTMLResponse, summary="Страница кластера")
|
||
async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLResponse:
|
||
"""Ресурсы узлов, сводка, kubectl (поды, Deployments и др.), действия как в таблице на главной."""
|
||
if not _templates_dir.is_dir():
|
||
return HTMLResponse(
|
||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||
status_code=500,
|
||
)
|
||
n = cluster_name.strip()
|
||
if not validate_cluster_name(n):
|
||
raise HTTPException(status_code=404, detail="Некорректное имя кластера")
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"cluster_detail.html",
|
||
{
|
||
"app_title": settings.app_title,
|
||
"nav_active": "clusters",
|
||
"cluster_name": n,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/cluster/{cluster_name}/edit", response_class=HTMLResponse, summary="Редактирование конфигурации кластера")
|
||
async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse:
|
||
"""Форма: версия/workers, полный kind-config.yaml, описание в meta; сохранение через PUT /api/v1/clusters/{name}/config."""
|
||
if not _templates_dir.is_dir():
|
||
return HTMLResponse(
|
||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||
status_code=500,
|
||
)
|
||
n = cluster_name.strip()
|
||
if not validate_cluster_name(n):
|
||
raise HTTPException(status_code=404, detail="Некорректное имя кластера")
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"cluster_edit.html",
|
||
{
|
||
"app_title": settings.app_title,
|
||
"nav_active": "clusters",
|
||
"cluster_name": n,
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/cluster-addons", response_class=HTMLResponse, summary="Helm-аддоны для кластера")
|
||
async def cluster_addons_page(request: Request) -> HTMLResponse:
|
||
"""Выбор кластера и установка/удаление ingress-nginx, prometheus-stack, metrics-server, Istio+Kiali."""
|
||
if not _templates_dir.is_dir():
|
||
return HTMLResponse(
|
||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||
status_code=500,
|
||
)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"cluster_addons.html",
|
||
{"app_title": settings.app_title, "nav_active": "cluster_addons"},
|
||
)
|
||
|
||
|
||
@app.get("/journal", response_class=HTMLResponse, summary="Журнал завершённых заданий по кластерам")
|
||
async def journal_page(request: Request) -> HTMLResponse:
|
||
"""Агрегат записей из ``clusters/<имя>/journal/jobs_history.json`` (создание/старт/стоп и др.)."""
|
||
if not _templates_dir.is_dir():
|
||
return HTMLResponse(
|
||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||
status_code=500,
|
||
)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"journal.html",
|
||
{"app_title": settings.app_title, "nav_active": "journal"},
|
||
)
|
||
|
||
|
||
@app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания")
|
||
async def cluster_create_page(request: Request) -> HTMLResponse:
|
||
"""Форма создания кластера, прогресс и список последних заданий."""
|
||
if not _templates_dir.is_dir():
|
||
return HTMLResponse(
|
||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||
status_code=500,
|
||
)
|
||
return templates.TemplateResponse(
|
||
request,
|
||
"cluster_create.html",
|
||
{"app_title": settings.app_title, "nav_active": "cluster_create"},
|
||
)
|
||
|
||
|
||
@app.get("/ui", include_in_schema=False)
|
||
async def ui_redirect() -> RedirectResponse:
|
||
"""Удобный алиас на корень UI."""
|
||
return RedirectResponse(url="/", status_code=307)
|
||
|
||
|
||
@app.get("/favicon.ico", include_in_schema=False)
|
||
async def favicon_ico() -> FileResponse:
|
||
"""Браузеры по умолчанию запрашивают ``/favicon.ico``; отдаём SVG в стиле логотипа Kubernetes."""
|
||
path = _static_dir / "favicon.svg"
|
||
if not path.is_file():
|
||
raise HTTPException(status_code=404, detail="favicon не найден")
|
||
return FileResponse(path, media_type="image/svg+xml", filename="favicon.ico")
|
||
|
||
|
||
@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, "nav_active": "documentation"},
|
||
)
|