UI: навигация, документация, favicon; журнал развёртывания; валидация формы

- Меню API: ссылка «Теги образов», обёртка прокрутки пилюль, z-index и padding против обрезки hover
- Документация: ширина колонки как у дашборда (72rem)
- Favicon SVG + GET /favicon.ico, link в base.html
- provision_log.json, GET .../provision-log, кнопка в таблице кластеров
- Валидация create: имя, workers, тег kindest; модалка alert
- Прочие правки из сессии (clusters, job_store, стили, шаблоны)
This commit is contained in:
Sergey Antropoff
2026-04-04 08:15:15 +03:00
parent 6f3daa33ec
commit 4546f50aef
19 changed files with 723 additions and 103 deletions
+60 -2
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
@@ -39,8 +40,11 @@ from core.job_store import (
get_logs_snapshot_sync,
get_progress_sync,
job_store,
register_uncapped_job_log,
request_cancel_sync,
take_uncapped_log_finalize,
)
from core.provision_log import provision_log_file_path, write_cluster_provision_log
from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir
from models.schemas import (
@@ -184,6 +188,7 @@ async def list_clusters() -> list[ClusterSummary]:
registered_in_kind=bool(summary["registered_in_kind"]),
kind_nodes_running=bool(running_map.get(name, False)),
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
has_provision_log=bool(summary.get("has_provision_log")),
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
)
)
@@ -211,6 +216,27 @@ async def download_kubeconfig(name: str) -> FileResponse:
)
@router.get(
"/clusters/{name}/provision-log",
summary="Журнал развёртывания (JSON)",
responses={404: {"description": "Файл не найден"}},
)
async def get_cluster_provision_log(name: str) -> JSONResponse:
"""Полный журнал последнего создания/старта кластера (``provision_log.json`` в каталоге кластера)."""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
path = provision_log_file_path(name)
if not path.is_file():
raise HTTPException(status_code=404, detail="Журнал развёртывания не найден")
try:
raw = await asyncio.to_thread(path.read_text, encoding="utf-8")
data = json.loads(raw)
except (OSError, json.JSONDecodeError) as e:
logger.warning("provision_log для %s не прочитан: %s", name, e)
raise HTTPException(status_code=500, detail="Не удалось прочитать журнал") from e
return JSONResponse(content=data)
@router.get(
"/clusters/{name}/workloads",
response_model=ClusterWorkloadsResponse,
@@ -254,13 +280,15 @@ async def get_cluster(name: str) -> dict[str, object]:
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
register_uncapped_job_log(job_id)
cname = body.name.strip()
try:
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(),
name=cname,
kubernetes_version_tag=body.kubernetes_version.strip(),
workers=body.workers,
job_id=job_id,
@@ -291,18 +319,34 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
await job_store.set_success(job_id, result=payload, message="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
lines = take_uncapped_log_finalize(job_id)
rec = await job_store.get(job_id)
if rec is not None:
await asyncio.to_thread(
lambda: write_cluster_provision_log(
cluster_name=cname,
job_id=job_id,
job_kind="create_cluster",
status=rec.status,
message=rec.message,
lines=lines,
result=rec.result,
),
)
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 в списке)."""
register_uncapped_job_log(job_id)
n = name.strip()
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(),
name=n,
kubernetes_version_tag=kubernetes_version_tag.strip(),
workers=workers,
job_id=job_id,
@@ -334,6 +378,20 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag:
await job_store.set_success(job_id, result=payload, message="Кластер поднят по сохранённому конфигу")
logger.info("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
lines = take_uncapped_log_finalize(job_id)
rec = await job_store.get(job_id)
if rec is not None:
await asyncio.to_thread(
lambda: write_cluster_provision_log(
cluster_name=n,
job_id=job_id,
job_kind="start_cluster",
status=rec.status,
message=rec.message,
lines=lines,
result=rec.result,
),
)
end_job_tracking(job_id)
+1 -1
View File
@@ -22,7 +22,7 @@ router = APIRouter(tags=["versions"])
@router.get("/versions", summary="Теги kindest/node")
async def list_kindest_versions() -> dict[str, object]:
"""
Вернуть отсортированный список стабильных тегов (как при интерактивном выборе версии в UI/CLI).
Список тегов для выпадающего списка: первым — ``latest``, затем стабильные ``vX.Y.Z`` от новых к старым (>= 1.19).
При ``KIND_K8S_SKIP_VERSION_LIST=1`` возвращает пустой список — UI может предложить ввод вручную.
"""