UI: автообновление, прогресс, отмена; порт 8080; меню-пилюли и отдельные окна

- Порт хоста по умолчанию 8080 (Chrome ERR_UNSAFE_PORT на 6000); compose, setup, config, README.
- Дашборд: одна hero-карточка, прогресс создания, POST /jobs/{id}/cancel, JobView progress_*.
- job_store: отмена и прогресс (thread-safe); cluster_lifecycle этапы и откат.
- Навигация: стили nav-pill; Swagger/ReDoc/Health через window.open.
- main.py: TemplateResponse(request, …) для Starlette.
- Документация: README, app/docs (api_routes, README); Makefile ps; .gitignore clusters.
This commit is contained in:
Sergey Antropoff
2026-04-04 05:58:11 +03:00
parent 74538423d5
commit 02f4c655b9
17 changed files with 618 additions and 181 deletions
+51
View File
@@ -24,6 +24,22 @@ from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_afte
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None:
"""Удалить кластер kind и каталог данных после запроса отмены (best-effort)."""
logger.info("Откат после отмены: kind delete «%s»", cluster_name)
subprocess.run(
["kind", "delete", "cluster", "--name", cluster_name],
capture_output=True,
text=True,
)
if out_dir.is_dir():
try:
shutil.rmtree(out_dir)
logger.info("Удалён каталог %s", out_dir)
except OSError as e:
logger.warning("Не удалось удалить %s: %s", out_dir, e)
# Имя кластера: поддомен DNS (RFC 1123)
_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
@@ -160,12 +176,24 @@ def create_cluster_non_interactive(
name: str,
kubernetes_version_tag: str,
workers: int,
job_id: str | None = None,
) -> CreateClusterResult:
"""
Создать кластер kind без диалогов.
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
"""
from core import job_store as _job_store
def _progress(stage: str, pct: int) -> None:
if job_id:
_job_store.set_progress_sync(job_id, stage, pct)
def _cancelled() -> bool:
return bool(job_id and _job_store.is_cancelled_sync(job_id))
if not shutil.which("kind"):
raise KindClusterError("Не найден бинарник kind в PATH.", exit_code=127)
@@ -193,19 +221,40 @@ def create_cluster_non_interactive(
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
cfg_path.write_text(yaml_text, encoding="utf-8")
_progress("Подготовка каталога и kind-config", 12)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers)
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
_run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)])
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
_progress("Сохранение kubeconfig", 58)
kube = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name])
kube_path.write_text(kube, encoding="utf-8")
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
patched = False
if should_patch_after_create():
_progress("Патч kubeconfig для доступа к API с хоста", 72)
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
nodes_ready: bool | None = None
nodes_msg: str | None = None
if _wait_nodes_enabled():
_progress("Ожидание готовности нод (kubectl wait …)", 82)
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
nodes_ready = ok
nodes_msg = msg
@@ -229,6 +278,8 @@ def create_cluster_non_interactive(
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
_progress("Финализация", 95)
return CreateClusterResult(
cluster_name=name,
ver_tag=ver_tag,
+2 -1
View File
@@ -26,7 +26,8 @@ class Settings(BaseSettings):
)
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")
# Согласовано с дефолтом compose на хосте (8080); в контейнере процесс слушает 6000 через run_uvicorn.sh.
kind_k8s_web_port: int = Field(default=8080, validation_alias="KIND_K8S_WEB_PORT")
# Заголовок в OpenAPI / HTML; пустая строка из compose не должна ломать FastAPI.
app_title: str = Field(default=_DEFAULT_TITLE, validation_alias="KIND_K8S_APP_TITLE")
+66 -1
View File
@@ -2,6 +2,8 @@
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
@@ -10,6 +12,7 @@ from __future__ import annotations
import asyncio
import logging
import threading
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -20,7 +23,58 @@ logger = logging.getLogger("kind_k8s.job_store")
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
_MAX_JOBS = 200
JobStatus = Literal["queued", "running", "success", "failed"]
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
_thread_lock = threading.Lock()
_cancel_events: dict[str, threading.Event] = {}
_progress: dict[str, tuple[str, int]] = {}
def begin_job_tracking(job_id: str) -> None:
"""Зарегистрировать отмену/прогресс для нового job_id (вызывать при создании задания)."""
with _thread_lock:
_cancel_events[job_id] = threading.Event()
_progress[job_id] = ("В очереди", 0)
def end_job_tracking(job_id: str) -> None:
"""Очистить служебные структуры после завершения задания."""
with _thread_lock:
_cancel_events.pop(job_id, None)
_progress.pop(job_id, None)
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
"""Обновить текст этапа и процент (0–100) из worker-thread."""
pct = max(0, min(100, int(percent)))
with _thread_lock:
if job_id in _progress:
_progress[job_id] = (stage, pct)
def get_progress_sync(job_id: str) -> tuple[str, int] | None:
"""Снимок прогресса для ответа API."""
with _thread_lock:
return _progress.get(job_id)
def request_cancel_sync(job_id: str) -> bool:
"""Запросить отмену. Вернуть False, если job_id не отслеживается."""
with _thread_lock:
ev = _cancel_events.get(job_id)
if ev is None:
return False
ev.set()
logger.info("Запрошена отмена задания %s", job_id)
return True
def is_cancelled_sync(job_id: str) -> bool:
"""Проверка из worker-thread между этапами создания кластера."""
with _thread_lock:
ev = _cancel_events.get(job_id)
return bool(ev and ev.is_set())
@dataclass
@@ -58,8 +112,10 @@ class JobStore:
self._jobs[jid] = rec
while len(self._jobs) > _MAX_JOBS:
oldest_id = min(self._jobs, key=lambda k: self._jobs[k].created_at_utc)
end_job_tracking(oldest_id)
del self._jobs[oldest_id]
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
begin_job_tracking(jid)
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec
@@ -68,6 +124,7 @@ class JobStore:
if job_id in self._jobs:
self._jobs[job_id].status = "running"
self._jobs[job_id].message = None
set_progress_sync(job_id, "Запуск создания кластера…", 5)
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
async with self._lock:
@@ -75,6 +132,7 @@ class JobStore:
self._jobs[job_id].status = "success"
self._jobs[job_id].result = result
self._jobs[job_id].message = message
set_progress_sync(job_id, "Готово", 100)
async def set_failed(self, job_id: str, message: str) -> None:
async with self._lock:
@@ -83,6 +141,13 @@ class JobStore:
self._jobs[job_id].message = message
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "cancelled"
self._jobs[job_id].message = message
logger.info("Задание %s отменено: %s", job_id, message)
async def get(self, job_id: str) -> JobRecord | None:
async with self._lock:
return self._jobs.get(job_id)