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