Веб-UI: темы, навигация API, статистика и доработки API
- Шапка: логотип Kubernetes, ссылка на главную, выпадающее меню API (Swagger/ReDoc/Health), переключатель светлой/тёмной темы (localStorage). - Светлая тема в синей гамме; выравнивание кнопки темы в ряду с пилюлями. - Дашборд: единая карточка ошибки health/stats, подсказка Docker/Podman, поле container_cli в GET /stats, total_workers_from_meta всегда число (0 без meta). - Правки кластеров, job_store, compose, документация и частичные шаблоны.
This commit is contained in:
@@ -295,14 +295,49 @@ def _run_checked_stream(
|
||||
|
||||
def _docker_pull_plain_progress_enabled() -> bool:
|
||||
"""
|
||||
``docker pull --progress plain`` — построчный текст без TTY-ANSI; удобно с pipe (без PTY).
|
||||
Разрешить попытку ``docker pull --progress=plain`` (если CLI это поддерживает).
|
||||
|
||||
Отключить: ``KIND_K8S_DOCKER_PULL_PLAIN=0`` (если старая версия docker не знает флаг).
|
||||
Полностью отключить: ``KIND_K8S_DOCKER_PULL_PLAIN=0``.
|
||||
"""
|
||||
v = (os.environ.get("KIND_K8S_DOCKER_PULL_PLAIN") or "1").strip().lower()
|
||||
return v not in ("0", "false", "no", "off", "нет")
|
||||
|
||||
|
||||
# Кэш: есть ли в выводе ``docker pull --help`` опция ``--progress`` (в старых CLI её нет).
|
||||
_docker_pull_help_has_progress: bool | None = None
|
||||
|
||||
|
||||
def _docker_pull_cli_supports_progress_flag() -> bool:
|
||||
"""
|
||||
Узнать по справке, объявляет ли данный ``docker`` флаг ``--progress`` для ``pull``.
|
||||
|
||||
Без этого на старых клиентах в журнал попадало «unknown flag: --progress», а код выхода часто **1**,
|
||||
а не 125 — повтор без флага не срабатывал.
|
||||
"""
|
||||
global _docker_pull_help_has_progress
|
||||
if _docker_pull_help_has_progress is not None:
|
||||
return _docker_pull_help_has_progress
|
||||
exe = shutil.which("docker")
|
||||
if not exe:
|
||||
_docker_pull_help_has_progress = False
|
||||
return False
|
||||
try:
|
||||
p = subprocess.run(
|
||||
[exe, "pull", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=12,
|
||||
)
|
||||
blob = (p.stdout or "") + (p.stderr or "")
|
||||
_docker_pull_help_has_progress = "--progress" in blob
|
||||
if not _docker_pull_help_has_progress:
|
||||
logger.info("docker pull: в справке нет --progress — используем обычный pull (PTY по KIND_K8S_STREAM_PTY)")
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
logger.warning("Не удалось выполнить docker pull --help (%s), без флага --progress", e)
|
||||
_docker_pull_help_has_progress = False
|
||||
return _docker_pull_help_has_progress
|
||||
|
||||
|
||||
def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], job_id: str | None) -> None:
|
||||
"""
|
||||
Скачать образ узлов через ``docker pull`` / ``podman pull`` с выводом в журнал задания.
|
||||
@@ -316,7 +351,12 @@ def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], jo
|
||||
"Скачивание образа недоступно: не найдена программа для работы с контейнерами (docker/podman).",
|
||||
exit_code=127,
|
||||
)
|
||||
if cli == "docker" and _docker_pull_plain_progress_enabled():
|
||||
use_docker_plain = (
|
||||
cli == "docker"
|
||||
and _docker_pull_plain_progress_enabled()
|
||||
and _docker_pull_cli_supports_progress_flag()
|
||||
)
|
||||
if use_docker_plain:
|
||||
try:
|
||||
_run_checked_stream(
|
||||
[cli, "pull", "--progress=plain", image],
|
||||
@@ -326,16 +366,12 @@ def _pull_node_image_streaming(*, image: str, on_line: Callable[[str], None], jo
|
||||
)
|
||||
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
|
||||
if e.exit_code == 130:
|
||||
raise
|
||||
# На всякий случай, если справка устарела или другой код ошибки.
|
||||
logger.warning("Повтор docker pull без --progress=plain (код %s): %s", e.exit_code, e)
|
||||
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
||||
return
|
||||
_run_checked_stream([cli, "pull", image], on_line=on_line, job_id=job_id, use_pty=None)
|
||||
|
||||
|
||||
@@ -800,6 +836,14 @@ def _container_cli_bin() -> str:
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
|
||||
|
||||
def configured_container_cli() -> str:
|
||||
"""Активное имя CLI из ``CONTAINER_CLI`` (docker/podman).
|
||||
|
||||
Единый источник для проверки движка, сбора ``docker stats``/``podman stats`` и поля ``container_cli`` в GET /stats.
|
||||
"""
|
||||
return _container_cli_bin()
|
||||
|
||||
|
||||
def container_engine_ping(*, timeout_sec: float = 12.0) -> tuple[bool, str, str]:
|
||||
"""
|
||||
Проверить доступ к движку контейнеров (``docker info`` / ``podman info``).
|
||||
|
||||
@@ -13,8 +13,9 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters
|
||||
from models.schemas import KindClusterResources, KindNodeResourceStat
|
||||
from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat
|
||||
|
||||
logger = logging.getLogger("kind_k8s.container_resource_stats")
|
||||
|
||||
@@ -42,6 +43,25 @@ def _list_running_container_names(cli: str) -> set[str]:
|
||||
return {n.strip() for n in (p.stdout or "").splitlines() if n.strip()}
|
||||
|
||||
|
||||
def running_kind_clusters_mask(cluster_names: list[str]) -> dict[str, bool]:
|
||||
"""
|
||||
Один вызов ``<cli> ps``: для каждого имени кластера — есть ли запущенный контейнер-узел
|
||||
с префиксом ``{имя}-`` (как в ``collect_kind_clusters_resource_stats``).
|
||||
"""
|
||||
out: dict[str, bool] = {n: False for n in cluster_names}
|
||||
if not cluster_names:
|
||||
return out
|
||||
cli = _container_cli_bin()
|
||||
if not shutil.which(cli):
|
||||
logger.debug("running_kind_clusters_mask: CLI «%s» не в PATH", cli)
|
||||
return out
|
||||
running = _list_running_container_names(cli)
|
||||
for name in cluster_names:
|
||||
pfx = f"{name}-"
|
||||
out[name] = any(c.startswith(pfx) for c in running)
|
||||
return out
|
||||
|
||||
|
||||
def _all_container_names_ps_a(cli: str) -> tuple[list[str], str | None]:
|
||||
"""Все имена контейнеров (``ps -a``). При ошибке — ([], сообщение)."""
|
||||
p = subprocess.run(
|
||||
@@ -67,6 +87,164 @@ def _parse_pids(raw: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
# Множители для суффиксов Docker/Podman (kB/MB — десятичные, KiB/MiB — двоичные).
|
||||
_SUFFIX_TO_BYTES: dict[str, float] = {
|
||||
"b": 1.0,
|
||||
"kb": 1e3,
|
||||
"kib": 1024.0,
|
||||
"mb": 1e6,
|
||||
"mib": 1024.0**2,
|
||||
"gb": 1e9,
|
||||
"gib": 1024.0**3,
|
||||
"tb": 1e12,
|
||||
"tib": 1024.0**4,
|
||||
"pb": 1e15,
|
||||
"pib": 1024.0**5,
|
||||
}
|
||||
|
||||
|
||||
def _parse_docker_size_bytes(chunk: str) -> float | None:
|
||||
"""Разбор одного значения вроде ``512MiB``, ``7.7GiB``, ``800kB`` в байты."""
|
||||
t = chunk.strip().lower().replace(" ", "")
|
||||
if not t:
|
||||
return None
|
||||
m = re.match(r"^([\d.]+)([a-z]+)$", t)
|
||||
if not m:
|
||||
return None
|
||||
val = float(m.group(1))
|
||||
suf = m.group(2)
|
||||
mult = _SUFFIX_TO_BYTES.get(suf)
|
||||
if mult is None:
|
||||
return None
|
||||
return val * mult
|
||||
|
||||
|
||||
def _parse_percent_value(raw: str | None) -> float | None:
|
||||
"""Число из строки вида ``12.34%``."""
|
||||
if not raw:
|
||||
return None
|
||||
m = re.match(r"^\s*([\d.]+)\s*%", raw.strip())
|
||||
if not m:
|
||||
return None
|
||||
return float(m.group(1))
|
||||
|
||||
|
||||
def _memory_used_ratio_percent(memory_usage: str | None) -> float | None:
|
||||
"""Процент занятой памяти: левая часть MemUsage / правая (лимит контейнера)."""
|
||||
if not memory_usage or "/" not in memory_usage:
|
||||
return None
|
||||
left, right = memory_usage.split("/", 1)
|
||||
used = _parse_docker_size_bytes(left.strip())
|
||||
limit = _parse_docker_size_bytes(right.strip())
|
||||
if used is None or limit is None or limit <= 0:
|
||||
return None
|
||||
return min(100.0, 100.0 * used / limit)
|
||||
|
||||
|
||||
def _parse_io_pair_sum_bytes(raw: str | None) -> float | None:
|
||||
"""Сумма байт по обеим частям NetIO/BlockIO до и после ``/`` (чтение + запись)."""
|
||||
if not raw or "/" not in raw:
|
||||
return None
|
||||
a, b = raw.split("/", 1)
|
||||
ba = _parse_docker_size_bytes(a.strip())
|
||||
bb = _parse_docker_size_bytes(b.strip())
|
||||
if ba is None and bb is None:
|
||||
return None
|
||||
return (ba or 0.0) + (bb or 0.0)
|
||||
|
||||
|
||||
def _fmt_bytes_ru(n: float) -> str:
|
||||
"""Краткая подпись объёма для UI."""
|
||||
if n <= 0:
|
||||
return "0 B"
|
||||
if n < 1024:
|
||||
return f"{n:.0f} B"
|
||||
for thresh, suffix in (
|
||||
(1024.0**4, "TiB"),
|
||||
(1024.0**3, "GiB"),
|
||||
(1024.0**2, "MiB"),
|
||||
(1024.0, "KiB"),
|
||||
):
|
||||
if n >= thresh:
|
||||
v = n / thresh
|
||||
s = f"{v:.2f}".rstrip("0").rstrip(".")
|
||||
return f"{s} {suffix}"
|
||||
return f"{n:.0f} B"
|
||||
|
||||
|
||||
def _io_ring_percent(total_bytes: float, ref_bytes: float) -> float:
|
||||
"""Нормализация суммарного I/O в 0–100 для кольца (ref ≈ «полное кольцо»)."""
|
||||
if total_bytes <= 0 or ref_bytes <= 0:
|
||||
return 0.0
|
||||
return min(100.0, 100.0 * total_bytes / ref_bytes)
|
||||
|
||||
|
||||
def aggregate_kind_cluster_resources(blocks: list[KindClusterResources]) -> AggregateResourcesSummary:
|
||||
"""
|
||||
Средние и суммы по всем узлам из ``cluster_resources`` для донатов на главной.
|
||||
|
||||
Кольца сети/диска — условная шкала относительно порога (8 GiB суммарного I/O ≈ 100%).
|
||||
"""
|
||||
nodes: list[KindNodeResourceStat] = []
|
||||
for b in blocks:
|
||||
nodes.extend(b.nodes)
|
||||
n = len(nodes)
|
||||
if n == 0:
|
||||
logger.debug("aggregate_kind_cluster_resources: узлов нет")
|
||||
return AggregateResourcesSummary()
|
||||
|
||||
cpus = [_parse_percent_value(x.cpu_percent) for x in nodes]
|
||||
mem_pcts = [_parse_percent_value(x.memory_percent) for x in nodes]
|
||||
mem_ratios = [_memory_used_ratio_percent(x.memory_usage) for x in nodes]
|
||||
nets = [_parse_io_pair_sum_bytes(x.net_io) for x in nodes]
|
||||
blks = [_parse_io_pair_sum_bytes(x.block_io) for x in nodes]
|
||||
|
||||
def avg(vals: list[float | None]) -> float | None:
|
||||
xs = [v for v in vals if v is not None]
|
||||
if not xs:
|
||||
return None
|
||||
return sum(xs) / len(xs)
|
||||
|
||||
av_cpu = avg(cpus)
|
||||
av_mem_pct = avg(mem_pcts)
|
||||
av_mem_ratio = avg(mem_ratios)
|
||||
sum_net = sum(v for v in nets if v is not None)
|
||||
sum_blk = sum(v for v in blks if v is not None)
|
||||
|
||||
ref_io = 8.0 * 1024.0**3
|
||||
cpu_ring = min(100.0, av_cpu) if av_cpu is not None else 0.0
|
||||
mem_ring = min(100.0, av_mem_pct) if av_mem_pct is not None else 0.0
|
||||
ratio_ring = min(100.0, av_mem_ratio) if av_mem_ratio is not None else 0.0
|
||||
net_ring = _io_ring_percent(sum_net, ref_io)
|
||||
disk_ring = _io_ring_percent(sum_blk, ref_io)
|
||||
|
||||
cpu_l = f"ср. {av_cpu:.1f}%" if av_cpu is not None else "—"
|
||||
mem_l = f"ср. {av_mem_pct:.1f}%" if av_mem_pct is not None else "—"
|
||||
ratio_l = f"ср. {av_mem_ratio:.1f}%" if av_mem_ratio is not None else "—"
|
||||
net_l = f"Σ {_fmt_bytes_ru(sum_net)}" if sum_net > 0 else "—"
|
||||
disk_l = f"Σ {_fmt_bytes_ru(sum_blk)}" if sum_blk > 0 else "—"
|
||||
|
||||
logger.debug(
|
||||
"aggregate_kind_cluster_resources: узлов=%s cpu_ring=%.1f net_ring=%.1f",
|
||||
n,
|
||||
cpu_ring,
|
||||
net_ring,
|
||||
)
|
||||
return AggregateResourcesSummary(
|
||||
nodes_count=n,
|
||||
cpu_ring=cpu_ring,
|
||||
cpu_label=cpu_l,
|
||||
memory_percent_ring=mem_ring,
|
||||
memory_percent_label=mem_l,
|
||||
memory_used_ratio_ring=ratio_ring,
|
||||
memory_used_ratio_label=ratio_l,
|
||||
network_ring=net_ring,
|
||||
network_label=net_l,
|
||||
disk_ring=disk_ring,
|
||||
disk_label=disk_l,
|
||||
)
|
||||
|
||||
|
||||
def _stats_for_container_names(cli: str, names: list[str]) -> list[KindNodeResourceStat]:
|
||||
"""Вызов ``<cli> stats --no-stream`` для списка имён (только запущенные)."""
|
||||
if not names:
|
||||
|
||||
+158
-6
@@ -1,6 +1,8 @@
|
||||
"""Хранилище фоновых заданий (создание кластера) в памяти процесса.
|
||||
"""Хранилище фоновых заданий (создание/старт/стоп кластера).
|
||||
|
||||
При перезапуске контейнера история заданий обнуляется — это ожидаемо для dev-среды.
|
||||
Снимок заданий сохраняется в JSON в каталоге ``clusters/`` (том на хосте), чтобы после
|
||||
перезапуска контейнера история не терялась. Записи ``queued``/``running`` при старте
|
||||
помечаются как ``failed`` — рабочий процесс уже не существует.
|
||||
|
||||
Потокобезопасные флаги отмены и прогресс (для worker-thread) — через ``threading.Lock``.
|
||||
|
||||
@@ -11,6 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
@@ -20,13 +23,19 @@ import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from kind_k8s_paths import clusters_dir
|
||||
|
||||
logger = logging.getLogger("kind_k8s.job_store")
|
||||
|
||||
# Лимит записей в памяти (dev-инструмент; старые задания вытесняются)
|
||||
# Лимит записей в памяти и в файле (dev-инструмент; старые задания вытесняются)
|
||||
_MAX_JOBS = 200
|
||||
|
||||
# Версия формата JSON на диске
|
||||
_JOBS_FILE_VERSION = 1
|
||||
|
||||
JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||
|
||||
# --- Синхронное сопровождение задания (worker-thread и HTTP отмена) ---
|
||||
@@ -40,6 +49,19 @@ _process_lock = threading.Lock()
|
||||
_active_subprocess_by_job: dict[str, subprocess.Popen] = {}
|
||||
|
||||
|
||||
def _jobs_file_path() -> Path:
|
||||
"""
|
||||
Путь к JSON с историей заданий.
|
||||
|
||||
Переопределение: ``KIND_K8S_JOBS_JSON`` (абсолютный или относительный путь).
|
||||
По умолчанию: ``<clusters_dir>/kind_k8s_jobs.json``.
|
||||
"""
|
||||
override = (os.environ.get("KIND_K8S_JOBS_JSON") or "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return clusters_dir() / "kind_k8s_jobs.json"
|
||||
|
||||
|
||||
def _max_job_log_lines() -> int:
|
||||
# Буфер в памяти: старые строки вытесняются; по умолчанию 2500 — виден почти весь типичный лог.
|
||||
raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "2500").strip()
|
||||
@@ -176,12 +198,135 @@ class JobRecord:
|
||||
log_lines: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _record_to_dict(rec: JobRecord) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": rec.job_id,
|
||||
"kind": rec.kind,
|
||||
"status": rec.status,
|
||||
"cluster_name": rec.cluster_name,
|
||||
"created_at_utc": rec.created_at_utc,
|
||||
"message": rec.message,
|
||||
"result": rec.result,
|
||||
"log_lines": list(rec.log_lines),
|
||||
}
|
||||
|
||||
|
||||
def _dict_to_record(d: Any) -> JobRecord | None:
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
jid = d.get("job_id")
|
||||
kind = d.get("kind")
|
||||
status = d.get("status")
|
||||
if not isinstance(jid, str) or not jid or not isinstance(kind, str) or not kind.strip():
|
||||
return None
|
||||
if status not in ("queued", "running", "success", "failed", "cancelled"):
|
||||
return None
|
||||
created = d.get("created_at_utc")
|
||||
if not isinstance(created, str) or not created:
|
||||
return None
|
||||
cn = d.get("cluster_name")
|
||||
if cn is None:
|
||||
cluster_name = None
|
||||
elif isinstance(cn, str):
|
||||
cluster_name = cn
|
||||
else:
|
||||
cluster_name = str(cn)
|
||||
msg = d.get("message")
|
||||
if msg is None:
|
||||
message = None
|
||||
elif isinstance(msg, str):
|
||||
message = msg
|
||||
else:
|
||||
message = str(msg)
|
||||
result = d.get("result")
|
||||
if result is not None and not isinstance(result, dict):
|
||||
result = None
|
||||
lines = d.get("log_lines")
|
||||
log_lines: list[str] = []
|
||||
if isinstance(lines, list):
|
||||
log_lines = [str(x) for x in lines]
|
||||
return JobRecord(
|
||||
job_id=jid,
|
||||
kind=kind,
|
||||
status=cast(JobStatus, status),
|
||||
cluster_name=cluster_name,
|
||||
created_at_utc=created,
|
||||
message=message,
|
||||
result=result,
|
||||
log_lines=log_lines,
|
||||
)
|
||||
|
||||
|
||||
def _atomic_write_jobs_file(path: Path, jobs: list[dict[str, Any]]) -> None:
|
||||
"""Запись JSON атомарно (temp + replace)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"version": _JOBS_FILE_VERSION, "jobs": jobs}
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
class JobStore:
|
||||
"""Потокобезопасное (asyncio) хранилище заданий."""
|
||||
"""Потокобезопасное (asyncio) хранилище заданий с персистентностью в JSON."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._jobs: dict[str, JobRecord] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._load_from_disk_sync()
|
||||
|
||||
def _load_from_disk_sync(self) -> None:
|
||||
path = _jobs_file_path()
|
||||
if not path.is_file():
|
||||
logger.debug("Файл истории заданий отсутствует: %s", path)
|
||||
return
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Не удалось прочитать историю заданий %s: %s", path, e)
|
||||
return
|
||||
jobs_raw = data.get("jobs") if isinstance(data, dict) else data
|
||||
if not isinstance(jobs_raw, list):
|
||||
logger.warning("Некорректный формат истории заданий (ожидался ключ jobs или массив)")
|
||||
return
|
||||
|
||||
interrupt_msg = "Прервано перезапуском сервиса (задание не было завершено)."
|
||||
loaded: list[JobRecord] = []
|
||||
for item in jobs_raw:
|
||||
rec = _dict_to_record(item)
|
||||
if rec is None:
|
||||
continue
|
||||
if rec.status in ("queued", "running"):
|
||||
rec = JobRecord(
|
||||
job_id=rec.job_id,
|
||||
kind=rec.kind,
|
||||
status="failed",
|
||||
cluster_name=rec.cluster_name,
|
||||
created_at_utc=rec.created_at_utc,
|
||||
message=interrupt_msg,
|
||||
result=rec.result,
|
||||
log_lines=rec.log_lines,
|
||||
)
|
||||
loaded.append(rec)
|
||||
|
||||
loaded.sort(key=lambda r: r.created_at_utc)
|
||||
if len(loaded) > _MAX_JOBS:
|
||||
loaded = loaded[-_MAX_JOBS:]
|
||||
self._jobs = {r.job_id: r for r in loaded}
|
||||
logger.info("Восстановлено заданий из %s: %s", path, len(self._jobs))
|
||||
|
||||
async def _persist_async(self) -> None:
|
||||
"""Сохранить текущий снимок заданий на диск (вне удержания lock во время записи)."""
|
||||
async with self._lock:
|
||||
items = sorted(self._jobs.values(), key=lambda r: r.created_at_utc)
|
||||
snapshot = [_record_to_dict(r) for r in items]
|
||||
path = _jobs_file_path()
|
||||
try:
|
||||
await asyncio.to_thread(_atomic_write_jobs_file, path, snapshot)
|
||||
logger.debug("История заданий записана: %s (%s записей)", path, len(snapshot))
|
||||
except OSError as e:
|
||||
logger.error("Не удалось сохранить историю заданий в %s: %s", path, e)
|
||||
|
||||
async def create_job(self, kind: str, *, cluster_name: str | None) -> JobRecord:
|
||||
"""Зарегистрировать задание в статусе ``queued``."""
|
||||
@@ -203,6 +348,7 @@ class JobStore:
|
||||
logger.debug("Вытеснено старое задание из хранилища: %s", oldest_id)
|
||||
begin_job_tracking(jid)
|
||||
logger.info("Создано задание %s kind=%s cluster=%s", jid, kind, cluster_name)
|
||||
await self._persist_async()
|
||||
return rec
|
||||
|
||||
async def set_running(
|
||||
@@ -217,6 +363,7 @@ class JobStore:
|
||||
self._jobs[job_id].status = "running"
|
||||
self._jobs[job_id].message = None
|
||||
set_progress_sync(job_id, stage, percent)
|
||||
await self._persist_async()
|
||||
|
||||
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)
|
||||
@@ -227,6 +374,7 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
self._jobs[job_id].log_lines = logs
|
||||
set_progress_sync(job_id, "Готово", 100)
|
||||
await self._persist_async()
|
||||
|
||||
async def set_failed(self, job_id: str, message: str) -> None:
|
||||
logs = take_logs_finalize_sync(job_id)
|
||||
@@ -236,6 +384,7 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
self._jobs[job_id].log_lines = logs
|
||||
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
|
||||
await self._persist_async()
|
||||
|
||||
async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None:
|
||||
logs = take_logs_finalize_sync(job_id)
|
||||
@@ -245,6 +394,7 @@ class JobStore:
|
||||
self._jobs[job_id].message = message
|
||||
self._jobs[job_id].log_lines = logs
|
||||
logger.info("Задание %s отменено: %s", job_id, message)
|
||||
await self._persist_async()
|
||||
|
||||
async def get(self, job_id: str) -> JobRecord | None:
|
||||
async with self._lock:
|
||||
@@ -276,7 +426,9 @@ class JobStore:
|
||||
del self._jobs[jid]
|
||||
for jid in to_remove:
|
||||
end_job_tracking(jid)
|
||||
return len(to_remove)
|
||||
n = len(to_remove)
|
||||
await self._persist_async()
|
||||
return n
|
||||
|
||||
|
||||
# Синглтон на процесс uvicorn
|
||||
|
||||
Reference in New Issue
Block a user