Веб-UI: логи kind create, старт/стоп кластеров, документация README

- Потоковые логи в job_store и UI; kind create через Popen с построчным выводом
- POST /clusters/{name}/start|stop; create по сохранённому kind-config.yaml
- Страница /documentation: GET /api/v1/docs/readme, marked+DOMPurify из static/vendor
- Иконки действий, плавающие подсказки, модалка подтверждения вместо confirm
- Makefile: make docker|podman rebuild; compose: монтирование README.md
- Dockerfile: COPY README.md; readme_doc: несколько путей к README

Автор: Сергей Антропов — https://devops.org.ru
This commit is contained in:
Sergey Antropoff
2026-04-04 06:21:00 +03:00
parent 02f4c655b9
commit c1e867a01f
23 changed files with 1689 additions and 180 deletions
+52 -1
View File
@@ -12,9 +12,11 @@ from __future__ import annotations
import asyncio
import logging
import os
import threading
import uuid
from dataclasses import dataclass
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
@@ -29,6 +31,46 @@ JobStatus = Literal["queued", "running", "success", "failed", "cancelled"]
_thread_lock = threading.Lock()
_cancel_events: dict[str, threading.Event] = {}
_progress: dict[str, tuple[str, int]] = {}
# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines
_job_log_deques: dict[str, deque[str]] = {}
def _max_job_log_lines() -> int:
raw = (os.environ.get("KIND_K8S_JOB_LOG_MAX_LINES") or "500").strip()
try:
return max(50, min(int(raw), 5000))
except ValueError:
return 500
def append_log_sync(job_id: str, line: str) -> None:
"""Добавить строку в журнал задания (вызывается из worker-thread во время долгих команд)."""
text = (line or "").rstrip()
if not text:
return
cap = _max_job_log_lines()
with _thread_lock:
if job_id not in _job_log_deques:
_job_log_deques[job_id] = deque(maxlen=cap)
_job_log_deques[job_id].append(text)
def get_logs_snapshot_sync(job_id: str) -> list[str]:
"""Снимок текущего журнала (для API во время running/queued)."""
with _thread_lock:
d = _job_log_deques.get(job_id)
return list(d) if d else []
def take_logs_finalize_sync(job_id: str) -> list[str]:
"""
Забрать журнал в список и удалить deque (после успеха/ошибки/отмены).
Вызывать перед или внутри обновления JobRecord.
"""
with _thread_lock:
d = _job_log_deques.pop(job_id, None)
return list(d) if d else []
def begin_job_tracking(job_id: str) -> None:
@@ -43,6 +85,7 @@ def end_job_tracking(job_id: str) -> None:
with _thread_lock:
_cancel_events.pop(job_id, None)
_progress.pop(job_id, None)
_job_log_deques.pop(job_id, None)
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
@@ -88,6 +131,8 @@ class JobRecord:
created_at_utc: str
message: str | None = None
result: dict[str, Any] | None = None
# Журнал после завершения (stdout/stderr kind create и этапы); пока задание активно — см. deque
log_lines: list[str] = field(default_factory=list)
class JobStore:
@@ -127,25 +172,31 @@ class JobStore:
set_progress_sync(job_id, "Запуск создания кластера…", 5)
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)
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "success"
self._jobs[job_id].result = result
self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
set_progress_sync(job_id, "Готово", 100)
async def set_failed(self, job_id: str, message: str) -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "failed"
self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
async def set_cancelled(self, job_id: str, message: str = "Создание отменено пользователем") -> None:
logs = take_logs_finalize_sync(job_id)
async with self._lock:
if job_id in self._jobs:
self._jobs[job_id].status = "cancelled"
self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs
logger.info("Задание %s отменено: %s", job_id, message)
async def get(self, job_id: str) -> JobRecord | None: