Журнал: пагинация 30 записей на страницу, API offset/total_pages
- job_journal: collect_recent_journal_entries_page_sync(limit, offset) - GET /api/v1/journal/recent: limit по умолчанию 30, offset, total, page, total_pages - journal.html/js: навигация Первая/Назад/номера/Вперёд/Последняя, стили - app/docs/api_routes.md: описание query и пример ответа - Прочие изменения UI/API (аддоны, helm, job_journal в кластерах) в том же коммите
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
"""История завершённых заданий (create/start/stop и т.д.) в ``clusters/<имя>/journal/jobs_history.json``.
|
||||
|
||||
Дополняет глобальный ``kind_k8s_jobs.json``: записи дублируются в каталог кластера для архива на томе.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.cluster_lifecycle import validate_cluster_name
|
||||
from kind_k8s_paths import clusters_dir
|
||||
|
||||
logger = logging.getLogger("kind_k8s.job_journal")
|
||||
|
||||
JOURNAL_SUBDIR = "journal"
|
||||
JOBS_HISTORY_FILENAME = "jobs_history.json"
|
||||
JOURNAL_FILE_VERSION = 1
|
||||
|
||||
# Блокировки по имени кластера (один процесс uvicorn).
|
||||
_locks_guard = threading.Lock()
|
||||
_cluster_locks: dict[str, threading.Lock] = {}
|
||||
|
||||
|
||||
def _cluster_lock(name: str) -> threading.Lock:
|
||||
with _locks_guard:
|
||||
if name not in _cluster_locks:
|
||||
_cluster_locks[name] = threading.Lock()
|
||||
return _cluster_locks[name]
|
||||
|
||||
|
||||
def _max_entries() -> int:
|
||||
raw = (os.environ.get("KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES") or "500").strip()
|
||||
try:
|
||||
return max(20, min(int(raw), 5000))
|
||||
except ValueError:
|
||||
return 500
|
||||
|
||||
|
||||
def _max_log_lines() -> int:
|
||||
raw = (os.environ.get("KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES") or "2000").strip()
|
||||
try:
|
||||
return max(50, min(int(raw), 20000))
|
||||
except ValueError:
|
||||
return 2000
|
||||
|
||||
|
||||
def journal_file_path(cluster_name: str) -> Path:
|
||||
"""Путь к JSON с историей заданий кластера."""
|
||||
return clusters_dir() / cluster_name.strip() / JOURNAL_SUBDIR / JOBS_HISTORY_FILENAME
|
||||
|
||||
|
||||
def append_job_from_record_sync(
|
||||
*,
|
||||
job_id: str,
|
||||
kind: str,
|
||||
cluster_name: str | None,
|
||||
status: str,
|
||||
message: str | None,
|
||||
created_at_utc: str,
|
||||
log_lines: list[str],
|
||||
result: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""
|
||||
Добавить запись о завершённом задании в журнал каталога кластера.
|
||||
|
||||
Вызывается из потока после ``job_store`` (to_thread); без паролей в result.
|
||||
"""
|
||||
if not cluster_name or not str(cluster_name).strip():
|
||||
return
|
||||
name = str(cluster_name).strip()
|
||||
if not validate_cluster_name(name):
|
||||
logger.debug("Журнал кластера: пропуск, некорректное имя «%s»", name)
|
||||
return
|
||||
|
||||
cdir = clusters_dir() / name
|
||||
if not cdir.is_dir():
|
||||
logger.debug("Журнал кластера: каталог %s отсутствует", cdir)
|
||||
return
|
||||
|
||||
jdir = cdir / JOURNAL_SUBDIR
|
||||
finished = datetime.now(timezone.utc).isoformat()
|
||||
cap_lines = _max_log_lines()
|
||||
lines = list(log_lines)[-cap_lines:] if log_lines else []
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"version": JOURNAL_FILE_VERSION,
|
||||
"job_id": job_id,
|
||||
"kind": kind,
|
||||
"cluster_name": name,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"created_at_utc": created_at_utc,
|
||||
"finished_at_utc": finished,
|
||||
"log_lines": lines,
|
||||
"result": result,
|
||||
}
|
||||
|
||||
path = jdir / JOBS_HISTORY_FILENAME
|
||||
lock = _cluster_lock(name)
|
||||
with lock:
|
||||
jdir.mkdir(parents=True, exist_ok=True)
|
||||
entries: list[dict[str, Any]] = []
|
||||
if path.is_file():
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict) and isinstance(data.get("entries"), list):
|
||||
entries = [e for e in data["entries"] if isinstance(e, dict)]
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Журнал %s: не прочитан, создаём заново: %s", path, e)
|
||||
entries = []
|
||||
|
||||
entries.insert(0, entry)
|
||||
max_e = _max_entries()
|
||||
if len(entries) > max_e:
|
||||
entries = entries[:max_e]
|
||||
|
||||
payload = {"file_version": JOURNAL_FILE_VERSION, "entries": entries}
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
logger.info("Журнал кластера «%s»: запись %s (%s)", name, job_id, kind)
|
||||
except OSError as e:
|
||||
logger.warning("Журнал %s: запись не удалась: %s", path, e)
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def read_cluster_journal_sync(cluster_name: str) -> dict[str, Any] | None:
|
||||
"""Прочитать файл журнала кластера или ``None``."""
|
||||
n = cluster_name.strip()
|
||||
if not validate_cluster_name(n):
|
||||
return None
|
||||
path = journal_file_path(n)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]:
|
||||
"""Все записи из журналов кластеров, отсортированные по времени (новые первыми)."""
|
||||
root = clusters_dir()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
flat: list[tuple[str, dict[str, Any]]] = []
|
||||
for sub in sorted(root.iterdir()):
|
||||
if not sub.is_dir():
|
||||
continue
|
||||
name = sub.name
|
||||
if not validate_cluster_name(name):
|
||||
continue
|
||||
path = sub / JOURNAL_SUBDIR / JOBS_HISTORY_FILENAME
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
entries = data.get("entries") if isinstance(data, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for e in entries:
|
||||
if isinstance(e, dict):
|
||||
flat.append((name, e))
|
||||
|
||||
def sort_key(item: tuple[str, dict[str, Any]]) -> str:
|
||||
return str(item[1].get("finished_at_utc") or item[1].get("created_at_utc") or "")
|
||||
|
||||
flat.sort(key=sort_key, reverse=True)
|
||||
return flat
|
||||
|
||||
|
||||
def collect_recent_journal_entries_page_sync(*, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
|
||||
"""
|
||||
Страница записей из всех ``clusters/*/journal/jobs_history.json``, новые первыми.
|
||||
|
||||
В каждую запись добавляется поле ``source_cluster`` (имя каталога).
|
||||
Возвращает ``(страница записей, общее число записей)``.
|
||||
"""
|
||||
lim = max(1, min(limit, 100))
|
||||
off = max(0, offset)
|
||||
flat = _collect_all_journal_rows_sorted_sync()
|
||||
total = len(flat)
|
||||
chunk = flat[off : off + lim]
|
||||
out: list[dict[str, Any]] = []
|
||||
for cluster_dir_name, e in chunk:
|
||||
row = dict(e)
|
||||
row["source_cluster"] = cluster_dir_name
|
||||
out.append(row)
|
||||
return out, total
|
||||
Reference in New Issue
Block a user