Панель: журнал заданий, 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:
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
@@ -51,19 +52,31 @@ logger = logging.getLogger("kind_k8s.api.clusters")
|
||||
router = APIRouter(tags=["clusters"])
|
||||
|
||||
|
||||
def _record_to_job_view(rec: JobRecord) -> JobView:
|
||||
def _api_job_log_limit_detail() -> int:
|
||||
"""Сколько строк журнала отдавать в GET /jobs/{id} (полный хвост буфера задания)."""
|
||||
raw = (os.environ.get("KIND_K8S_JOB_API_LOG_MAX_LINES") or "5000").strip()
|
||||
try:
|
||||
return max(200, min(int(raw), 20000))
|
||||
except ValueError:
|
||||
return 5000
|
||||
|
||||
|
||||
def _record_to_job_view(rec: JobRecord, *, include_progress_log: bool = True) -> 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]
|
||||
if rec.status in ("queued", "running"):
|
||||
log_tail = get_logs_snapshot_sync(rec.job_id)
|
||||
if not include_progress_log:
|
||||
log_tail: list[str] = []
|
||||
else:
|
||||
log_tail = list(rec.log_lines or [])
|
||||
max_log = 400
|
||||
if len(log_tail) > max_log:
|
||||
log_tail = log_tail[-max_log:]
|
||||
if rec.status in ("queued", "running"):
|
||||
log_tail = get_logs_snapshot_sync(rec.job_id)
|
||||
else:
|
||||
log_tail = list(rec.log_lines or [])
|
||||
cap = _api_job_log_limit_detail()
|
||||
if len(log_tail) > cap:
|
||||
log_tail = log_tail[-cap:]
|
||||
return JobView(
|
||||
job_id=rec.job_id,
|
||||
kind=rec.kind,
|
||||
@@ -127,7 +140,18 @@ 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 [_record_to_job_view(r) for r in items]
|
||||
# В списке не тащим progress_log — экономим трафик; полный журнал только в GET /jobs/{id}.
|
||||
return [_record_to_job_view(r, include_progress_log=False) for r in items]
|
||||
|
||||
|
||||
@router.delete("/jobs", summary="Очистить завершённые задания из памяти")
|
||||
async def delete_completed_jobs() -> dict[str, int]:
|
||||
"""
|
||||
Удаляет записи со статусом success, failed, cancelled. Задания в очереди и в работе не трогаются.
|
||||
"""
|
||||
removed = await job_store.purge_completed()
|
||||
logger.info("Очистка заданий: удалено завершённых записей: %s", removed)
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
@router.get("/clusters", response_model=list[ClusterSummary], summary="Список кластеров")
|
||||
@@ -230,7 +254,7 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
||||
)
|
||||
except KindClusterError as e:
|
||||
msg = str(e)
|
||||
if "отменено" in msg.lower():
|
||||
if "отмен" in msg.lower():
|
||||
await job_store.set_cancelled(job_id, msg)
|
||||
else:
|
||||
await job_store.set_failed(job_id, msg)
|
||||
@@ -273,7 +297,7 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag:
|
||||
)
|
||||
except KindClusterError as e:
|
||||
msg = str(e)
|
||||
if "отменено" in msg.lower():
|
||||
if "отмен" in msg.lower():
|
||||
await job_store.set_cancelled(job_id, msg)
|
||||
else:
|
||||
await job_store.set_failed(job_id, msg)
|
||||
@@ -300,6 +324,72 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag:
|
||||
end_job_tracking(job_id)
|
||||
|
||||
|
||||
async def _run_stop_cluster_job(job_id: str, name: str) -> None:
|
||||
"""Фоновая остановка узлов с журналом в GET /jobs/{id}."""
|
||||
n = name.strip()
|
||||
try:
|
||||
async with kind_cluster_lock:
|
||||
await job_store.set_running(job_id, stage="Остановка узлов", percent=6)
|
||||
try:
|
||||
ok, summary = await asyncio.to_thread(stop_kind_cluster_containers, name=n, 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("stop_containers 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("stop_containers job %s: непредвиденная ошибка", job_id)
|
||||
return
|
||||
|
||||
await job_store.set_success(
|
||||
job_id,
|
||||
result={"name": n, "containers_stopped_ok": ok},
|
||||
message=summary,
|
||||
)
|
||||
logger.info("stop_containers job %s: ok=%s", job_id, ok)
|
||||
finally:
|
||||
end_job_tracking(job_id)
|
||||
|
||||
|
||||
async def _run_start_containers_job(job_id: str, name: str) -> None:
|
||||
"""Фоновый запуск уже существующих узлов (кластер зарегистрирован в kind)."""
|
||||
n = name.strip()
|
||||
try:
|
||||
async with kind_cluster_lock:
|
||||
await job_store.set_running(job_id, stage="Запуск узлов", percent=6)
|
||||
try:
|
||||
ok, summary = await asyncio.to_thread(start_kind_cluster_containers, name=n, 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("start_containers 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("start_containers job %s: непредвиденная ошибка", job_id)
|
||||
return
|
||||
|
||||
if not ok:
|
||||
await job_store.set_failed(job_id, summary)
|
||||
return
|
||||
|
||||
await job_store.set_success(
|
||||
job_id,
|
||||
result={"name": n, "containers_started_ok": ok},
|
||||
message=summary,
|
||||
)
|
||||
logger.info("start_containers job %s: ok=%s", job_id, ok)
|
||||
finally:
|
||||
end_job_tracking(job_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/clusters",
|
||||
response_model=ClusterCreateAccepted,
|
||||
@@ -346,30 +436,31 @@ async def delete_cluster(name: str) -> dict[str, object]:
|
||||
|
||||
@router.post(
|
||||
"/clusters/{name}/stop",
|
||||
summary="Остановить узлы кластера (docker stop)",
|
||||
summary="Остановить узлы кластера (фон, журнал в /jobs)",
|
||||
responses={400: {"description": "Некорректное имя"}},
|
||||
)
|
||||
async def stop_cluster_nodes(name: str) -> dict[str, object]:
|
||||
async def stop_cluster_nodes(name: str, background_tasks: BackgroundTasks) -> JSONResponse:
|
||||
"""
|
||||
Остановить контейнеры узлов kind; запись кластера в kind сохраняется.
|
||||
Поставить остановку узлов в фон: тот же журнал в реальном времени, что и при создании.
|
||||
|
||||
После этого API «Старт» запустит те же контейнеры без ``kind create``.
|
||||
Запись кластера в kind сохраняется; «Старт» снова поднимет узлы.
|
||||
"""
|
||||
if not validate_cluster_name(name):
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||
|
||||
async with kind_cluster_lock:
|
||||
|
||||
def _do() -> tuple[bool, str]:
|
||||
return stop_kind_cluster_containers(name=name)
|
||||
|
||||
try:
|
||||
ok, summary = await asyncio.to_thread(_do)
|
||||
except KindClusterError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
logger.info("Остановка узлов %s: ok=%s", name, ok)
|
||||
return {"name": name, "containers_stopped_ok": ok, "summary": summary}
|
||||
n = name.strip()
|
||||
rec = await job_store.create_job("stop_containers", cluster_name=n)
|
||||
background_tasks.add_task(_run_stop_cluster_job, rec.job_id, n)
|
||||
logger.info("Остановка узлов %s в фоне, job_id=%s", n, rec.job_id)
|
||||
return JSONResponse(
|
||||
status_code=202,
|
||||
content={
|
||||
"job_id": rec.job_id,
|
||||
"status": "queued",
|
||||
"mode": "stop",
|
||||
"message": "Остановка узлов; опросите GET /api/v1/jobs/{job_id}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -382,10 +473,9 @@ async def start_cluster_nodes(
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Если кластер есть в ``kind get clusters`` — ``docker start`` всех узлов.
|
||||
Если кластер уже зарегистрирован — фоновый запуск сохранённых узлов (журнал в GET /jobs).
|
||||
|
||||
Если в kind нет, но есть ``clusters/<имя>/kind-config.yaml`` — фоновое ``kind create``
|
||||
(как при создании, с журналом в GET /jobs/{job_id}).
|
||||
Иначе, при наличии сохранённого конфига в каталоге кластера — фоновый подъём как при создании.
|
||||
"""
|
||||
if not validate_cluster_name(name):
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||
@@ -394,25 +484,20 @@ async def start_cluster_nodes(
|
||||
|
||||
async with kind_cluster_lock:
|
||||
in_kind = n in await asyncio.to_thread(list_registered_kind_clusters)
|
||||
if in_kind:
|
||||
|
||||
def _start() -> tuple[bool, str]:
|
||||
return start_kind_cluster_containers(name=n)
|
||||
|
||||
try:
|
||||
ok, summary = await asyncio.to_thread(_start)
|
||||
except KindClusterError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
logger.info("Запуск контейнеров кластера %s: ok=%s", n, ok)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"name": n,
|
||||
"mode": "containers",
|
||||
"containers_started_ok": ok,
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
if in_kind:
|
||||
rec = await job_store.create_job("start_containers", cluster_name=n)
|
||||
background_tasks.add_task(_run_start_containers_job, rec.job_id, n)
|
||||
logger.info("Запуск контейнеров кластера %s в фоне, job_id=%s", n, rec.job_id)
|
||||
return JSONResponse(
|
||||
status_code=202,
|
||||
content={
|
||||
"job_id": rec.job_id,
|
||||
"status": "queued",
|
||||
"mode": "containers",
|
||||
"message": "Запуск узлов; опросите GET /api/v1/jobs/{job_id}",
|
||||
},
|
||||
)
|
||||
|
||||
cfg = clusters_dir() / n / "kind-config.yaml"
|
||||
if not cfg.is_file():
|
||||
@@ -437,22 +522,23 @@ async def start_cluster_nodes(
|
||||
content={
|
||||
"job_id": rec.job_id,
|
||||
"status": "queued",
|
||||
"message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}",
|
||||
"mode": "kind_config",
|
||||
"message": "Подъём кластера по сохранённому конфигу; опросите GET /api/v1/jobs/{job_id}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jobs/{job_id}/cancel",
|
||||
summary="Запросить отмену создания кластера",
|
||||
summary="Прервать фоновое задание",
|
||||
responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}},
|
||||
)
|
||||
async def cancel_create_job(job_id: str) -> dict[str, object]:
|
||||
"""
|
||||
Установить флаг отмены для задания ``create_cluster`` или ``start_cluster``.
|
||||
Прервать активное задание: скачивание образа, создание кластера, запуск/остановка узлов.
|
||||
|
||||
Этап ``kind create cluster`` нельзя прервать до его завершения; после него отмена удалит
|
||||
кластер и данные (если успели создать).
|
||||
Для длительных команд дочерний процесс завершается принудительно; между узлами останов
|
||||
также учитывает флаг отмены.
|
||||
"""
|
||||
rec = await job_store.get(job_id)
|
||||
if not rec:
|
||||
@@ -465,7 +551,7 @@ async def cancel_create_job(job_id: str) -> dict[str, object]:
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"cancel_requested": True,
|
||||
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага",
|
||||
"message": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+348
-64
@@ -8,6 +8,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -25,6 +27,18 @@ from kubeconfig_patch import patch_kubeconfig_server_for_host, should_patch_afte
|
||||
|
||||
logger = logging.getLogger("kind_k8s.cluster_lifecycle")
|
||||
|
||||
# Удаление ANSI/OSC из потока (docker TTY: курсор [1A, очистка [2K и т.д.) — в журнале остаётся читаемый текст.
|
||||
_ANSI_CSI_RE = re.compile(r"\x1b\[[\d;?]*[ -/]*[@-~]")
|
||||
_ANSI_OSC_RE = re.compile(r"\x1b\][^\x07]*(?:\x07|\x1b\\)")
|
||||
|
||||
|
||||
def _strip_ansi_stream_text(s: str) -> str:
|
||||
"""Убрать CSI/OSC-последовательности терминала из строки лога (docker TTY: [1A, [2K, …)."""
|
||||
if not s:
|
||||
return ""
|
||||
t = _ANSI_CSI_RE.sub("", s)
|
||||
return _ANSI_OSC_RE.sub("", t)
|
||||
|
||||
|
||||
def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None:
|
||||
"""Удалить кластер kind и каталог данных после запроса отмены (best-effort)."""
|
||||
@@ -107,40 +121,222 @@ def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None:
|
||||
raise KindClusterError(f"Команда завершилась с кодом {p.returncode}: {err}", exit_code=p.returncode)
|
||||
|
||||
|
||||
def _stream_pty_enabled() -> bool:
|
||||
"""
|
||||
Псевдо-TTY: docker/kind при выводе в pipe часто полностью буферизуют stdout — в журнале «тишина»
|
||||
до конца команды. С TTY строки прогресса (в т.ч. pull) уходят порциями.
|
||||
|
||||
Отключение: ``KIND_K8S_STREAM_PTY=0``.
|
||||
"""
|
||||
if os.name != "posix":
|
||||
return False
|
||||
v = (os.environ.get("KIND_K8S_STREAM_PTY") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off", "нет")
|
||||
|
||||
|
||||
def _emit_stream_lines(
|
||||
buffer: str,
|
||||
on_line: Callable[[str], None] | None,
|
||||
*,
|
||||
flush_tail: bool,
|
||||
) -> str:
|
||||
"""
|
||||
Разобрать буфер по ``\\n`` и ``\\r`` (прогресс docker/containerd часто идёт с ``\\r`` без ``\\n``).
|
||||
|
||||
При ``flush_tail=False`` возвращает неполный хвост после последнего ``\\n``.
|
||||
"""
|
||||
if not buffer:
|
||||
return ""
|
||||
normalized = buffer.replace("\r\n", "\n").replace("\r", "\n")
|
||||
parts = normalized.split("\n")
|
||||
if flush_tail:
|
||||
for part in parts:
|
||||
s = _strip_ansi_stream_text(part).strip()
|
||||
if s and on_line:
|
||||
on_line(s)
|
||||
if s:
|
||||
logger.debug("stream: %s", s[:800])
|
||||
return ""
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
*body, tail = parts
|
||||
for part in body:
|
||||
s = _strip_ansi_stream_text(part).strip()
|
||||
if s and on_line:
|
||||
on_line(s)
|
||||
if s:
|
||||
logger.debug("stream: %s", s[:800])
|
||||
return tail
|
||||
|
||||
|
||||
def _run_checked_stream(
|
||||
cmd: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
on_line: Callable[[str], None] | None = None,
|
||||
job_id: str | None = None,
|
||||
use_pty: bool | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Выполнить команду с построчным выводом в колбэк (stdout+stderr объединены).
|
||||
Выполнить команду с потоковым выводом в колбэк (stdout+stderr объединены).
|
||||
|
||||
Нужен для ``kind create cluster``: pull образов и подъём нод видны в UI по опросу job.
|
||||
``use_pty=None`` — как в ``KIND_K8S_STREAM_PTY``. ``use_pty=False`` — только pipe (удобно вместе
|
||||
с ``docker pull --progress plain``). ``use_pty=True`` — принудительно PTY на POSIX.
|
||||
|
||||
``job_id`` — регистрация ``Popen`` для принудительной отмены (``SIGKILL`` группы процессов).
|
||||
"""
|
||||
from core import job_store as _js
|
||||
|
||||
logger.info("Выполнение (поток): %s", " ".join(cmd))
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
if p.stdout is None:
|
||||
raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1)
|
||||
|
||||
master_fd: int | None = None
|
||||
if use_pty is None:
|
||||
use_pty = _stream_pty_enabled()
|
||||
slave_fd: int | None = None
|
||||
|
||||
if use_pty:
|
||||
try:
|
||||
import pty
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
except OSError as e:
|
||||
logger.warning("PTY недоступен (%s), поток через pipe — вывод может обновляться с задержкой", e)
|
||||
use_pty = False
|
||||
master_fd = None
|
||||
slave_fd = None
|
||||
|
||||
if use_pty and slave_fd is not None and master_fd is not None:
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.STDOUT,
|
||||
close_fds=False,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except OSError:
|
||||
pass
|
||||
slave_fd = None
|
||||
else:
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0,
|
||||
start_new_session=True,
|
||||
)
|
||||
master_fd = None
|
||||
if p.stdout is None:
|
||||
raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1)
|
||||
|
||||
if job_id:
|
||||
_js.register_subprocess_sync(job_id, p)
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
carry = ""
|
||||
rc = -1
|
||||
try:
|
||||
for raw in p.stdout:
|
||||
line = raw.rstrip("\n\r")
|
||||
if on_line and line:
|
||||
on_line(line)
|
||||
if line:
|
||||
logger.debug("stream: %s", line[:800])
|
||||
while True:
|
||||
try:
|
||||
if master_fd is not None:
|
||||
raw = os.read(master_fd, 4096)
|
||||
else:
|
||||
raw = p.stdout.read(4096) if p.stdout else b""
|
||||
except OSError as e:
|
||||
# EINTR — повторить read; EIO — slave PTY закрыт (часто после SIGKILL при отмене).
|
||||
_eio = getattr(errno, "EIO", 5)
|
||||
if e.errno == errno.EINTR:
|
||||
continue
|
||||
if e.errno == _eio:
|
||||
logger.debug("Чтение потока: EIO (вероятно процесс завершён), выходим из цикла")
|
||||
break
|
||||
raise
|
||||
if not raw:
|
||||
break
|
||||
carry += decoder.decode(raw)
|
||||
carry = _emit_stream_lines(carry, on_line, flush_tail=False)
|
||||
carry += decoder.decode(b"", final=True)
|
||||
carry = _emit_stream_lines(carry, on_line, flush_tail=True)
|
||||
rc = p.wait()
|
||||
finally:
|
||||
p.stdout.close()
|
||||
if job_id:
|
||||
_js.unregister_subprocess_sync(job_id)
|
||||
if master_fd is not None:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
if p.stdout:
|
||||
p.stdout.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if job_id and _js.is_cancelled_sync(job_id):
|
||||
raise KindClusterError("Операция отменена", exit_code=130)
|
||||
if rc != 0:
|
||||
raise KindClusterError(f"Команда завершилась с кодом {rc} (см. журнал задания выше)", exit_code=rc)
|
||||
raise KindClusterError(f"Команда завершилась с кодом {rc} (см. журнал выше)", exit_code=rc)
|
||||
|
||||
|
||||
def _docker_pull_plain_progress_enabled() -> bool:
|
||||
"""
|
||||
``docker pull --progress plain`` — построчный текст без TTY-ANSI; удобно с pipe (без PTY).
|
||||
|
||||
Отключить: ``KIND_K8S_DOCKER_PULL_PLAIN=0`` (если старая версия docker не знает флаг).
|
||||
"""
|
||||
v = (os.environ.get("KIND_K8S_DOCKER_PULL_PLAIN") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off", "нет")
|
||||
|
||||
|
||||
def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], job_id: str | None) -> None:
|
||||
"""
|
||||
Скачать образ узлов через ``docker pull`` / ``podman pull`` с выводом в журнал задания.
|
||||
|
||||
Для **docker** по умолчанию: ``--progress plain`` и **без PTY** — идут строки Downloading/Extracting
|
||||
с процентами. Для **podman** — обычный pull и PTY по ``KIND_K8S_STREAM_PTY``.
|
||||
"""
|
||||
cli = _container_cli_bin()
|
||||
if not shutil.which(cli):
|
||||
raise KindClusterError(
|
||||
"Скачивание образа недоступно: не найдена программа для работы с контейнерами (docker/podman).",
|
||||
exit_code=127,
|
||||
)
|
||||
if cli == "docker" and _docker_pull_plain_progress_enabled():
|
||||
try:
|
||||
_run_checked_stream(
|
||||
[cli, "pull", "--progress=plain", image],
|
||||
on_line=on_line,
|
||||
job_id=job_id,
|
||||
use_pty=False,
|
||||
)
|
||||
return
|
||||
except KindClusterError as e:
|
||||
# У docker CLI код 125 — «неверная команда/флаг»; тогда пробуем pull без plain.
|
||||
err_low = str(e).lower()
|
||||
if e.exit_code == 125 or any(
|
||||
x in err_low
|
||||
for x in ("progress", "unknown flag", "bad flag", "invalid option", "unknown shorthand")
|
||||
):
|
||||
logger.warning("Повтор docker pull без --progress=plain: %s", e)
|
||||
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
||||
return
|
||||
raise
|
||||
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
||||
|
||||
|
||||
def _run_capture_checked(cmd: list[str]) -> str:
|
||||
@@ -231,7 +427,6 @@ def create_cluster_non_interactive(
|
||||
def _progress(stage: str, pct: int) -> None:
|
||||
if job_id:
|
||||
_job_store.set_progress_sync(job_id, stage, pct)
|
||||
_job_store.append_log_sync(job_id, f"[{pct}%] {stage}")
|
||||
|
||||
def _log(line: str) -> None:
|
||||
if job_id:
|
||||
@@ -274,15 +469,17 @@ def create_cluster_non_interactive(
|
||||
node_image = str(prev["node_image"])
|
||||
if prev.get("kubernetes_version_tag"):
|
||||
ver_tag = str(prev["kubernetes_version_tag"])
|
||||
_progress("Используется существующий kind-config.yaml", 10)
|
||||
_progress("Чтение сохранённого конфига", 10)
|
||||
_log("Используется сохранённый файл конфигурации кластера.")
|
||||
else:
|
||||
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)
|
||||
_progress("Подготовка конфигурации", 12)
|
||||
_log("Файл конфигурации кластера сохранён в каталог данных.")
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
raise KindClusterError("Операция отменена")
|
||||
|
||||
logger.info(
|
||||
"Создание кластера «%s», образ %s, workers=%s, existing_cfg=%s",
|
||||
@@ -291,38 +488,54 @@ def create_cluster_non_interactive(
|
||||
workers,
|
||||
use_existing_config,
|
||||
)
|
||||
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
|
||||
_log("--- kind create cluster ---")
|
||||
_progress("Скачивание образа при необходимости", 18)
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Операция отменена")
|
||||
try:
|
||||
_pull_node_image_streaming(image=node_image, on_line=_log, job_id=job_id)
|
||||
except KindClusterError as e:
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Операция отменена") from e
|
||||
raise
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Операция отменена")
|
||||
|
||||
_progress("Создание узлов кластера", 32)
|
||||
_run_checked_stream(
|
||||
["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)],
|
||||
on_line=_log,
|
||||
job_id=job_id,
|
||||
)
|
||||
|
||||
if _cancelled():
|
||||
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
|
||||
raise KindClusterError("Создание отменено пользователем")
|
||||
raise KindClusterError("Операция отменена")
|
||||
|
||||
_progress("Сохранение kubeconfig", 58)
|
||||
_progress("Сохранение доступа к кластеру", 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("Создание отменено пользователем")
|
||||
raise KindClusterError("Операция отменена")
|
||||
|
||||
patched = False
|
||||
if should_patch_after_create():
|
||||
_progress("Патч kubeconfig для доступа к API с хоста", 72)
|
||||
_progress("Настройка доступа с вашего компьютера", 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("Создание отменено пользователем")
|
||||
raise KindClusterError("Операция отменена")
|
||||
|
||||
nodes_ready: bool | None = None
|
||||
nodes_msg: str | None = None
|
||||
if _wait_nodes_enabled():
|
||||
_progress("Ожидание готовности нод (kubectl wait …)", 82)
|
||||
_progress("Ожидание готовности узлов", 82)
|
||||
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
|
||||
nodes_ready = ok
|
||||
nodes_msg = msg
|
||||
@@ -330,7 +543,7 @@ def create_cluster_non_interactive(
|
||||
logger.info("Ноды готовы: %s", msg)
|
||||
else:
|
||||
logger.warning("Ожидание нод не завершилось успешно: %s", msg)
|
||||
_log(f"kubectl wait nodes: {msg}"[:4000])
|
||||
_log(("Узлы готовы" if ok else "Ожидание узлов: ") + (msg or "")[:4000])
|
||||
|
||||
worker_nodes_meta = workers
|
||||
if use_existing_config:
|
||||
@@ -357,7 +570,7 @@ def create_cluster_non_interactive(
|
||||
}
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
_progress("Финализация", 95)
|
||||
_progress("Завершение", 95)
|
||||
|
||||
return CreateClusterResult(
|
||||
cluster_name=name,
|
||||
@@ -434,7 +647,7 @@ def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]:
|
||||
"""Имена контейнеров узлов kind (все с префиксом ``<имя>-``)."""
|
||||
cli = _container_cli_bin()
|
||||
if not shutil.which(cli):
|
||||
raise KindClusterError(f"CLI контейнеров «{cli}» не найден в PATH.", exit_code=127)
|
||||
raise KindClusterError("Программа для контейнеров не найдена в PATH.", exit_code=127)
|
||||
p = subprocess.run(
|
||||
[cli, "ps", "-a", "--format", "{{.Names}}"],
|
||||
capture_output=True,
|
||||
@@ -442,59 +655,130 @@ def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]:
|
||||
)
|
||||
if p.returncode != 0:
|
||||
err = (p.stderr or p.stdout or "").strip()
|
||||
raise KindClusterError(f"{cli} ps: {err}", exit_code=p.returncode)
|
||||
raise KindClusterError(f"Не удалось получить список контейнеров: {err}", exit_code=p.returncode)
|
||||
prefix = f"{cluster_name}-"
|
||||
raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()]
|
||||
matched = [n for n in raw if n.startswith(prefix)]
|
||||
return _sort_kind_node_containers(matched)
|
||||
|
||||
|
||||
def stop_kind_cluster_containers(*, name: str) -> tuple[bool, str]:
|
||||
def stop_kind_cluster_containers(*, name: str, job_id: str | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Остановить контейнеры узлов (``docker stop`` / ``podman stop``).
|
||||
Остановить контейнеры узлов (CLI контейнеров из ``CONTAINER_CLI``).
|
||||
|
||||
Запись kind о кластере сохраняется; позже можно вызвать ``start_kind_cluster_containers``.
|
||||
|
||||
При ``job_id`` — потоковый вывод в журнал задания и возможность прервать текущую команду отменой.
|
||||
"""
|
||||
from core import job_store as _js
|
||||
|
||||
names = list_kind_cluster_container_names(cluster_name=name)
|
||||
if not names:
|
||||
return True, "Нет контейнеров с префиксом «%s-» (уже остановлены или удалены)" % name
|
||||
return True, "Узлы уже остановлены или отсутствуют"
|
||||
|
||||
def _cancelled() -> bool:
|
||||
return bool(job_id and _js.is_cancelled_sync(job_id))
|
||||
|
||||
cli = _container_cli_bin()
|
||||
n = len(names)
|
||||
ok_all = True
|
||||
parts: list[str] = []
|
||||
for ctr in names:
|
||||
p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
ok_all = False
|
||||
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
|
||||
parts.append(f"{ctr}: ошибка ({err})")
|
||||
logger.warning("%s stop %s: %s", cli, ctr, err)
|
||||
failed_idx: list[int] = []
|
||||
|
||||
for i, ctr in enumerate(names, start=1):
|
||||
if _cancelled():
|
||||
raise KindClusterError("Операция отменена", exit_code=130)
|
||||
if job_id:
|
||||
pct = 5 + int(88 * (i - 1) / max(n, 1))
|
||||
_js.set_progress_sync(job_id, f"Остановка узла {i} из {n}", min(pct, 93))
|
||||
_js.append_log_sync(job_id, f"Узел {i} из {n}")
|
||||
try:
|
||||
|
||||
def _on_line(line: str) -> None:
|
||||
_js.append_log_sync(job_id, line)
|
||||
|
||||
_run_checked_stream([cli, "stop", ctr], on_line=_on_line, job_id=job_id)
|
||||
except KindClusterError as e:
|
||||
if _cancelled():
|
||||
raise KindClusterError("Операция отменена", exit_code=130) from e
|
||||
ok_all = False
|
||||
failed_idx.append(i)
|
||||
logger.warning("%s stop узел %s: %s", cli, i, e)
|
||||
else:
|
||||
parts.append(f"{ctr}: OK")
|
||||
return ok_all, "; ".join(parts)
|
||||
p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
ok_all = False
|
||||
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
|
||||
failed_idx.append(i)
|
||||
logger.warning("%s stop %s: %s", cli, ctr, err)
|
||||
|
||||
if job_id:
|
||||
_js.set_progress_sync(job_id, "Готово", 98)
|
||||
|
||||
if ok_all:
|
||||
return True, f"Остановлено узлов: {n}"
|
||||
return False, "Не удалось остановить узлы: " + (
|
||||
", ".join(str(x) for x in failed_idx) if failed_idx else "см. журнал"
|
||||
)
|
||||
|
||||
|
||||
def start_kind_cluster_containers(*, name: str) -> tuple[bool, str]:
|
||||
"""Запустить контейнеры узлов kind (после ``stop`` или рестарта движка)."""
|
||||
def start_kind_cluster_containers(*, name: str, job_id: str | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Запустить контейнеры узлов (после остановки или перезапуска движка контейнеров).
|
||||
|
||||
При ``job_id`` — журнал и прогресс в задании, прерывание текущей команды через отмену задания.
|
||||
"""
|
||||
from core import job_store as _js
|
||||
|
||||
names = list_kind_cluster_container_names(cluster_name=name)
|
||||
if not names:
|
||||
return False, (
|
||||
"Не найдены контейнеры «%s-*». Если кластера нет в kind — используйте «Старт» "
|
||||
"из UI (создание по сохранённому kind-config.yaml) или создайте кластер заново."
|
||||
% name
|
||||
"Сохранённые узлы не найдены. Если кластер ещё не создавался в этой среде — создайте его "
|
||||
"или поднимите по сохранённой конфигурации из панели."
|
||||
)
|
||||
|
||||
def _cancelled() -> bool:
|
||||
return bool(job_id and _js.is_cancelled_sync(job_id))
|
||||
|
||||
cli = _container_cli_bin()
|
||||
n = len(names)
|
||||
ok_all = True
|
||||
parts: list[str] = []
|
||||
for ctr in names:
|
||||
p = subprocess.run([cli, "start", ctr], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
ok_all = False
|
||||
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
|
||||
parts.append(f"{ctr}: ошибка ({err})")
|
||||
logger.warning("%s start %s: %s", cli, ctr, err)
|
||||
failed_idx: list[int] = []
|
||||
|
||||
for i, ctr in enumerate(names, start=1):
|
||||
if _cancelled():
|
||||
raise KindClusterError("Операция отменена", exit_code=130)
|
||||
if job_id:
|
||||
pct = 5 + int(88 * (i - 1) / max(n, 1))
|
||||
_js.set_progress_sync(job_id, f"Запуск узла {i} из {n}", min(pct, 93))
|
||||
_js.append_log_sync(job_id, f"Узел {i} из {n}")
|
||||
try:
|
||||
|
||||
def _on_line(line: str) -> None:
|
||||
_js.append_log_sync(job_id, line)
|
||||
|
||||
_run_checked_stream([cli, "start", ctr], on_line=_on_line, job_id=job_id)
|
||||
except KindClusterError as e:
|
||||
if _cancelled():
|
||||
raise KindClusterError("Операция отменена", exit_code=130) from e
|
||||
ok_all = False
|
||||
failed_idx.append(i)
|
||||
logger.warning("%s start узел %s: %s", cli, i, e)
|
||||
else:
|
||||
parts.append(f"{ctr}: OK")
|
||||
return ok_all, "; ".join(parts)
|
||||
p = subprocess.run([cli, "start", ctr], capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
ok_all = False
|
||||
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
|
||||
failed_idx.append(i)
|
||||
logger.warning("%s start %s: %s", cli, ctr, err)
|
||||
|
||||
if job_id:
|
||||
_js.set_progress_sync(job_id, "Готово", 98)
|
||||
|
||||
if ok_all:
|
||||
return True, f"Запущено узлов: {n}"
|
||||
return False, "Не удалось запустить узлы: " + (
|
||||
", ".join(str(x) for x in failed_idx) if failed_idx else "см. журнал"
|
||||
)
|
||||
|
||||
|
||||
def read_meta_json(cluster_name: str) -> dict[str, object] | None:
|
||||
|
||||
+72
-7
@@ -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()
|
||||
|
||||
+54
-34
@@ -19,7 +19,7 @@
|
||||
|
||||
| Маршрут | Описание |
|
||||
|---------|----------|
|
||||
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** `kind create`, отмена), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. |
|
||||
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** операции в реальном времени, **отмена** с прерыванием текущей команды), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. |
|
||||
| `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path` — **`GET /api/v1/docs/readme`**, с `?path=app/docs/…` — **`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2** — **одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. |
|
||||
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
|
||||
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
|
||||
@@ -41,15 +41,16 @@
|
||||
| GET | `/api/v1/stats` | Сводка для дашборда |
|
||||
| GET | `/api/v1/clusters` | Список кластеров |
|
||||
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) |
|
||||
| POST | `/api/v1/clusters/{name}/start` | Запуск: **200** — `docker start` узлов (кластер в kind); **202** + `job_id` — фоновый `kind create` по сохранённому `kind-config.yaml` |
|
||||
| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов (`docker`/`podman` **stop**), запись в kind сохраняется |
|
||||
| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers` или `kind_config`); журнал — `GET /jobs/{job_id}` |
|
||||
| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов в фоне (**202** + `job_id`, `mode`: `stop`); журнал — `GET /jobs/{job_id}` |
|
||||
| GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig |
|
||||
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
|
||||
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
|
||||
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
|
||||
| GET | `/api/v1/jobs` | Последние задания создания |
|
||||
| GET | `/api/v1/jobs/{job_id}` | Статус одного задания (включая `progress_stage`, `progress_percent`) |
|
||||
| POST | `/api/v1/jobs/{job_id}/cancel` | Запросить отмену создания (между этапами; `kind create` до конца не прерывается) |
|
||||
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
|
||||
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
|
||||
| DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
|
||||
| POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) |
|
||||
|
||||
### Фоновые задания (jobs)
|
||||
|
||||
@@ -57,9 +58,11 @@
|
||||
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
|
||||
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
||||
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
|
||||
- Поле **`progress_log`** — массив последних строк журнала (вывод `kind create`: pull образов, подъём нод и т.д.); размер ограничен (см. `KIND_K8S_JOB_LOG_MAX_LINES` в коде `job_store`, по умолчанию до **500** строк в буфере, в JSON отдаётся хвост).
|
||||
- Тип задания **`kind`**: `create_cluster` или `start_cluster` (повторный подъём по `clusters/<имя>/kind-config.yaml`).
|
||||
- Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается.
|
||||
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
|
||||
- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются.
|
||||
- Для **`docker pull`** по умолчанию: **`--progress=plain`** и вывод **без PTY** (`KIND_K8S_DOCKER_PULL_PLAIN=1`) — в журнале идут строки с процентами/слоями. Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды.
|
||||
- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов).
|
||||
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
|
||||
|
||||
---
|
||||
|
||||
@@ -236,7 +239,7 @@ Accept: text/markdown
|
||||
|
||||
## GET /api/v1/jobs
|
||||
|
||||
Список последних фоновых заданий (создание кластера), от новых к старым.
|
||||
Список последних фоновых заданий, от новых к старым. Поле **`progress_log`** в каждом элементе **пустое** — используйте **GET /api/v1/jobs/{job_id}** для журнала.
|
||||
|
||||
**Query:** `limit` (1–200, по умолчанию **30**).
|
||||
|
||||
@@ -253,16 +256,31 @@ Accept: text/markdown
|
||||
"message": "Кластер создан",
|
||||
"result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" },
|
||||
"progress_stage": null,
|
||||
"progress_percent": null
|
||||
"progress_percent": null,
|
||||
"progress_log": []
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DELETE /api/v1/jobs
|
||||
|
||||
Удаляет из памяти записи заданий со статусом **success**, **failed** или **cancelled**. Задания **queued** и **running** не удаляются.
|
||||
|
||||
**Пример ответа 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"removed": 4
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/jobs/{job_id}/cancel
|
||||
|
||||
Запрос отмены создания кластера. Пока задание в статусе `queued` или `running`, между этапами выполняется проверка флага; после уже запущенного `kind create cluster` нужно дождаться окончания этого шага.
|
||||
Прервать фоновое задание (`create_cluster`, `start_cluster`, `start_containers`, `stop_containers`). Для длительной команды завершается связанный дочерний процесс; между шагами запуска/остановки отдельных узлов также проверяется флаг отмены.
|
||||
|
||||
**Пример ответа 200:**
|
||||
|
||||
@@ -270,7 +288,7 @@ Accept: text/markdown
|
||||
{
|
||||
"job_id": "a1b2…",
|
||||
"cancel_requested": true,
|
||||
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага"
|
||||
"message": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -370,19 +388,19 @@ Accept: text/markdown
|
||||
|
||||
## POST /api/v1/clusters/{name}/start
|
||||
|
||||
Запуск кластера двумя сценариями:
|
||||
Запуск кластера двумя сценариями (оба с **202** и `job_id`, журнал в `GET /jobs/{job_id}`):
|
||||
|
||||
1. Кластер **есть** в `kind get clusters` (узлы когда-либо создавались) — выполняется **`docker start`** / **`podman start`** для всех контейнеров с именами вида `<имя>-control-plane`, `<имя>-worker`, … Ответ **200**.
|
||||
2. В **kind** кластера **нет**, но в `clusters/<имя>/kind-config.yaml` файл **есть** — ставится фоновое задание **`start_cluster`** (как при создании: `kind create` по сохранённому конфигу, журнал в `GET /jobs/{job_id}`). Ответ **202** + `job_id`.
|
||||
1. Кластер **зарегистрирован** в kind — задание **`start_containers`** (поочерёдный запуск узлов, `mode`: **`containers`**).
|
||||
2. В kind кластера **нет**, но есть сохранённый **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**), логика как при создании (в т.ч. скачивание образа при необходимости).
|
||||
|
||||
**Пример ответа 200 (контейнеры запущены):**
|
||||
**Пример ответа 202 (запуск узлов):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dev",
|
||||
"job_id": "cafebabe...",
|
||||
"status": "queued",
|
||||
"mode": "containers",
|
||||
"containers_started_ok": true,
|
||||
"summary": "dev-control-plane: OK; dev-worker: OK; dev-worker2: OK"
|
||||
"message": "Запуск узлов; опросите GET /api/v1/jobs/{job_id}"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -390,9 +408,10 @@ Accept: text/markdown
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "cafebabe...",
|
||||
"job_id": "deadbeef...",
|
||||
"status": "queued",
|
||||
"message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}"
|
||||
"mode": "kind_config",
|
||||
"message": "Подъём кластера по сохранённому конфигу; опросите GET /api/v1/jobs/{job_id}"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -402,15 +421,16 @@ Accept: text/markdown
|
||||
|
||||
## POST /api/v1/clusters/{name}/stop
|
||||
|
||||
Остановка **всех** контейнеров узлов кластера (`docker stop` / `podman stop` по префиксу имени). Запись кластера в kind **не удаляется**; позже можно снова вызвать **POST …/start** (режим `containers`).
|
||||
Остановка узлов кластера **в фоне** (задание **`stop_containers`**). Запись кластера в kind **не удаляется**; позже снова **POST …/start**.
|
||||
|
||||
**Пример ответа 200:**
|
||||
**Пример ответа 202:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dev",
|
||||
"containers_stopped_ok": true,
|
||||
"summary": "dev-control-plane: OK; dev-worker: OK"
|
||||
"job_id": "baba...",
|
||||
"status": "queued",
|
||||
"mode": "stop",
|
||||
"message": "Остановка узлов; опросите GET /api/v1/jobs/{job_id}"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -433,13 +453,13 @@ Accept: text/markdown
|
||||
"created_at_utc": "2026-04-04T12:00:00+00:00",
|
||||
"message": null,
|
||||
"result": null,
|
||||
"progress_stage": "kind create cluster (скачивание образов и подъём нод — может занять несколько минут)",
|
||||
"progress_percent": 28,
|
||||
"progress_stage": "Создание узлов кластера",
|
||||
"progress_percent": 45,
|
||||
"progress_log": [
|
||||
"[12%] Подготовка каталога и kind-config",
|
||||
"--- kind create cluster ---",
|
||||
"Creating cluster \"dev\" ...",
|
||||
" • Ensuring node image (kindest/node:v1.29.4) 🖼 ..."
|
||||
"Подготовка конфигурации",
|
||||
"Using default tag: latest",
|
||||
"Status: Downloaded newer image for kindest/node:v1.29.4",
|
||||
"Creating cluster \"dev\" ..."
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -454,7 +474,7 @@ Accept: text/markdown
|
||||
"cluster_name": "dev",
|
||||
"created_at_utc": "2026-04-04T12:00:00+00:00",
|
||||
"message": "Кластер создан",
|
||||
"progress_log": ["[95%] Финализация", "kubectl wait nodes: ..."],
|
||||
"progress_log": ["Завершение", "Узлы готовы: condition met"],
|
||||
"result": {
|
||||
"cluster_name": "dev",
|
||||
"kubernetes_version_tag": "v1.29.4",
|
||||
|
||||
@@ -41,11 +41,11 @@ class JobView(BaseModel):
|
||||
created_at_utc: str
|
||||
message: str | None = None
|
||||
result: dict[str, Any] | None = None
|
||||
progress_stage: str | None = Field(default=None, description="Текущий этап создания (пока задание активно)")
|
||||
progress_stage: str | None = Field(default=None, description="Текущий этап операции (пока задание активно)")
|
||||
progress_percent: int | None = Field(default=None, description="Прогресс 0–100 для индикатора в UI")
|
||||
progress_log: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Хвост лога (kind create, этапы); обновляется при опросе GET /jobs/{id}",
|
||||
description="Хвост журнала (скачивание образа, длительные команды, этапы); при опросе GET /jobs/{id}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+161
-35
@@ -12,16 +12,18 @@
|
||||
const body = document.body;
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
|
||||
/** Интервал опроса списков и среды (мс) */
|
||||
/** Интервал автообновления таблиц и среды на панели (мс) */
|
||||
var AUTO_REFRESH_MS = 3500;
|
||||
/** Интервал опроса задания создания (мс) */
|
||||
var JOB_POLL_MS = 1500;
|
||||
/** Опрос GET /jobs/{id} для активного задания (мс) — чаще, чтобы журнал обновлялся живее */
|
||||
var JOB_POLL_ACTIVE_MS = 650;
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
var autoTimer = null;
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
var pollTimer = null;
|
||||
var createInProgress = false;
|
||||
/** Блокировать форму «Создать» только при создании из формы, не при старт/стоп из таблицы. */
|
||||
var pollLockCreateForm = false;
|
||||
/** @type {string | null} */
|
||||
var currentPollJobId = null;
|
||||
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
|
||||
@@ -69,6 +71,32 @@
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? "0" + n : String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC).
|
||||
* @param {string} iso
|
||||
*/
|
||||
function formatJobTimeUtc(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return (
|
||||
pad2(d.getUTCDate()) +
|
||||
"." +
|
||||
pad2(d.getUTCMonth() + 1) +
|
||||
"." +
|
||||
String(d.getUTCFullYear()).slice(-2) +
|
||||
" " +
|
||||
pad2(d.getUTCHours()) +
|
||||
":" +
|
||||
pad2(d.getUTCMinutes()) +
|
||||
" UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
|
||||
* @param {string} clusterName
|
||||
@@ -493,6 +521,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена).
|
||||
*/
|
||||
async function clearCompletedJobs() {
|
||||
const ok = await openConfirmModal({
|
||||
title: "Очистить завершённые задания?",
|
||||
message:
|
||||
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
|
||||
confirmLabel: "Очистить",
|
||||
danger: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
try {
|
||||
const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } });
|
||||
const text = await r.text();
|
||||
var data = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
if (!r.ok) {
|
||||
showToast(formatApiError(data, text || r.statusText), true);
|
||||
return;
|
||||
}
|
||||
var n = data.removed != null ? data.removed : 0;
|
||||
if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать.";
|
||||
showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false);
|
||||
await loadJobs();
|
||||
await loadStats();
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка: " + e.message;
|
||||
showToast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
const tbody = document.querySelector("#tbl-jobs tbody");
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
@@ -503,7 +568,10 @@
|
||||
rows.forEach(function (j) {
|
||||
const tr = document.createElement("tr");
|
||||
const st = escapeHtml(j.status || "");
|
||||
var kindTag = j.kind === "start_cluster" ? "[старт] " : "";
|
||||
var kindTag = "";
|
||||
if (j.kind === "start_cluster") kindTag = "[старт] ";
|
||||
else if (j.kind === "stop_containers") kindTag = "[стоп] ";
|
||||
else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
|
||||
var cellMsg = kindTag + (j.message || "").slice(0, 140);
|
||||
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
|
||||
cellMsg =
|
||||
@@ -511,11 +579,15 @@
|
||||
j.progress_stage +
|
||||
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
|
||||
}
|
||||
const iso = j.created_at_utc || "";
|
||||
const timeLabel = formatJobTimeUtc(iso);
|
||||
tr.innerHTML =
|
||||
"<td><time datetime=\"" +
|
||||
escapeHtml(j.created_at_utc || "") +
|
||||
escapeHtml(iso) +
|
||||
"\" title=\"" +
|
||||
escapeHtml(iso ? "UTC: " + iso : "") +
|
||||
"\">" +
|
||||
escapeHtml(j.created_at_utc || "") +
|
||||
escapeHtml(timeLabel) +
|
||||
"</time></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(j.cluster_name || "—") +
|
||||
@@ -644,14 +716,31 @@
|
||||
message:
|
||||
"Кластер «" +
|
||||
name +
|
||||
"»: контейнеры будут остановлены (docker/podman stop). Запись в kind сохранится — позже можно снова нажать «Старт».",
|
||||
"»: узлы будут остановлены. Сведения о кластере сохранятся — позже можно снова нажать «Старт».",
|
||||
confirmLabel: "Остановить",
|
||||
danger: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
const url = API + "/clusters/" + encodeURIComponent(name) + "/stop";
|
||||
try {
|
||||
const res = await api("/clusters/" + encodeURIComponent(name) + "/stop", { method: "POST" });
|
||||
showToast(String(res.summary || "Узлы остановлены"), false);
|
||||
const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
|
||||
const text = await r.text();
|
||||
var data = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
if (r.status === 202 && data.job_id) {
|
||||
setProgressHint(name, "stop");
|
||||
pollJob(data.job_id, { lockCreateForm: false });
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showToast(formatApiError(data, text || r.statusText), true);
|
||||
return;
|
||||
}
|
||||
showToast(String(data.summary || "Готово"), false);
|
||||
await loadClusters();
|
||||
await loadStats();
|
||||
await loadHealth();
|
||||
@@ -674,8 +763,10 @@
|
||||
data = {};
|
||||
}
|
||||
if (r.status === 202 && data.job_id) {
|
||||
setProgressHint(name);
|
||||
pollJob(data.job_id);
|
||||
var sm = data.mode || "kind_config";
|
||||
if (sm === "containers") setProgressHint(name, "start_containers");
|
||||
else setProgressHint(name, "start_config");
|
||||
pollJob(data.job_id, { lockCreateForm: false });
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
@@ -741,21 +832,26 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить текст подсказки над прогрессом (создание с нуля или старт по конфигу).
|
||||
* Подсказка над прогресс-баром (без технических имён команд).
|
||||
* @param {string | null} clusterName
|
||||
* @param {"create"|"start_config"|"start_containers"|"stop"} [mode]
|
||||
*/
|
||||
function setProgressHint(clusterName) {
|
||||
function setProgressHint(clusterName, mode) {
|
||||
const hint = document.getElementById("create-progress-hint");
|
||||
if (!hint) return;
|
||||
if (clusterName) {
|
||||
hint.innerHTML =
|
||||
"Кластер <code>" +
|
||||
escapeHtml(clusterName) +
|
||||
"</code>: подъём по конфигу или длительный <code>kind create</code> — ниже журнал (pull образов и ноды).";
|
||||
mode = mode || "create";
|
||||
var named = clusterName ? "«" + clusterName + "»" : "";
|
||||
if (mode === "stop") {
|
||||
hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже.";
|
||||
} else if (mode === "start_containers") {
|
||||
hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже.";
|
||||
} else if (mode === "start_config") {
|
||||
hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже.";
|
||||
} else {
|
||||
hint.innerHTML =
|
||||
"Создание кластера: шаг <code>kind create</code> может занять несколько минут при первом pull образов. " +
|
||||
"Ниже — журнал в реальном времени.";
|
||||
hint.textContent =
|
||||
"Создаём кластер" +
|
||||
(named ? " " + named : "") +
|
||||
". Сначала при необходимости скачивается образ, затем создаются узлы — всё в журнале ниже.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,7 +877,10 @@
|
||||
}
|
||||
currentPollJobId = null;
|
||||
createInProgress = false;
|
||||
setCreateFormDisabled(false);
|
||||
if (pollLockCreateForm) {
|
||||
setCreateFormDisabled(false);
|
||||
pollLockCreateForm = false;
|
||||
}
|
||||
showCreateProgress(false);
|
||||
}
|
||||
|
||||
@@ -789,13 +888,19 @@
|
||||
if (!currentPollJobId) return;
|
||||
try {
|
||||
await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
|
||||
showToast("Запрос отмены отправлен (между этапами)", false);
|
||||
showToast("Запрос на прерывание отправлен", false);
|
||||
} catch (e) {
|
||||
showToast(e.message || "Не удалось отменить", true);
|
||||
}
|
||||
}
|
||||
|
||||
function pollJob(jobId) {
|
||||
/**
|
||||
* @param {string} jobId
|
||||
* @param {{ lockCreateForm?: boolean }} [opts]
|
||||
*/
|
||||
function pollJob(jobId, opts) {
|
||||
opts = opts || {};
|
||||
pollLockCreateForm = !!opts.lockCreateForm;
|
||||
const details = document.getElementById("job-details");
|
||||
const msg = document.getElementById("create-msg");
|
||||
const logEl = document.getElementById("job-log-panel");
|
||||
@@ -807,7 +912,9 @@
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
createInProgress = true;
|
||||
currentPollJobId = jobId;
|
||||
setCreateFormDisabled(true);
|
||||
if (pollLockCreateForm) {
|
||||
setCreateFormDisabled(true);
|
||||
}
|
||||
showCreateProgress(true);
|
||||
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
|
||||
|
||||
@@ -817,23 +924,35 @@
|
||||
const preEl = document.getElementById("job-json");
|
||||
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
|
||||
updateCreateProgressFromJob(j);
|
||||
if (logEl && j.progress_log && j.progress_log.length) {
|
||||
logEl.textContent = j.progress_log.join("\n");
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
if (logEl) {
|
||||
if (j.progress_log && j.progress_log.length) {
|
||||
logEl.removeAttribute("data-placeholder");
|
||||
logEl.textContent = j.progress_log.join("\n");
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
} else if (j.status === "running" || j.status === "queued") {
|
||||
logEl.setAttribute("data-placeholder", "1");
|
||||
logEl.textContent =
|
||||
"Журнал подгружается… Строки скачивания и создания узлов появляются по мере вывода команды.";
|
||||
}
|
||||
}
|
||||
|
||||
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
|
||||
stopPollJob();
|
||||
setProgressHint(null);
|
||||
setProgressHint(null, "create");
|
||||
if (msg) {
|
||||
if (j.status === "success") {
|
||||
msg.textContent =
|
||||
j.kind === "start_cluster" ? "Кластер поднят по сохранённому конфигу." : "Кластер создан.";
|
||||
if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу.";
|
||||
else if (j.kind === "stop_containers") msg.textContent = "Узлы кластера остановлены.";
|
||||
else if (j.kind === "start_containers") msg.textContent = "Узлы кластера запущены.";
|
||||
else msg.textContent = "Кластер создан.";
|
||||
} else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
|
||||
else msg.textContent = "Ошибка: " + (j.message || "");
|
||||
}
|
||||
if (j.status === "success") {
|
||||
showToast(j.kind === "start_cluster" ? "Кластер запущен" : "Кластер создан", false);
|
||||
if (j.kind === "start_cluster") showToast("Кластер запущен", false);
|
||||
else if (j.kind === "stop_containers") showToast("Узлы остановлены", false);
|
||||
else if (j.kind === "start_containers") showToast("Узлы запущены", false);
|
||||
else showToast("Кластер создан", false);
|
||||
} else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
|
||||
else showToast(j.message || "Ошибка", true);
|
||||
await loadClusters();
|
||||
@@ -846,7 +965,7 @@
|
||||
}
|
||||
};
|
||||
tick();
|
||||
pollTimer = setInterval(tick, JOB_POLL_MS);
|
||||
pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
|
||||
}
|
||||
|
||||
function refreshLists() {
|
||||
@@ -866,7 +985,7 @@
|
||||
const details = document.getElementById("job-details");
|
||||
if (msg) msg.textContent = "";
|
||||
if (details) details.classList.add("hidden");
|
||||
setProgressHint(null);
|
||||
setProgressHint(null, "create");
|
||||
const fd = new FormData(form);
|
||||
const body = {
|
||||
name: String(fd.get("name") || "").trim(),
|
||||
@@ -880,7 +999,7 @@
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (msg) msg.textContent = "Задание: " + res.job_id;
|
||||
pollJob(res.job_id);
|
||||
pollJob(res.job_id, { lockCreateForm: true });
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка: " + e.message;
|
||||
showToast(e.message, true);
|
||||
@@ -895,6 +1014,13 @@
|
||||
});
|
||||
}
|
||||
|
||||
const btnClearJobs = document.getElementById("btn-jobs-clear");
|
||||
if (btnClearJobs) {
|
||||
btnClearJobs.addEventListener("click", function () {
|
||||
clearCompletedJobs();
|
||||
});
|
||||
}
|
||||
|
||||
const mClose = document.getElementById("modal-close");
|
||||
if (mClose) mClose.addEventListener("click", closeModal);
|
||||
|
||||
|
||||
+17
-1
@@ -418,7 +418,7 @@ body.modal-open {
|
||||
}
|
||||
.job-log-panel {
|
||||
margin: 0;
|
||||
max-height: min(40vh, 280px);
|
||||
max-height: min(55vh, 420px);
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
@@ -430,6 +430,16 @@ body.modal-open {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Заголовок блока заданий + кнопка очистки */
|
||||
.jobs-card-toolbar {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.jobs-card-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.git-hint {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.65rem;
|
||||
@@ -690,6 +700,12 @@ table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th .th-hint {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
aria-relevant="additions"
|
||||
></pre>
|
||||
</div>
|
||||
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить между этапами</button>
|
||||
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить</button>
|
||||
</div>
|
||||
|
||||
<p id="create-msg" class="msg" aria-live="polite"></p>
|
||||
@@ -146,12 +146,15 @@
|
||||
</section>
|
||||
|
||||
<section class="card jobs-card">
|
||||
<h2>Последние задания</h2>
|
||||
<div class="row spread jobs-card-toolbar">
|
||||
<h2 id="jobs-heading" class="jobs-card-title">Последние задания</h2>
|
||||
<button type="button" id="btn-jobs-clear" class="btn-secondary">Очистить завершённые</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl-jobs" aria-label="Последние задания создания">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время (UTC)</th>
|
||||
<th>Дата, время <span class="th-hint">(UTC)</span></th>
|
||||
<th>Кластер</th>
|
||||
<th>Статус</th>
|
||||
<th>Сообщение</th>
|
||||
|
||||
Reference in New Issue
Block a user