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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 может предложить ввод вручную.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user