Files
KindClustersDashboard/app/main.py
Sergey Antropoff eb063aec20 Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
2026-04-04 13:42:21 +03:00

211 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Веб-интерфейс и 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")
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"},
)