Панель: журнал заданий, 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
+4 -1
View File
@@ -150,7 +150,10 @@ docker compose exec kind-k8s-web kubectl --kubeconfig=/work/clusters/<имя>/ku
| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов отдавать в API/UI | | **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов отдавать в API/UI |
| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub | | **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub |
| **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах | | **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах |
| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Размер буфера строк журнала фонового задания (`kind create`) для поля `progress_log` в API/UI; по умолчанию **500** (задаётся в коде, при необходимости передайте в compose) | | **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Сколько строк журнала хранить в памяти на задание (старые вытесняются); по умолчанию **2500** |
| **`KIND_K8S_JOB_API_LOG_MAX_LINES`** | приложение | Сколько строк отдавать в **GET /api/v1/jobs/{id}** (хвост); по умолчанию **5000**, максимум **20000** |
| **`KIND_K8S_STREAM_PTY`** | приложение | **`1`** (по умолчанию) — для `kind` и **podman pull** псевдо-TTY; **`0`** — только pipe |
| **`KIND_K8S_DOCKER_PULL_PLAIN`** | приложение | **`1`** (по умолчанию) — `docker pull --progress=plain` без PTY (построчный прогресс в журнале); **`0`** — обычный pull |
| **`KIND_K8S_README_PATH`** | контейнер / приложение | Абсолютный путь к **README.md** для страницы **`/documentation`**; если пусто — используется `README.md` рядом с каталогом `app/` (в образе: `/opt/kind-k8s/README.md`) | | **`KIND_K8S_README_PATH`** | контейнер / приложение | Абсолютный путь к **README.md** для страницы **`/documentation`**; если пусто — используется `README.md` рядом с каталогом `app/` (в образе: `/opt/kind-k8s/README.md`) |
| **`KIND_K8S_WORKDIR`** | локальный запуск | Корень данных на машине разработчика без compose | | **`KIND_K8S_WORKDIR`** | локальный запуск | Корень данных на машине разработчика без compose |
| **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (то же для **`make docker rebuild`**) | | **`COMPOSE_BUILD_FLAGS`** | Makefile | Например `make docker build COMPOSE_BUILD_FLAGS=--platform linux/arm64` (то же для **`make docker rebuild`**) |
+139 -53
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
@@ -51,19 +52,31 @@ logger = logging.getLogger("kind_k8s.api.clusters")
router = APIRouter(tags=["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 с полями прогресса из потокобезопасного снимка.""" """JobRecord → JobView с полями прогресса из потокобезопасного снимка."""
prog = get_progress_sync(rec.job_id) prog = get_progress_sync(rec.job_id)
stage, pct = (None, None) stage, pct = (None, None)
if prog is not None: if prog is not None:
stage, pct = prog[0], prog[1] stage, pct = prog[0], prog[1]
if rec.status in ("queued", "running"): if not include_progress_log:
log_tail = get_logs_snapshot_sync(rec.job_id) log_tail: list[str] = []
else: else:
log_tail = list(rec.log_lines or []) if rec.status in ("queued", "running"):
max_log = 400 log_tail = get_logs_snapshot_sync(rec.job_id)
if len(log_tail) > max_log: else:
log_tail = log_tail[-max_log:] 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( return JobView(
job_id=rec.job_id, job_id=rec.job_id,
kind=rec.kind, 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]: async def list_jobs(limit: int = Query(30, ge=1, le=200, description="Сколько последних заданий")) -> list[JobView]:
"""История создания кластеров (в памяти процесса; после перезапуска контейнера пусто).""" """История создания кластеров (в памяти процесса; после перезапуска контейнера пусто)."""
items = job_store.snapshot_recent_sorted(limit=limit) 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="Список кластеров") @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: except KindClusterError as e:
msg = str(e) msg = str(e)
if "отменено" in msg.lower(): if "отмен" in msg.lower():
await job_store.set_cancelled(job_id, msg) await job_store.set_cancelled(job_id, msg)
else: else:
await job_store.set_failed(job_id, msg) 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: except KindClusterError as e:
msg = str(e) msg = str(e)
if "отменено" in msg.lower(): if "отмен" in msg.lower():
await job_store.set_cancelled(job_id, msg) await job_store.set_cancelled(job_id, msg)
else: else:
await job_store.set_failed(job_id, msg) 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) 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( @router.post(
"/clusters", "/clusters",
response_model=ClusterCreateAccepted, response_model=ClusterCreateAccepted,
@@ -346,30 +436,31 @@ async def delete_cluster(name: str) -> dict[str, object]:
@router.post( @router.post(
"/clusters/{name}/stop", "/clusters/{name}/stop",
summary="Остановить узлы кластера (docker stop)", summary="Остановить узлы кластера (фон, журнал в /jobs)",
responses={400: {"description": "Некорректное имя"}}, 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): if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock: n = name.strip()
rec = await job_store.create_job("stop_containers", cluster_name=n)
def _do() -> tuple[bool, str]: background_tasks.add_task(_run_stop_cluster_job, rec.job_id, n)
return stop_kind_cluster_containers(name=name) logger.info("Остановка узлов %s в фоне, job_id=%s", n, rec.job_id)
return JSONResponse(
try: status_code=202,
ok, summary = await asyncio.to_thread(_do) content={
except KindClusterError as e: "job_id": rec.job_id,
raise HTTPException(status_code=500, detail=str(e)) from e "status": "queued",
"mode": "stop",
logger.info("Остановка узлов %s: ok=%s", name, ok) "message": "Остановка узлов; опросите GET /api/v1/jobs/{job_id}",
return {"name": name, "containers_stopped_ok": ok, "summary": summary} },
)
@router.post( @router.post(
@@ -382,10 +473,9 @@ async def start_cluster_nodes(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
) -> JSONResponse: ) -> 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): if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера") raise HTTPException(status_code=400, detail="Некорректное имя кластера")
@@ -394,25 +484,20 @@ async def start_cluster_nodes(
async with kind_cluster_lock: async with kind_cluster_lock:
in_kind = n in await asyncio.to_thread(list_registered_kind_clusters) in_kind = n in await asyncio.to_thread(list_registered_kind_clusters)
if in_kind:
def _start() -> tuple[bool, str]: if in_kind:
return start_kind_cluster_containers(name=n) rec = await job_store.create_job("start_containers", cluster_name=n)
background_tasks.add_task(_run_start_containers_job, rec.job_id, n)
try: logger.info("Запуск контейнеров кластера %s в фоне, job_id=%s", n, rec.job_id)
ok, summary = await asyncio.to_thread(_start) return JSONResponse(
except KindClusterError as e: status_code=202,
raise HTTPException(status_code=500, detail=str(e)) from e content={
logger.info("Запуск контейнеров кластера %s: ok=%s", n, ok) "job_id": rec.job_id,
return JSONResponse( "status": "queued",
status_code=200, "mode": "containers",
content={ "message": "Запуск узлов; опросите GET /api/v1/jobs/{job_id}",
"name": n, },
"mode": "containers", )
"containers_started_ok": ok,
"summary": summary,
},
)
cfg = clusters_dir() / n / "kind-config.yaml" cfg = clusters_dir() / n / "kind-config.yaml"
if not cfg.is_file(): if not cfg.is_file():
@@ -437,22 +522,23 @@ async def start_cluster_nodes(
content={ content={
"job_id": rec.job_id, "job_id": rec.job_id,
"status": "queued", "status": "queued",
"message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}", "mode": "kind_config",
"message": "Подъём кластера по сохранённому конфигу; опросите GET /api/v1/jobs/{job_id}",
}, },
) )
@router.post( @router.post(
"/jobs/{job_id}/cancel", "/jobs/{job_id}/cancel",
summary="Запросить отмену создания кластера", summary="Прервать фоновое задание",
responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}}, responses={400: {"description": "Задание уже завершено"}, 404: {"description": "Нет задания"}},
) )
async def cancel_create_job(job_id: str) -> dict[str, object]: 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) rec = await job_store.get(job_id)
if not rec: if not rec:
@@ -465,7 +551,7 @@ async def cancel_create_job(job_id: str) -> dict[str, object]:
return { return {
"job_id": job_id, "job_id": job_id,
"cancel_requested": True, "cancel_requested": True,
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага", "message": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено",
} }
+348 -64
View File
@@ -8,6 +8,8 @@
from __future__ import annotations from __future__ import annotations
import codecs
import errno
import json import json
import logging import logging
import os 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") 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: def _rollback_after_cancel(*, cluster_name: str, out_dir: Path) -> None:
"""Удалить кластер kind и каталог данных после запроса отмены (best-effort).""" """Удалить кластер 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) 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( def _run_checked_stream(
cmd: list[str], cmd: list[str],
*, *,
cwd: Path | None = None, cwd: Path | None = None,
on_line: Callable[[str], None] | None = None, on_line: Callable[[str], None] | None = None,
job_id: str | None = None,
use_pty: bool | None = 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)) logger.info("Выполнение (поток): %s", " ".join(cmd))
p = subprocess.Popen(
cmd, master_fd: int | None = None
cwd=cwd, if use_pty is None:
stdout=subprocess.PIPE, use_pty = _stream_pty_enabled()
stderr=subprocess.STDOUT, slave_fd: int | None = None
text=True,
bufsize=1, if use_pty:
) try:
if p.stdout is None: import pty
raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1)
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: try:
for raw in p.stdout: while True:
line = raw.rstrip("\n\r") try:
if on_line and line: if master_fd is not None:
on_line(line) raw = os.read(master_fd, 4096)
if line: else:
logger.debug("stream: %s", line[:800]) 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() rc = p.wait()
finally: 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: 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: def _run_capture_checked(cmd: list[str]) -> str:
@@ -231,7 +427,6 @@ def create_cluster_non_interactive(
def _progress(stage: str, pct: int) -> None: def _progress(stage: str, pct: int) -> None:
if job_id: if job_id:
_job_store.set_progress_sync(job_id, stage, pct) _job_store.set_progress_sync(job_id, stage, pct)
_job_store.append_log_sync(job_id, f"[{pct}%] {stage}")
def _log(line: str) -> None: def _log(line: str) -> None:
if job_id: if job_id:
@@ -274,15 +469,17 @@ def create_cluster_non_interactive(
node_image = str(prev["node_image"]) node_image = str(prev["node_image"])
if prev.get("kubernetes_version_tag"): if prev.get("kubernetes_version_tag"):
ver_tag = str(prev["kubernetes_version_tag"]) ver_tag = str(prev["kubernetes_version_tag"])
_progress("Используется существующий kind-config.yaml", 10) _progress("Чтение сохранённого конфига", 10)
_log("Используется сохранённый файл конфигурации кластера.")
else: else:
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers) yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
cfg_path.write_text(yaml_text, encoding="utf-8") cfg_path.write_text(yaml_text, encoding="utf-8")
_progress("Подготовка каталога и kind-config", 12) _progress("Подготовка конфигурации", 12)
_log("Файл конфигурации кластера сохранён в каталог данных.")
if _cancelled(): if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем") raise KindClusterError("Операция отменена")
logger.info( logger.info(
"Создание кластера «%s», образ %s, workers=%s, existing_cfg=%s", "Создание кластера «%s», образ %s, workers=%s, existing_cfg=%s",
@@ -291,38 +488,54 @@ def create_cluster_non_interactive(
workers, workers,
use_existing_config, use_existing_config,
) )
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28) _progress("Скачивание образа при необходимости", 18)
_log("--- kind create cluster ---") 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( _run_checked_stream(
["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)], ["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)],
on_line=_log, on_line=_log,
job_id=job_id,
) )
if _cancelled(): if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _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 = _run_capture_checked(["kind", "get", "kubeconfig", "--name", name])
kube_path.write_text(kube, encoding="utf-8") kube_path.write_text(kube, encoding="utf-8")
if _cancelled(): if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем") raise KindClusterError("Операция отменена")
patched = False patched = False
if should_patch_after_create(): if should_patch_after_create():
_progress("Патч kubeconfig для доступа к API с хоста", 72) _progress("Настройка доступа с вашего компьютера", 72)
patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path) patched = patch_kubeconfig_server_for_host(cluster_name=name, kube_path=kube_path)
if _cancelled(): if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir) _rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем") raise KindClusterError("Операция отменена")
nodes_ready: bool | None = None nodes_ready: bool | None = None
nodes_msg: str | None = None nodes_msg: str | None = None
if _wait_nodes_enabled(): if _wait_nodes_enabled():
_progress("Ожидание готовности нод (kubectl wait …)", 82) _progress("Ожидание готовности узлов", 82)
ok, msg = wait_nodes_ready(kubeconfig_path=kube_path) ok, msg = wait_nodes_ready(kubeconfig_path=kube_path)
nodes_ready = ok nodes_ready = ok
nodes_msg = msg nodes_msg = msg
@@ -330,7 +543,7 @@ def create_cluster_non_interactive(
logger.info("Ноды готовы: %s", msg) logger.info("Ноды готовы: %s", msg)
else: else:
logger.warning("Ожидание нод не завершилось успешно: %s", msg) logger.warning("Ожидание нод не завершилось успешно: %s", msg)
_log(f"kubectl wait nodes: {msg}"[:4000]) _log(("Узлы готовы" if ok else "Ожидание узлов: ") + (msg or "")[:4000])
worker_nodes_meta = workers worker_nodes_meta = workers
if use_existing_config: 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") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
_progress("Финализация", 95) _progress("Завершение", 95)
return CreateClusterResult( return CreateClusterResult(
cluster_name=name, cluster_name=name,
@@ -434,7 +647,7 @@ def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]:
"""Имена контейнеров узлов kind (все с префиксом ``<имя>-``).""" """Имена контейнеров узлов kind (все с префиксом ``<имя>-``)."""
cli = _container_cli_bin() cli = _container_cli_bin()
if not shutil.which(cli): if not shutil.which(cli):
raise KindClusterError(f"CLI контейнеров «{cli}» не найден в PATH.", exit_code=127) raise KindClusterError("Программа для контейнеров не найдена в PATH.", exit_code=127)
p = subprocess.run( p = subprocess.run(
[cli, "ps", "-a", "--format", "{{.Names}}"], [cli, "ps", "-a", "--format", "{{.Names}}"],
capture_output=True, capture_output=True,
@@ -442,59 +655,130 @@ def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]:
) )
if p.returncode != 0: if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip() 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}-" prefix = f"{cluster_name}-"
raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()] raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()]
matched = [n for n in raw if n.startswith(prefix)] matched = [n for n in raw if n.startswith(prefix)]
return _sort_kind_node_containers(matched) 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``. Запись kind о кластере сохраняется; позже можно вызвать ``start_kind_cluster_containers``.
При ``job_id`` — потоковый вывод в журнал задания и возможность прервать текущую команду отменой.
""" """
from core import job_store as _js
names = list_kind_cluster_container_names(cluster_name=name) names = list_kind_cluster_container_names(cluster_name=name)
if not names: 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() cli = _container_cli_bin()
n = len(names)
ok_all = True ok_all = True
parts: list[str] = [] failed_idx: list[int] = []
for ctr in names:
p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True) for i, ctr in enumerate(names, start=1):
if p.returncode != 0: if _cancelled():
ok_all = False raise KindClusterError("Операция отменена", exit_code=130)
err = (p.stderr or p.stdout or "").strip() or str(p.returncode) if job_id:
parts.append(f"{ctr}: ошибка ({err})") pct = 5 + int(88 * (i - 1) / max(n, 1))
logger.warning("%s stop %s: %s", cli, ctr, err) _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: else:
parts.append(f"{ctr}: OK") p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True)
return ok_all, "; ".join(parts) 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]: def start_kind_cluster_containers(*, name: str, job_id: str | None = None) -> tuple[bool, str]:
"""Запустить контейнеры узлов kind (после ``stop`` или рестарта движка).""" """
Запустить контейнеры узлов (после остановки или перезапуска движка контейнеров).
При ``job_id`` — журнал и прогресс в задании, прерывание текущей команды через отмену задания.
"""
from core import job_store as _js
names = list_kind_cluster_container_names(cluster_name=name) names = list_kind_cluster_container_names(cluster_name=name)
if not names: if not names:
return False, ( 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() cli = _container_cli_bin()
n = len(names)
ok_all = True ok_all = True
parts: list[str] = [] failed_idx: list[int] = []
for ctr in names:
p = subprocess.run([cli, "start", ctr], capture_output=True, text=True) for i, ctr in enumerate(names, start=1):
if p.returncode != 0: if _cancelled():
ok_all = False raise KindClusterError("Операция отменена", exit_code=130)
err = (p.stderr or p.stdout or "").strip() or str(p.returncode) if job_id:
parts.append(f"{ctr}: ошибка ({err})") pct = 5 + int(88 * (i - 1) / max(n, 1))
logger.warning("%s start %s: %s", cli, ctr, err) _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: else:
parts.append(f"{ctr}: OK") p = subprocess.run([cli, "start", ctr], capture_output=True, text=True)
return ok_all, "; ".join(parts) 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: def read_meta_json(cluster_name: str) -> dict[str, object] | None:
+72 -7
View File
@@ -13,6 +13,8 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import signal
import subprocess
import threading import threading
import uuid import uuid
from collections import deque from collections import deque
@@ -33,10 +35,14 @@ _cancel_events: dict[str, threading.Event] = {}
_progress: dict[str, tuple[str, int]] = {} _progress: dict[str, tuple[str, int]] = {}
# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines # Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines
_job_log_deques: dict[str, deque[str]] = {} _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: 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: try:
return max(50, min(int(raw), 5000)) return max(50, min(int(raw), 5000))
except ValueError: except ValueError:
@@ -82,6 +88,8 @@ def begin_job_tracking(job_id: str) -> None:
def end_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: with _thread_lock:
_cancel_events.pop(job_id, None) _cancel_events.pop(job_id, None)
_progress.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) 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: def request_cancel_sync(job_id: str) -> bool:
"""Запросить отмену. Вернуть False, если job_id не отслеживается.""" """Запросить отмену и прервать активный подпроцесс (если есть)."""
with _thread_lock: with _thread_lock:
ev = _cancel_events.get(job_id) ev = _cancel_events.get(job_id)
if ev is None: if ev is None:
return False return False
ev.set() ev.set()
logger.info("Запрошена отмена задания %s", job_id) logger.info("Запрошена отмена задания %s", job_id)
return True kill_subprocess_for_job_sync(job_id)
return True
def is_cancelled_sync(job_id: str) -> bool: def is_cancelled_sync(job_id: str) -> bool:
"""Проверка из worker-thread между этапами создания кластера.""" """Проверка из worker-thread между этапами и перед каждой следующей командой."""
with _thread_lock: with _thread_lock:
ev = _cancel_events.get(job_id) ev = _cancel_events.get(job_id)
return bool(ev and ev.is_set()) return bool(ev and ev.is_set())
@@ -164,12 +205,18 @@ class JobStore:
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name) logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
return rec 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: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "running" self._jobs[job_id].status = "running"
self._jobs[job_id].message = None 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: 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) logs = take_logs_finalize_sync(job_id)
@@ -190,7 +237,7 @@ class JobStore:
self._jobs[job_id].log_lines = logs self._jobs[job_id].log_lines = logs
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) 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) logs = take_logs_finalize_sync(job_id)
async with self._lock: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
@@ -213,6 +260,24 @@ class JobStore:
items.sort(key=lambda r: r.created_at_utc, reverse=True) items.sort(key=lambda r: r.created_at_utc, reverse=True)
return items[: max(1, limit)] 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 # Синглтон на процесс uvicorn
job_store = JobStore() job_store = JobStore()
+54 -34
View File
@@ -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 /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 /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `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/stats` | Сводка для дашборда |
| GET | `/api/v1/clusters` | Список кластеров | | GET | `/api/v1/clusters` | Список кластеров |
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) | | 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}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers` или `kind_config`); журнал — `GET /jobs/{job_id}` |
| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов (`docker`/`podman` **stop**), запись в kind сохраняется | | 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}` | Детали + `kubectl get nodes` при наличии kubeconfig |
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig | | GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) | | GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| GET | `/api/v1/jobs` | Последние задания создания | | GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
| GET | `/api/v1/jobs/{job_id}` | Статус одного задания (включая `progress_stage`, `progress_percent`) | | GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
| POST | `/api/v1/jobs/{job_id}/cancel` | Запросить отмену создания (между этапами; `kind create` до конца не прерывается) | | DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
| POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) |
### Фоновые задания (jobs) ### Фоновые задания (jobs)
@@ -57,9 +58,11 @@
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
- Поле **`progress_log`** — массив последних строк журнала (вывод `kind create`: pull образов, подъём нод и т.д.); размер ограничен (см. `KIND_K8S_JOB_LOG_MAX_LINES` в коде `job_store`, по умолчанию до **500** строк в буфере, в JSON отдаётся хвост). - В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
- Тип задания **`kind`**: `create_cluster` или `start_cluster` (повторный подъём по `clusters/<имя>/kind-config.yaml`). - Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются.
- Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается. - Для **`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 ## GET /api/v1/jobs
Список последних фоновых заданий (создание кластера), от новых к старым. Список последних фоновых заданий, от новых к старым. Поле **`progress_log`** в каждом элементе **пустое** — используйте **GET /api/v1/jobs/{job_id}** для журнала.
**Query:** `limit` (1–200, по умолчанию **30**). **Query:** `limit` (1–200, по умолчанию **30**).
@@ -253,16 +256,31 @@ Accept: text/markdown
"message": "Кластер создан", "message": "Кластер создан",
"result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" }, "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" },
"progress_stage": null, "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 ## POST /api/v1/jobs/{job_id}/cancel
Запрос отмены создания кластера. Пока задание в статусе `queued` или `running`, между этапами выполняется проверка флага; после уже запущенного `kind create cluster` нужно дождаться окончания этого шага. Прервать фоновое задание (`create_cluster`, `start_cluster`, `start_containers`, `stop_containers`). Для длительной команды завершается связанный дочерний процесс; между шагами запуска/остановки отдельных узлов также проверяется флаг отмены.
**Пример ответа 200:** **Пример ответа 200:**
@@ -270,7 +288,7 @@ Accept: text/markdown
{ {
"job_id": "a1b2…", "job_id": "a1b2…",
"cancel_requested": true, "cancel_requested": true,
"message": "Отмена обрабатывается между этапами; во время kind create дождитесь окончания шага" "message": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено"
} }
``` ```
@@ -370,19 +388,19 @@ Accept: text/markdown
## POST /api/v1/clusters/{name}/start ## 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**. 1. Кластер **зарегистрирован** в kind — задание **`start_containers`** (поочерёдный запуск узлов, `mode`: **`containers`**).
2. В **kind** кластера **нет**, но в `clusters/<имя>/kind-config.yaml` файл **есть** — ставится фоновое задание **`start_cluster`** (как при создании: `kind create` по сохранённому конфигу, журнал в `GET /jobs/{job_id}`). Ответ **202** + `job_id`. 2. В kind кластера **нет**, но есть сохранённый **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**), логика как при создании (в т.ч. скачивание образа при необходимости).
**Пример ответа 200 (контейнеры запущены):** **Пример ответа 202 (запуск узлов):**
```json ```json
{ {
"name": "dev", "job_id": "cafebabe...",
"status": "queued",
"mode": "containers", "mode": "containers",
"containers_started_ok": true, "message": "Запуск узлов; опросите GET /api/v1/jobs/{job_id}"
"summary": "dev-control-plane: OK; dev-worker: OK; dev-worker2: OK"
} }
``` ```
@@ -390,9 +408,10 @@ Accept: text/markdown
```json ```json
{ {
"job_id": "cafebabe...", "job_id": "deadbeef...",
"status": "queued", "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 ## POST /api/v1/clusters/{name}/stop
Остановка **всех** контейнеров узлов кластера (`docker stop` / `podman stop` по префиксу имени). Запись кластера в kind **не удаляется**; позже можно снова вызвать **POST …/start** (режим `containers`). Остановка узлов кластера **в фоне** (задание **`stop_containers`**). Запись кластера в kind **не удаляется**; позже снова **POST …/start**.
**Пример ответа 200:** **Пример ответа 202:**
```json ```json
{ {
"name": "dev", "job_id": "baba...",
"containers_stopped_ok": true, "status": "queued",
"summary": "dev-control-plane: OK; dev-worker: OK" "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", "created_at_utc": "2026-04-04T12:00:00+00:00",
"message": null, "message": null,
"result": null, "result": null,
"progress_stage": "kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", "progress_stage": "Создание узлов кластера",
"progress_percent": 28, "progress_percent": 45,
"progress_log": [ "progress_log": [
"[12%] Подготовка каталога и kind-config", "Подготовка конфигурации",
"--- kind create cluster ---", "Using default tag: latest",
"Creating cluster \"dev\" ...", "Status: Downloaded newer image for kindest/node:v1.29.4",
" • Ensuring node image (kindest/node:v1.29.4) 🖼 ..." "Creating cluster \"dev\" ..."
] ]
} }
``` ```
@@ -454,7 +474,7 @@ Accept: text/markdown
"cluster_name": "dev", "cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00", "created_at_utc": "2026-04-04T12:00:00+00:00",
"message": "Кластер создан", "message": "Кластер создан",
"progress_log": ["[95%] Финализация", "kubectl wait nodes: ..."], "progress_log": ["Завершение", "Узлы готовы: condition met"],
"result": { "result": {
"cluster_name": "dev", "cluster_name": "dev",
"kubernetes_version_tag": "v1.29.4", "kubernetes_version_tag": "v1.29.4",
+2 -2
View File
@@ -41,11 +41,11 @@ class JobView(BaseModel):
created_at_utc: str created_at_utc: str
message: str | None = None message: str | None = None
result: dict[str, Any] | 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_percent: int | None = Field(default=None, description="Прогресс 0–100 для индикатора в UI")
progress_log: list[str] = Field( progress_log: list[str] = Field(
default_factory=list, default_factory=list,
description="Хвост лога (kind create, этапы); обновляется при опросе GET /jobs/{id}", description="Хвост журнала (скачивание образа, длительные команды, этапы); при опросе GET /jobs/{id}",
) )
+161 -35
View File
@@ -12,16 +12,18 @@
const body = document.body; const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Интервал опроса списков и среды (мс) */ /** Интервал автообновления таблиц и среды на панели (мс) */
var AUTO_REFRESH_MS = 3500; var AUTO_REFRESH_MS = 3500;
/** Интервал опроса задания создания (мс) */ /** Опрос GET /jobs/{id} для активного задания (мс) — чаще, чтобы журнал обновлялся живее */
var JOB_POLL_MS = 1500; var JOB_POLL_ACTIVE_MS = 650;
/** @type {ReturnType<typeof setInterval> | null} */ /** @type {ReturnType<typeof setInterval> | null} */
var autoTimer = null; var autoTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */ /** @type {ReturnType<typeof setInterval> | null} */
var pollTimer = null; var pollTimer = null;
var createInProgress = false; var createInProgress = false;
/** Блокировать форму «Создать» только при создании из формы, не при старт/стоп из таблицы. */
var pollLockCreateForm = false;
/** @type {string | null} */ /** @type {string | null} */
var currentPollJobId = null; var currentPollJobId = null;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */ /** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
@@ -69,6 +71,32 @@
return d.innerHTML; 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). * Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
* @param {string} clusterName * @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() { async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody"); const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg"); const msg = document.getElementById("jobs-msg");
@@ -503,7 +568,10 @@
rows.forEach(function (j) { rows.forEach(function (j) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const st = escapeHtml(j.status || ""); 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); var cellMsg = kindTag + (j.message || "").slice(0, 140);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) { if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
cellMsg = cellMsg =
@@ -511,11 +579,15 @@
j.progress_stage + j.progress_stage +
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
} }
const iso = j.created_at_utc || "";
const timeLabel = formatJobTimeUtc(iso);
tr.innerHTML = tr.innerHTML =
"<td><time datetime=\"" + "<td><time datetime=\"" +
escapeHtml(j.created_at_utc || "") + escapeHtml(iso) +
"\" title=\"" +
escapeHtml(iso ? "UTC: " + iso : "") +
"\">" + "\">" +
escapeHtml(j.created_at_utc || "") + escapeHtml(timeLabel) +
"</time></td>" + "</time></td>" +
"<td>" + "<td>" +
escapeHtml(j.cluster_name || "—") + escapeHtml(j.cluster_name || "—") +
@@ -644,14 +716,31 @@
message: message:
"Кластер «" + "Кластер «" +
name + name +
"»: контейнеры будут остановлены (docker/podman stop). Запись в kind сохранится — позже можно снова нажать «Старт».", "»: узлы будут остановлены. Сведения о кластере сохранятся — позже можно снова нажать «Старт».",
confirmLabel: "Остановить", confirmLabel: "Остановить",
danger: false, danger: false,
}); });
if (!ok) return; if (!ok) return;
const url = API + "/clusters/" + encodeURIComponent(name) + "/stop";
try { try {
const res = await api("/clusters/" + encodeURIComponent(name) + "/stop", { method: "POST" }); const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
showToast(String(res.summary || "Узлы остановлены"), false); 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 loadClusters();
await loadStats(); await loadStats();
await loadHealth(); await loadHealth();
@@ -674,8 +763,10 @@
data = {}; data = {};
} }
if (r.status === 202 && data.job_id) { if (r.status === 202 && data.job_id) {
setProgressHint(name); var sm = data.mode || "kind_config";
pollJob(data.job_id); if (sm === "containers") setProgressHint(name, "start_containers");
else setProgressHint(name, "start_config");
pollJob(data.job_id, { lockCreateForm: false });
return; return;
} }
if (!r.ok) { if (!r.ok) {
@@ -741,21 +832,26 @@
} }
/** /**
* Обновить текст подсказки над прогрессом (создание с нуля или старт по конфигу). * Подсказка над прогресс-баром (без технических имён команд).
* @param {string | null} clusterName * @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"); const hint = document.getElementById("create-progress-hint");
if (!hint) return; if (!hint) return;
if (clusterName) { mode = mode || "create";
hint.innerHTML = var named = clusterName ? "«" + clusterName + "»" : "";
"Кластер <code>" + if (mode === "stop") {
escapeHtml(clusterName) + hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже.";
"</code>: подъём по конфигу или длительный <code>kind create</code> — ниже журнал (pull образов и ноды)."; } else if (mode === "start_containers") {
hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже.";
} else if (mode === "start_config") {
hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже.";
} else { } else {
hint.innerHTML = hint.textContent =
"Создание кластера: шаг <code>kind create</code> может занять несколько минут при первом pull образов. " + "Создаём кластер" +
"Ниже — журнал в реальном времени."; (named ? " " + named : "") +
". Сначала при необходимости скачивается образ, затем создаются узлы — всё в журнале ниже.";
} }
} }
@@ -781,7 +877,10 @@
} }
currentPollJobId = null; currentPollJobId = null;
createInProgress = false; createInProgress = false;
setCreateFormDisabled(false); if (pollLockCreateForm) {
setCreateFormDisabled(false);
pollLockCreateForm = false;
}
showCreateProgress(false); showCreateProgress(false);
} }
@@ -789,13 +888,19 @@
if (!currentPollJobId) return; if (!currentPollJobId) return;
try { try {
await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" }); await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
showToast("Запрос отмены отправлен (между этапами)", false); showToast("Запрос на прерывание отправлен", false);
} catch (e) { } catch (e) {
showToast(e.message || "Не удалось отменить", true); 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 details = document.getElementById("job-details");
const msg = document.getElementById("create-msg"); const msg = document.getElementById("create-msg");
const logEl = document.getElementById("job-log-panel"); const logEl = document.getElementById("job-log-panel");
@@ -807,7 +912,9 @@
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
createInProgress = true; createInProgress = true;
currentPollJobId = jobId; currentPollJobId = jobId;
setCreateFormDisabled(true); if (pollLockCreateForm) {
setCreateFormDisabled(true);
}
showCreateProgress(true); showCreateProgress(true);
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" }); updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
@@ -817,23 +924,35 @@
const preEl = document.getElementById("job-json"); const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2); if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
updateCreateProgressFromJob(j); updateCreateProgressFromJob(j);
if (logEl && j.progress_log && j.progress_log.length) { if (logEl) {
logEl.textContent = j.progress_log.join("\n"); if (j.progress_log && j.progress_log.length) {
logEl.scrollTop = logEl.scrollHeight; 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") { if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob(); stopPollJob();
setProgressHint(null); setProgressHint(null, "create");
if (msg) { if (msg) {
if (j.status === "success") { if (j.status === "success") {
msg.textContent = if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу.";
j.kind === "start_cluster" ? "Кластер поднят по сохранённому конфигу." : "Кластер создан."; 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 if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
else msg.textContent = "Ошибка: " + (j.message || ""); else msg.textContent = "Ошибка: " + (j.message || "");
} }
if (j.status === "success") { 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 if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка", true); else showToast(j.message || "Ошибка", true);
await loadClusters(); await loadClusters();
@@ -846,7 +965,7 @@
} }
}; };
tick(); tick();
pollTimer = setInterval(tick, JOB_POLL_MS); pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
} }
function refreshLists() { function refreshLists() {
@@ -866,7 +985,7 @@
const details = document.getElementById("job-details"); const details = document.getElementById("job-details");
if (msg) msg.textContent = ""; if (msg) msg.textContent = "";
if (details) details.classList.add("hidden"); if (details) details.classList.add("hidden");
setProgressHint(null); setProgressHint(null, "create");
const fd = new FormData(form); const fd = new FormData(form);
const body = { const body = {
name: String(fd.get("name") || "").trim(), name: String(fd.get("name") || "").trim(),
@@ -880,7 +999,7 @@
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (msg) msg.textContent = "Задание: " + res.job_id; if (msg) msg.textContent = "Задание: " + res.job_id;
pollJob(res.job_id); pollJob(res.job_id, { lockCreateForm: true });
} catch (e) { } catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message; if (msg) msg.textContent = "Ошибка: " + e.message;
showToast(e.message, true); 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"); const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal); if (mClose) mClose.addEventListener("click", closeModal);
+17 -1
View File
@@ -418,7 +418,7 @@ body.modal-open {
} }
.job-log-panel { .job-log-panel {
margin: 0; margin: 0;
max-height: min(40vh, 280px); max-height: min(55vh, 420px);
overflow: auto; overflow: auto;
padding: 0.5rem 0.65rem; padding: 0.5rem 0.65rem;
font-size: 0.78rem; font-size: 0.78rem;
@@ -430,6 +430,16 @@ body.modal-open {
word-break: break-word; 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 { .git-hint {
font-size: 0.85rem; font-size: 0.85rem;
margin: 0 0 0.65rem; margin: 0 0 0.65rem;
@@ -690,6 +700,12 @@ table {
font-size: 0.9rem; font-size: 0.9rem;
} }
th .th-hint {
font-weight: 500;
opacity: 0.7;
font-size: 0.82em;
}
th, th,
td { td {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
+6 -3
View File
@@ -111,7 +111,7 @@
aria-relevant="additions" aria-relevant="additions"
></pre> ></pre>
</div> </div>
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить между этапами</button> <button type="button" id="btn-cancel-create" class="btn-secondary">Отменить</button>
</div> </div>
<p id="create-msg" class="msg" aria-live="polite"></p> <p id="create-msg" class="msg" aria-live="polite"></p>
@@ -146,12 +146,15 @@
</section> </section>
<section class="card jobs-card"> <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"> <div class="table-wrap">
<table id="tbl-jobs" aria-label="Последние задания создания"> <table id="tbl-jobs" aria-label="Последние задания создания">
<thead> <thead>
<tr> <tr>
<th>Время (UTC)</th> <th>Дата, время <span class="th-hint">(UTC)</span></th>
<th>Кластер</th> <th>Кластер</th>
<th>Статус</th> <th>Статус</th>
<th>Сообщение</th> <th>Сообщение</th>
+5
View File
@@ -40,6 +40,11 @@ services:
KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-} KIND_K8S_HUB_TAGS_MAX_PAGES: ${KIND_K8S_HUB_TAGS_MAX_PAGES:-}
KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-} KIND_K8S_DEBUG: ${KIND_K8S_DEBUG:-}
KIND_K8S_JOB_LOG_MAX_LINES: ${KIND_K8S_JOB_LOG_MAX_LINES:-} KIND_K8S_JOB_LOG_MAX_LINES: ${KIND_K8S_JOB_LOG_MAX_LINES:-}
# Псевдо-TTY для потоковых команд (0 = pipe). Для docker pull по умолчанию используется --progress=plain без PTY.
KIND_K8S_STREAM_PTY: ${KIND_K8S_STREAM_PTY:-}
KIND_K8S_DOCKER_PULL_PLAIN: ${KIND_K8S_DOCKER_PULL_PLAIN:-}
# Сколько строк журнала отдавать в GET /api/v1/jobs/{id} (по умолчанию 5000 в коде).
KIND_K8S_JOB_API_LOG_MAX_LINES: ${KIND_K8S_JOB_API_LOG_MAX_LINES:-}
KIND_K8S_README_PATH: ${KIND_K8S_README_PATH:-} KIND_K8S_README_PATH: ${KIND_K8S_README_PATH:-}
KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-} KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-}
KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-} KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-}