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
+84 -51
View File
@@ -24,7 +24,7 @@ from core.cluster_lifecycle import (
read_meta_json,
validate_cluster_name,
)
from core.job_store import job_store
from core.job_store import JobRecord, end_job_tracking, get_progress_sync, job_store, request_cancel_sync
from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir
from models.schemas import (
@@ -41,6 +41,25 @@ logger = logging.getLogger("kind_k8s.api.clusters")
router = APIRouter(tags=["clusters"])
def _record_to_job_view(rec: JobRecord) -> JobView:
"""JobRecord → JobView с полями прогресса из потокобезопасного снимка."""
prog = get_progress_sync(rec.job_id)
stage, pct = (None, None)
if prog is not None:
stage, pct = prog[0], prog[1]
return JobView(
job_id=rec.job_id,
kind=rec.kind,
status=rec.status,
cluster_name=rec.cluster_name,
created_at_utc=rec.created_at_utc,
message=rec.message,
result=rec.result,
progress_stage=stage,
progress_percent=pct,
)
def _stats_sync() -> StatsResponse:
"""Собрать статистику (синхронно; вызывать из thread при необходимости)."""
kind_names = list_registered_kind_clusters()
@@ -86,18 +105,7 @@ async def get_stats() -> StatsResponse:
async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]:
"""История создания кластеров (в памяти процесса; после перезапуска контейнера пусто)."""
items = job_store.snapshot_recent_sorted(limit=limit)
return [
JobView(
job_id=r.job_id,
kind=r.kind,
status=r.status,
cluster_name=r.cluster_name,
created_at_utc=r.created_at_utc,
message=r.message,
result=r.result,
)
for r in items
]
return [_record_to_job_view(r) for r in items]
@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров")
@@ -187,36 +195,44 @@ async def get_cluster(name: str) -> dict[str, object]:
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
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(),
kubernetes_version_tag=body.kubernetes_version.strip(),
workers=body.workers,
)
except KindClusterError as e:
await job_store.set_failed(job_id, str(e))
logger.warning("create job %s: %s", job_id, e)
return
except Exception as e:
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
logger.exception("create job %s: непредвиденная ошибка", job_id)
return
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(),
kubernetes_version_tag=body.kubernetes_version.strip(),
workers=body.workers,
job_id=job_id,
)
except KindClusterError as e:
msg = str(e)
if "отменено" in msg.lower():
await job_store.set_cancelled(job_id, msg)
else:
await job_store.set_failed(job_id, msg)
logger.warning("create job %s: %s", job_id, e)
return
except Exception as e:
await job_store.set_failed(job_id, f"{type(e).__name__}: {e}")
logger.exception("create job %s: непредвиденная ошибка", job_id)
return
payload: dict[str, Any] = {
"cluster_name": result.cluster_name,
"kubernetes_version_tag": result.ver_tag,
"node_image": result.node_image,
"workers": result.workers,
"kubeconfig_path": str(result.kubeconfig_path),
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
"nodes_ready": result.nodes_ready,
"nodes_ready_message": result.nodes_ready_message,
}
await job_store.set_success(job_id, result=payload, message="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
payload: dict[str, Any] = {
"cluster_name": result.cluster_name,
"kubernetes_version_tag": result.ver_tag,
"node_image": result.node_image,
"workers": result.workers,
"kubeconfig_path": str(result.kubeconfig_path),
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
"nodes_ready": result.nodes_ready,
"nodes_ready_message": result.nodes_ready_message,
}
await job_store.set_success(job_id, result=payload, message="Кластер создан")
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
end_job_tracking(job_id)
@router.post(
@@ -263,18 +279,35 @@ async def delete_cluster(name: str) -> dict[str, object]:
return {"name": name, "kind_delete_ok": kind_ok, "summary": summary}
@router.post(
"/jobs/{job_id}/cancel",
summary="Запросить отмену создания кластера",
responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}},
)
async def cancel_create_job(job_id: str) -> dict[str, object]:
"""
Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения;
после него отмена удалит кластер и данные (если успели создать).
"""
rec = await job_store.get(job_id)
if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено")
if rec.status not in ("queued", "running"):
raise HTTPException(status_code=400, detail="Задание уже завершено; отмена невозможна")
if not request_cancel_sync(job_id):
raise HTTPException(status_code=404, detail="Задание не найдено")
logger.info("Принят запрос отмены задания %s", job_id)
return {
"job_id": job_id,
"cancel_requested": True,
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага",
}
@router.get("/jobs/{job_id}", response_model=JobView, summary="Статус одного задания")
async def get_job(job_id: str) -> JobView:
"""Узнать состояние фонового создания кластера."""
rec = await job_store.get(job_id)
if not rec:
raise HTTPException(status_code=404, detail="Задание не найдено")
return JobView(
job_id=rec.job_id,
kind=rec.kind,
status=rec.status,
cluster_name=rec.cluster_name,
created_at_utc=rec.created_at_utc,
message=rec.message,
result=rec.result,
)
return _record_to_job_view(rec)