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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user