Панель: журнал заданий, pull --progress plain, PTY/EIO, старт/стоп в фоне, очистка jobs

- Фоновые stop/start с job_id и poll; отмена с kill; docker pull plain + снятие ANSI
- Лимиты журнала API/буфера; список jobs без progress_log; DELETE /jobs
- UI: опрос чаще, подсказка при пустом логе, кнопка очистки завершённых
This commit is contained in:
Sergey Antropoff
2026-04-04 07:04:46 +03:00
parent 6d4bc65c8a
commit 8bd44adbb0
10 changed files with 808 additions and 200 deletions
+72 -7
View File
@@ -13,6 +13,8 @@ from __future__ import annotations
import asyncio
import logging
import os
import signal
import subprocess
import threading
import uuid
from collections import deque
@@ -33,10 +35,14 @@ _cancel_events: dict[str, threading.Event] = {}
_progress: dict[str, tuple[str, int]] = {}
# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines
_job_log_deques: dict[str, deque[str]] = {}
# Активный дочерний процесс (pull / kind create) — для принудительной отмены
_process_lock = threading.Lock()
_active_subprocess_by_job: dict[str, subprocess.Popen] = {}
def _max_job_log_lines() -> int:
raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "500").strip()
# Буфер в памяти: старые строки вытесняются; по умолчанию 2500 — виден почти весь типичный лог.
raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "2500").strip()
try:
return max(50, min(int(raw), 5000))
except ValueError:
@@ -82,6 +88,8 @@ def begin_job_tracking(job_id: str) -> None:
def end_job_tracking(job_id: str) -> None:
"""Очистить служебные структуры после завершения задания."""
with _process_lock:
_active_subprocess_by_job.pop(job_id, None)
with _thread_lock:
_cancel_events.pop(job_id, None)
_progress.pop(job_id, None)
@@ -102,19 +110,52 @@ def get_progress_sync(job_id: str) -> tuple[str, int] | None:
return _progress.get(job_id)
def register_subprocess_sync(job_id: str, proc: subprocess.Popen) -> None:
"""Привязать текущий дочерний процесс к заданию (pull, kind create)."""
with _process_lock:
_active_subprocess_by_job[job_id] = proc
def unregister_subprocess_sync(job_id: str) -> None:
"""Снять регистрацию процесса (после завершения потока чтения)."""
with _process_lock:
_active_subprocess_by_job.pop(job_id, None)
def kill_subprocess_for_job_sync(job_id: str) -> None:
"""Принудительно завершить дочерний процесс задания (SIGKILL / группа процессов)."""
with _process_lock:
proc = _active_subprocess_by_job.get(job_id)
if proc is None:
return
if proc.poll() is not None:
return
pid = proc.pid
try:
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
logger.warning("Отмена: SIGKILL группы процесса PID %s (задание %s)", pid, job_id)
except (AttributeError, OSError, ProcessLookupError):
proc.kill()
logger.warning("Отмена: kill PID %s (задание %s)", pid, job_id)
except OSError as e:
logger.warning("Не удалось убить процесс задания %s: %s", job_id, e)
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
kill_subprocess_for_job_sync(job_id)
return True
def is_cancelled_sync(job_id: str) -> bool:
"""Проверка из worker-thread между этапами создания кластера."""
"""Проверка из worker-thread между этапами и перед каждой следующей командой."""
with _thread_lock:
ev = _cancel_events.get(job_id)
return bool(ev and ev.is_set())
@@ -164,12 +205,18 @@ class JobStore:
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec
async def set_running(self, job_id: str) -> None:
async def set_running(
self,
job_id: str,
*,
stage: str = "Выполняется…",
percent: int = 5,
) -> None:
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "running"
self._jobs[job_id].message = None
set_progress_sync(job_id, "Запуск создания кластера…", 5)
set_progress_sync(job_id, stage, percent)
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
logs = take_logs_finalize_sync(job_id)
@@ -190,7 +237,7 @@ class JobStore:
self._jobs[job_id].log_lines = logs
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock:
if job_id in self._jobs:
@@ -213,6 +260,24 @@ class JobStore:
items.sort(key=lambda r: r.created_at_utc, reverse=True)
return items[: max(1, limit)]
async def purge_completed(self) -> int:
"""
Удалить из памяти только завершённые задания (success / failed / cancelled).
Активные (queued, running) не трогаем. Служебные структуры отслеживания подчищаются.
"""
async with self._lock:
to_remove = [
jid
for jid, rec in self._jobs.items()
if rec.status in ("success", "failed", "cancelled")
]
for jid in to_remove:
del self._jobs[jid]
for jid in to_remove:
end_job_tracking(jid)
return len(to_remove)
# Синглтон на процесс uvicorn
job_store = JobStore()