Веб-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

View File

@@ -11,7 +11,7 @@ import logging
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from core.cluster_lifecycle import (
KindClusterError,
@@ -22,9 +22,18 @@ from core.cluster_lifecycle import (
kubectl_pods_all_namespaces,
list_registered_kind_clusters,
read_meta_json,
start_kind_cluster_containers,
stop_kind_cluster_containers,
validate_cluster_name,
)
from core.job_store import JobRecord, end_job_tracking, get_progress_sync, job_store, request_cancel_sync
from core.job_store import (
JobRecord,
end_job_tracking,
get_logs_snapshot_sync,
get_progress_sync,
job_store,
request_cancel_sync,
)
from core.kind_guard import kind_cluster_lock
from kind_k8s_paths import clusters_dir
from models.schemas import (
@@ -47,6 +56,13 @@ def _record_to_job_view(rec: JobRecord) -> JobView:
stage, pct = (None, None)
if prog is not None:
stage, pct = prog[0], prog[1]
if rec.status in ("queued", "running"):
log_tail = get_logs_snapshot_sync(rec.job_id)
else:
log_tail = list(rec.log_lines or [])
max_log = 400
if len(log_tail) > max_log:
log_tail = log_tail[-max_log:]
return JobView(
job_id=rec.job_id,
kind=rec.kind,
@@ -57,6 +73,7 @@ def _record_to_job_view(rec: JobRecord) -> JobView:
result=rec.result,
progress_stage=stage,
progress_percent=pct,
progress_log=log_tail,
)
@@ -235,6 +252,49 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
end_job_tracking(job_id)
async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: str, workers: int) -> None:
"""Фоновое создание кластера по уже сохранённому ``kind-config.yaml`` (без kind в списке)."""
try:
async with kind_cluster_lock:
await job_store.set_running(job_id)
try:
result = await asyncio.to_thread(
create_cluster_non_interactive,
name=name.strip(),
kubernetes_version_tag=kubernetes_version_tag.strip(),
workers=workers,
job_id=job_id,
use_existing_config=True,
)
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_cluster 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_cluster job %s: непредвиденная ошибка", job_id)
return
payload: dict[str, Any] = {
"cluster_name": result.cluster_name,
"kubernetes_version_tag": result.ver_tag,
"node_image": result.node_image,
"workers": result.workers,
"kubeconfig_path": str(result.kubeconfig_path),
"kubeconfig_patched_for_host": result.kubeconfig_patched_for_host,
"nodes_ready": result.nodes_ready,
"nodes_ready_message": result.nodes_ready_message,
}
await job_store.set_success(job_id, result=payload, message="Кластер поднят по сохранённому конфигу")
logger.info("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name)
finally:
end_job_tracking(job_id)
@router.post(
"/clusters",
response_model=ClusterCreateAccepted,
@@ -279,6 +339,104 @@ async def delete_cluster(name: str) -> dict[str, object]:
return {"name": name, "kind_delete_ok": kind_ok, "summary": summary}
@router.post(
"/clusters/{name}/stop",
summary="Остановить узлы кластера (docker stop)",
responses={400: {"description": "Некорректное имя"}},
)
async def stop_cluster_nodes(name: str) -> dict[str, object]:
"""
Остановить контейнеры узлов kind; запись кластера в kind сохраняется.
После этого API «Старт» запустит те же контейнеры без ``kind create``.
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
def _do() -> tuple[bool, str]:
return stop_kind_cluster_containers(name=name)
try:
ok, summary = await asyncio.to_thread(_do)
except KindClusterError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
logger.info("Остановка узлов %s: ok=%s", name, ok)
return {"name": name, "containers_stopped_ok": ok, "summary": summary}
@router.post(
"/clusters/{name}/start",
summary="Запустить кластер (контейнеры или kind create по конфигу)",
responses={400: {"description": "Нет kind и нет kind-config.yaml"}},
)
async def start_cluster_nodes(
name: str,
background_tasks: BackgroundTasks,
) -> JSONResponse:
"""
Если кластер есть в ``kind get clusters`` — ``docker start`` всех узлов.
Если в kind нет, но есть ``clusters/<имя>/kind-config.yaml`` — фоновое ``kind create``
(как при создании, с журналом в GET /jobs/{job_id}).
"""
if not validate_cluster_name(name):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
n = name.strip()
async with kind_cluster_lock:
in_kind = n in await asyncio.to_thread(list_registered_kind_clusters)
if in_kind:
def _start() -> tuple[bool, str]:
return start_kind_cluster_containers(name=n)
try:
ok, summary = await asyncio.to_thread(_start)
except KindClusterError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
logger.info("Запуск контейнеров кластера %s: ok=%s", n, ok)
return JSONResponse(
status_code=200,
content={
"name": n,
"mode": "containers",
"containers_started_ok": ok,
"summary": summary,
},
)
cfg = clusters_dir() / n / "kind-config.yaml"
if not cfg.is_file():
raise HTTPException(
status_code=400,
detail="Кластер не в kind и нет файла clusters/<имя>/kind-config.yaml — создайте кластер или восстановите конфиг.",
)
meta = read_meta_json(n) or {}
ver_raw = str(meta.get("kubernetes_version_tag") or "v1.29.4").strip() or "v1.29.4"
w_raw = meta.get("worker_nodes")
try:
w = int(w_raw) if w_raw is not None else 0
except (TypeError, ValueError):
w = 0
rec = await job_store.create_job("start_cluster", cluster_name=n)
background_tasks.add_task(_run_start_cluster_job, rec.job_id, n, ver_raw, w)
logger.info("Фоновый старт кластера %s по конфигу, job_id=%s", n, rec.job_id)
return JSONResponse(
status_code=202,
content={
"job_id": rec.job_id,
"status": "queued",
"message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}",
},
)
@router.post(
"/jobs/{job_id}/cancel",
summary="Запросить отмену создания кластера",
@@ -286,8 +444,10 @@ async def delete_cluster(name: str) -> dict[str, object]:
)
async def cancel_create_job(job_id: str) -> dict[str, object]:
"""
Установить флаг отмены. Этап ``kind create cluster`` нельзя прервать до его завершения;
после него отмена удалит кластер и данные (если успели создать).
Установить флаг отмены для задания ``create_cluster`` или ``start_cluster``.
Этап ``kind create cluster`` нельзя прервать до его завершения; после него отмена удалит
кластер и данные (если успели создать).
"""
rec = await job_store.get(job_id)
if not rec:

View File

@@ -0,0 +1,53 @@
"""Отдача сырого README.md для клиентского рендера Markdown (marked в static).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException
from fastapi.responses import PlainTextResponse
from core.readme_doc import read_readme_text
logger = logging.getLogger("kind_k8s.api.docs_readme")
router = APIRouter(tags=["documentation"])
@router.get(
"/docs/readme",
response_class=PlainTextResponse,
summary="README.md как текст (Markdown)",
responses={404: {"description": "Файл не найден"}},
)
async def get_readme_markdown() -> PlainTextResponse:
"""
Тело ответа — содержимое README в кодировке UTF-8.
Разбор Markdown выполняется в браузере скриптами из ``/static/js/vendor/`` (marked + DOMPurify).
"""
try:
def _read() -> str:
return read_readme_text()
text = await asyncio.to_thread(_read)
except FileNotFoundError:
logger.info("GET /docs/readme: файл README не найден")
raise HTTPException(
status_code=404,
detail=(
"README.md не найден. Укажите KIND_K8S_README_PATH, смонтируйте в compose "
"./README.md:/opt/kind-k8s/README.md:ro или пересоберите образ (COPY README.md)."
),
) from None
logger.debug("GET /docs/readme: отдано %s символов", len(text))
return PlainTextResponse(
content=text,
media_type="text/markdown; charset=utf-8",
)

View File

@@ -8,9 +8,10 @@ from __future__ import annotations
from fastapi import APIRouter
from api.v1.endpoints import clusters, health, versions
from api.v1.endpoints import clusters, docs_readme, health, versions
api_router = APIRouter()
api_router.include_router(health.router, prefix="")
api_router.include_router(versions.router, prefix="")
api_router.include_router(docs_readme.router, prefix="")
api_router.include_router(clusters.router, prefix="")

View File

@@ -14,6 +14,7 @@ import os
import re
import shutil
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -106,6 +107,42 @@ def _run_checked(cmd: list[str], *, cwd: Path | None = None) -> None:
raise KindClusterError(f"Команда завершилась с кодом {p.returncode}: {err}", exit_code=p.returncode)
def _run_checked_stream(
cmd: list[str],
*,
cwd: Path | None = None,
on_line: Callable[[str], None] | None = None,
) -> None:
"""
Выполнить команду с построчным выводом в колбэк (stdout+stderr объединены).
Нужен для ``kind create cluster``: pull образов и подъём нод видны в UI по опросу job.
"""
logger.info("Выполнение (поток): %s", " ".join(cmd))
p = subprocess.Popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
if p.stdout is None:
raise KindClusterError("Не удалось открыть stdout процесса", exit_code=1)
try:
for raw in p.stdout:
line = raw.rstrip("\n\r")
if on_line and line:
on_line(line)
if line:
logger.debug("stream: %s", line[:800])
rc = p.wait()
finally:
p.stdout.close()
if rc != 0:
raise KindClusterError(f"Команда завершилась с кодом {rc} (см. журнал задания выше)", exit_code=rc)
def _run_capture_checked(cmd: list[str]) -> str:
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
@@ -177,6 +214,7 @@ def create_cluster_non_interactive(
kubernetes_version_tag: str,
workers: int,
job_id: str | None = None,
use_existing_config: bool = False,
) -> CreateClusterResult:
"""
Создать кластер kind без диалогов.
@@ -184,12 +222,20 @@ def create_cluster_non_interactive(
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
``use_existing_config=True`` — не перезаписывать ``kind-config.yaml``, поднять кластер по уже
сохранённому файлу (каталог ``clusters/<имя>/`` должен существовать).
"""
from core import job_store as _job_store
def _progress(stage: str, pct: int) -> None:
if job_id:
_job_store.set_progress_sync(job_id, stage, pct)
_job_store.append_log_sync(job_id, f"[{pct}%] {stage}")
def _log(line: str) -> None:
if job_id:
_job_store.append_log_sync(job_id, line)
def _cancelled() -> bool:
return bool(job_id and _job_store.is_cancelled_sync(job_id))
@@ -204,7 +250,7 @@ def create_cluster_non_interactive(
if name in existing:
raise KindClusterError(f"Кластер «{name}» уже существует в kind.")
if workers < 0 or workers > 20:
if not use_existing_config and (workers < 0 or workers > 20):
raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
@@ -218,17 +264,39 @@ def create_cluster_non_interactive(
kube_path = out_dir / "kubeconfig"
meta_path = out_dir / "meta.json"
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
cfg_path.write_text(yaml_text, encoding="utf-8")
prev_meta_for_workers: dict[str, object] = {}
if use_existing_config:
if not cfg_path.is_file():
raise KindClusterError(f"Нет сохранённого kind-config.yaml: {cfg_path}")
prev = read_meta_json(name) or {}
prev_meta_for_workers = prev
if prev.get("node_image"):
node_image = str(prev["node_image"])
if prev.get("kubernetes_version_tag"):
ver_tag = str(prev["kubernetes_version_tag"])
_progress("Используется существующий kind-config.yaml", 10)
else:
yaml_text = build_kind_config_yaml(node_image=node_image, workers=workers)
cfg_path.write_text(yaml_text, encoding="utf-8")
_progress("Подготовка каталога и kind-config", 12)
_progress("Подготовка каталога и kind-config", 12)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
raise KindClusterError("Создание отменено пользователем")
logger.info("Создание кластера «%s», образ %s, workers=%s", name, node_image, workers)
logger.info(
"Создание кластера «%s», образ %s, workers=%s, existing_cfg=%s",
name,
node_image,
workers,
use_existing_config,
)
_progress("kind create cluster (скачивание образов и подъём нод — может занять несколько минут)", 28)
_run_checked(["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)])
_log("--- kind create cluster ---")
_run_checked_stream(
["kind", "create", "cluster", "--name", name, "--config", str(cfg_path)],
on_line=_log,
)
if _cancelled():
_rollback_after_cancel(cluster_name=name, out_dir=out_dir)
@@ -262,12 +330,22 @@ def create_cluster_non_interactive(
logger.info("Ноды готовы: %s", msg)
else:
logger.warning("Ожидание нод не завершилось успешно: %s", msg)
_log(f"kubectl wait nodes: {msg}"[:4000])
worker_nodes_meta = workers
if use_existing_config:
prev_w = prev_meta_for_workers.get("worker_nodes")
if prev_w is not None:
try:
worker_nodes_meta = int(prev_w)
except (TypeError, ValueError):
worker_nodes_meta = workers
meta = {
"cluster_name": name,
"kubernetes_version_tag": ver_tag,
"node_image": node_image,
"worker_nodes": workers,
"worker_nodes": worker_nodes_meta,
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"kind_config_path": str(cfg_path.relative_to(root)),
"kubeconfig_path": str(kube_path.relative_to(root)),
@@ -275,6 +353,7 @@ def create_cluster_non_interactive(
"created_via_container": _in_container(),
"nodes_ready_after_create": nodes_ready,
"nodes_ready_message": nodes_msg,
"provisioned_from_existing_config": use_existing_config,
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -284,7 +363,7 @@ def create_cluster_non_interactive(
cluster_name=name,
ver_tag=ver_tag,
node_image=node_image,
workers=workers,
workers=worker_nodes_meta,
kubeconfig_path=kube_path,
meta_path=meta_path,
kubeconfig_patched_for_host=patched,
@@ -340,6 +419,84 @@ def delete_kind_cluster_and_data(*, name: str, log_to_stdout: bool = False) -> t
return kind_ok, "; ".join(parts)
def _sort_kind_node_containers(names: list[str]) -> list[str]:
"""Сначала control-plane, затем остальные — удобнее для ``docker start``."""
def sort_key(n: str) -> tuple[int, str]:
if n.endswith("-control-plane"):
return (0, n)
return (1, n)
return sorted(names, key=sort_key)
def list_kind_cluster_container_names(*, cluster_name: str) -> list[str]:
"""Имена контейнеров узлов kind (все с префиксом ``<имя>-``)."""
cli = _container_cli_bin()
if not shutil.which(cli):
raise KindClusterError(f"CLI контейнеров «{cli}» не найден в PATH.", exit_code=127)
p = subprocess.run(
[cli, "ps", "-a", "--format", "{{.Names}}"],
capture_output=True,
text=True,
)
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
raise KindClusterError(f"{cli} ps: {err}", exit_code=p.returncode)
prefix = f"{cluster_name}-"
raw = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()]
matched = [n for n in raw if n.startswith(prefix)]
return _sort_kind_node_containers(matched)
def stop_kind_cluster_containers(*, name: str) -> tuple[bool, str]:
"""
Остановить контейнеры узлов (``docker stop`` / ``podman stop``).
Запись kind о кластере сохраняется; позже можно вызвать ``start_kind_cluster_containers``.
"""
names = list_kind_cluster_container_names(cluster_name=name)
if not names:
return True, "Нет контейнеров с префиксом «%s-» (уже остановлены или удалены)" % name
cli = _container_cli_bin()
ok_all = True
parts: list[str] = []
for ctr in names:
p = subprocess.run([cli, "stop", ctr], capture_output=True, text=True)
if p.returncode != 0:
ok_all = False
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
parts.append(f"{ctr}: ошибка ({err})")
logger.warning("%s stop %s: %s", cli, ctr, err)
else:
parts.append(f"{ctr}: OK")
return ok_all, "; ".join(parts)
def start_kind_cluster_containers(*, name: str) -> tuple[bool, str]:
"""Запустить контейнеры узлов kind (после ``stop`` или рестарта движка)."""
names = list_kind_cluster_container_names(cluster_name=name)
if not names:
return False, (
"Не найдены контейнеры «%s-*». Если кластера нет в kind — используйте «Старт» "
"из UI (создание по сохранённому kind-config.yaml) или создайте кластер заново."
% name
)
cli = _container_cli_bin()
ok_all = True
parts: list[str] = []
for ctr in names:
p = subprocess.run([cli, "start", ctr], capture_output=True, text=True)
if p.returncode != 0:
ok_all = False
err = (p.stderr or p.stdout or "").strip() or str(p.returncode)
parts.append(f"{ctr}: ошибка ({err})")
logger.warning("%s start %s: %s", cli, ctr, err)
else:
parts.append(f"{ctr}: OK")
return ok_all, "; ".join(parts)
def read_meta_json(cluster_name: str) -> dict[str, object] | None:
"""Прочитать ``clusters/<имя>/meta.json`` если есть."""
p = clusters_dir() / cluster_name / "meta.json"

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:

88
app/core/readme_doc.py Normal file
View File

@@ -0,0 +1,88 @@
"""Чтение README.md для API ``GET /api/v1/docs/readme`` и страницы «Документация».
Разметка Markdown преобразуется в браузере: ``/static/js/vendor/marked.min.js`` и
``purify.min.js`` (файлы входят в репозиторий, без CDN).
Путь к файлу: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``;
в Docker-образе — ``/opt/kind-k8s/README.md``.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
logger = logging.getLogger("kind_k8s.readme_doc")
# app/core/readme_doc.py: parents[2] = корень репозитория (рядом с app/) или /opt/kind-k8s в образе
_LIB_FILE = Path(__file__).resolve()
def _candidates_without_env() -> list[Path]:
"""
Возможные пути к README без KIND_K8S_README_PATH.
Порядок: родитель каталога app/ (типично репозиторий), затем фиксированный путь образа.
В compose рекомендуется монтировать ./README.md → /opt/kind-k8s/README.md (см. docker-compose.yml).
"""
out: list[Path] = []
seen: set[Path] = set()
try:
repo_readme = (_LIB_FILE.parents[2] / "README.md").resolve()
if repo_readme not in seen:
seen.add(repo_readme)
out.append(repo_readme)
except (IndexError, OSError):
pass
fixed = Path("/opt/kind-k8s/README.md")
try:
fixed_r = fixed.resolve()
if fixed_r not in seen:
seen.add(fixed_r)
out.append(fixed_r)
except OSError:
out.append(fixed)
return out
def get_readme_path() -> Path | None:
"""Первый существующий путь к README или ``None``."""
raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip()
if raw:
p = Path(raw).expanduser().resolve()
return p if p.is_file() else None
for p in _candidates_without_env():
if p.is_file():
return p
return None
def read_readme_text() -> str:
"""Прочитать README как UTF-8; ``FileNotFoundError`` если файла нет."""
raw = (os.environ.get("KIND_K8S_README_PATH") or "").strip()
if raw:
p = Path(raw).expanduser().resolve()
if not p.is_file():
logger.warning("KIND_K8S_README_PATH: файл не найден: %s", p)
raise FileNotFoundError(str(p))
text = p.read_text(encoding="utf-8")
logger.debug("README из KIND_K8S_README_PATH, %s символов", len(text))
return text
for p in _candidates_without_env():
if p.is_file():
text = p.read_text(encoding="utf-8")
logger.info("README прочитан: %s (%s символов)", p, len(text))
return text
logger.warning(
"README.md не найден. Проверены пути: %s. "
"В Docker Compose добавьте монтирование ./README.md:/opt/kind-k8s/README.md "
"или пересоберите образ (COPY README.md в Dockerfile).",
[str(x) for x in _candidates_without_env()],
)
raise FileNotFoundError("README.md")

View File

@@ -10,19 +10,21 @@
| Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) |
| ReDoc | `http://127.0.0.1:<порт>/redoc` |
| Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` |
| Документация проекта | `http://127.0.0.1:<порт>/documentation`**README.md**: текст с `GET /api/v1/docs/readme`, рендер **Markdown** в браузере (**marked** + **DOMPurify** из `app/static/js/vendor/`, без CDN) |
| Этот файл | `app/docs/api_routes.md` в репозитории |
С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно).
С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). Пункт **Документация** открывает `GET /documentation` в той же вкладке.
## Веб-интерфейс и статика (не JSON)
| Маршрут | Описание |
|---------|----------|
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, отмена), таблицы (автообновление ~3,5 с), модалка узлов/подов; в шапкеменю-пилюли и отдельные окна для Swagger / ReDoc / Health. |
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** `kind create`, отмена), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. |
| `GET /documentation` | HTML-оболочка; контент — запрос к **`GET /api/v1/docs/readme`** и разбор Markdown скриптами из **`/static/js/vendor/`** (marked, DOMPurify). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. |
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели).
Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/documentation.html` (README).
---
@@ -31,10 +33,13 @@
| Метод | Путь | Кратко |
|-------|------|--------|
| GET | `/api/v1/health` | Среда: kind, kubectl, движок контейнеров |
| GET | `/api/v1/docs/readme` | Текст **README.md** (`text/markdown`; для страницы `/documentation`) |
| GET | `/api/v1/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` |
| GET | `/api/v1/stats` | Сводка для дашборда |
| GET | `/api/v1/clusters` | Список кластеров |
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) |
| POST | `/api/v1/clusters/{name}/start` | Запуск: **200**`docker start` узлов (кластер в kind); **202** + `job_id` — фоновый `kind create` по сохранённому `kind-config.yaml` |
| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов (`docker`/`podman` **stop**), запись в kind сохраняется |
| GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig |
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
@@ -49,6 +54,8 @@
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0100) обновляются во время создания.
- Поле **`progress_log`** — массив последних строк журнала (вывод `kind create`: pull образов, подъём нод и т.д.); размер ограничен (см. `KIND_K8S_JOB_LOG_MAX_LINES` в коде `job_store`, по умолчанию до **500** строк в буфере, в JSON отдаётся хвост).
- Тип задания **`kind`**: `create_cluster` или `start_cluster` (повторный подъём по `clusters/<имя>/kind-config.yaml`).
- Статус **`cancelled`** — пользователь запросил отмену (`POST .../cancel`); этап `kind create cluster` до завершения не прерывается.
---
@@ -86,6 +93,16 @@
---
## GET /api/v1/docs/readme
Сырое содержимое **README.md** проекта в кодировке UTF-8, заголовок **`Content-Type: text/markdown; charset=utf-8`**.
Используется страницей **`GET /documentation`**: скрипт `documentation.js` загружает текст и превращает его в HTML через **marked** и **DOMPurify** (файлы лежат в репозитории: `app/static/js/vendor/`, без внешних CDN).
**Ошибка 404:** файл не найден. В Compose смонтируйте `./README.md:/opt/kind-k8s/README.md:ro`, задайте `KIND_K8S_README_PATH` или пересоберите образ (`COPY README.md`). См. `app/core/readme_doc.py`.
---
## GET /api/v1/versions
Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI).
@@ -293,6 +310,56 @@
---
## POST /api/v1/clusters/{name}/start
Запуск кластера двумя сценариями:
1. Кластер **есть** в `kind get clusters` (узлы когда-либо создавались) — выполняется **`docker start`** / **`podman start`** для всех контейнеров с именами вида `<имя>-control-plane`, `<имя>-worker`, … Ответ **200**.
2. В **kind** кластера **нет**, но в `clusters/<имя>/kind-config.yaml` файл **есть** — ставится фоновое задание **`start_cluster`** (как при создании: `kind create` по сохранённому конфигу, журнал в `GET /jobs/{job_id}`). Ответ **202** + `job_id`.
**Пример ответа 200 (контейнеры запущены):**
```json
{
"name": "dev",
"mode": "containers",
"containers_started_ok": true,
"summary": "dev-control-plane: OK; dev-worker: OK; dev-worker2: OK"
}
```
**Пример ответа 202 (подъём по конфигу):**
```json
{
"job_id": "cafebabe...",
"status": "queued",
"message": "Подъём кластера по kind-config.yaml; опросите GET /api/v1/jobs/{job_id}"
}
```
**Ошибка 400:** некорректное имя или нет ни кластера в kind, ни `kind-config.yaml` в `clusters/<имя>/`.
---
## POST /api/v1/clusters/{name}/stop
Остановка **всех** контейнеров узлов кластера (`docker stop` / `podman stop` по префиксу имени). Запись кластера в kind **не удаляется**; позже можно снова вызвать **POST …/start** (режим `containers`).
**Пример ответа 200:**
```json
{
"name": "dev",
"containers_stopped_ok": true,
"summary": "dev-control-plane: OK; dev-worker: OK"
}
```
**Ошибка 400:** некорректное имя кластера.
---
## GET /api/v1/jobs/{job_id}
Статус фонового задания создания.
@@ -307,7 +374,15 @@
"cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00",
"message": null,
"result": null
"result": null,
"progress_stage": "kind create cluster (скачивание образов и подъём нод — может занять несколько минут)",
"progress_percent": 28,
"progress_log": [
"[12%] Подготовка каталога и kind-config",
"--- kind create cluster ---",
"Creating cluster \"dev\" ...",
" • Ensuring node image (kindest/node:v1.29.4) 🖼 ..."
]
}
```
@@ -321,6 +396,7 @@
"cluster_name": "dev",
"created_at_utc": "2026-04-04T12:00:00+00:00",
"message": "Кластер создан",
"progress_log": ["[95%] Финализация", "kubectl wait nodes: ..."],
"result": {
"cluster_name": "dev",
"kubernetes_version_tag": "v1.29.4",

View File

@@ -79,3 +79,18 @@ async def dashboard(request: Request) -> HTMLResponse:
async def ui_redirect() -> RedirectResponse:
"""Удобный алиас на корень UI."""
return RedirectResponse(url="/", status_code=307)
@app.get("/documentation", response_class=HTMLResponse, summary="Документация (README)")
async def documentation_page(request: Request) -> HTMLResponse:
"""Оболочка страницы: Markdown подгружается с ``GET /api/v1/docs/readme``, рендер в браузере (marked + DOMPurify из ``/static/js/vendor/``)."""
if not _templates_dir.is_dir():
return HTMLResponse(
content="<p>Шаблоны не найдены.</p>",
status_code=500,
)
return templates.TemplateResponse(
request,
"documentation.html",
{"app_title": settings.app_title},
)

View File

@@ -43,6 +43,10 @@ class JobView(BaseModel):
result: dict[str, Any] | None = None
progress_stage: str | None = Field(default=None, description="Текущий этап создания (пока задание активно)")
progress_percent: int | None = Field(default=None, description="Прогресс 0100 для индикатора в UI")
progress_log: list[str] = Field(
default_factory=list,
description="Хвост лога (kind create, этапы); обновляется при опросе GET /jobs/{id}",
)
class ClusterSummary(BaseModel):

View File

@@ -1,6 +1,7 @@
/**
* Панель управления кластерами kind (REST /api/v1).
* Автообновление списков и health; прогресс и отмена создания кластера.
* Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная
* замена содержимого блоков (статистика, таблицы, плашка среды) — SPA-поведение.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
@@ -23,6 +24,8 @@
var createInProgress = false;
/** @type {string | null} */
var currentPollJobId = null;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
var currentModalClusterName = null;
function formatApiError(data, fallback) {
if (!data) return fallback;
@@ -66,6 +69,137 @@
return d.innerHTML;
}
/**
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
* @param {string} clusterName
*/
function downloadKubeconfig(clusterName) {
const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig";
const a = document.createElement("a");
a.href = url;
a.download = "kubeconfig-" + clusterName + ".yaml";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/** SVG-иконки действий (stroke, currentColor). */
var ICONS = {
state:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>',
play:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7 3 21 12 7 21 7 3"/></svg>',
stop:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>',
download:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
trash:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
};
/** @type {ReturnType<typeof setTimeout> | null} */
var actionTooltipHideTimer = null;
function getActionTooltipEl() {
return document.getElementById("action-tooltip");
}
/** Скрыть плавающую подсказку (#action-tooltip, position: fixed). */
function hideActionTooltip() {
if (actionTooltipHideTimer) {
clearTimeout(actionTooltipHideTimer);
actionTooltipHideTimer = null;
}
const el = getActionTooltipEl();
if (!el) return;
el.classList.add("hidden");
el.textContent = "";
el.style.visibility = "";
}
/**
* Показать подсказку у иконки (полный текст, без обрезки overflow таблицы).
* @param {HTMLElement} host элемент .icon-tooltip-host
*/
function showActionTooltip(host) {
const el = getActionTooltipEl();
const text = host.getAttribute("data-tooltip");
if (!el || !text) return;
if (actionTooltipHideTimer) {
clearTimeout(actionTooltipHideTimer);
actionTooltipHideTimer = null;
}
el.textContent = text;
const margin = 10;
const maxW = Math.min(352, window.innerWidth - margin * 2);
el.style.maxWidth = maxW + "px";
el.classList.remove("hidden");
var w = el.offsetWidth;
var h = el.offsetHeight;
var r = host.getBoundingClientRect();
var left = r.left + r.width / 2 - w / 2;
if (left < margin) left = margin;
if (left + w > window.innerWidth - margin) left = Math.max(margin, window.innerWidth - w - margin);
var top = r.top - h - margin;
if (top < margin) top = r.bottom + margin;
if (top + h > window.innerHeight - margin) {
top = Math.max(margin, window.innerHeight - h - margin);
}
el.style.left = left + "px";
el.style.top = top + "px";
}
/**
* Подписать .icon-tooltip-host внутри root на hover/focus (повторно безопасно).
* @param {ParentNode | null} root
*/
function bindActionTooltipHosts(root) {
if (!root) return;
root.querySelectorAll(".icon-tooltip-host").forEach(function (host) {
if (host.dataset.actionTooltipBound === "1") return;
host.dataset.actionTooltipBound = "1";
host.addEventListener("mouseenter", function () {
showActionTooltip(host);
});
host.addEventListener("mouseleave", function () {
actionTooltipHideTimer = setTimeout(hideActionTooltip, 120);
});
host.addEventListener("focusin", function () {
showActionTooltip(host);
});
host.addEventListener("focusout", function (ev) {
var rel = ev.relatedTarget;
if (!rel || !host.contains(rel)) hideActionTooltip();
});
});
}
/**
* Кнопка-иконка в обёртке; подсказка — data-tooltip (см. #action-tooltip в base.html).
* @param {string} svgHtml
* @param {string} tooltip
* @param {string} [classExtra] например icon-btn--danger
* @param {((ev: Event) => void) | null} onClick
* @param {boolean} [isDisabled]
*/
function iconActionButton(svgHtml, tooltip, classExtra, onClick, isDisabled) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "icon-btn" + (classExtra ? " " + classExtra : "");
btn.innerHTML = svgHtml;
btn.setAttribute("aria-label", tooltip);
if (isDisabled) {
btn.disabled = true;
} else if (onClick) {
btn.addEventListener("click", onClick);
}
const host = document.createElement("span");
host.className = "icon-tooltip-host";
host.setAttribute("data-tooltip", tooltip);
host.appendChild(btn);
return host;
}
function showToast(message, isError) {
const el = document.getElementById("toast");
if (!el) return;
@@ -78,17 +212,6 @@
}, 4500);
}
function setBusy(section, busy) {
const el = document.querySelector("[data-busy='" + section + "']");
if (!el) return;
if (busy) {
el.setAttribute("aria-busy", "true");
} else {
el.removeAttribute("aria-busy");
}
el.classList.toggle("is-loading", busy);
}
function setStatusBannerClass(ok, degraded) {
const el = document.getElementById("status-banner");
if (!el) return;
@@ -129,10 +252,9 @@
const dl = document.getElementById("stats-dl");
const errEl = document.getElementById("stats-err");
if (!dl) return;
errEl.classList.add("hidden");
dl.innerHTML = "";
try {
const s = await api("/stats");
errEl.classList.add("hidden");
const rows = [
["Кластеров в kind", s.kind_clusters_count],
["Локальных каталогов", s.local_cluster_dirs_count],
@@ -140,14 +262,16 @@
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
];
const frag = document.createDocumentFragment();
rows.forEach(function (kv) {
const dt = document.createElement("dt");
dt.textContent = kv[0];
const dd = document.createElement("dd");
dd.textContent = String(kv[1]);
dl.appendChild(dt);
dl.appendChild(dd);
frag.appendChild(dt);
frag.appendChild(dd);
});
dl.replaceChildren(frag);
} catch (e) {
errEl.textContent = "Статистика: " + e.message;
errEl.classList.remove("hidden");
@@ -196,17 +320,14 @@
const tbody = document.querySelector("#tbl-clusters tbody");
const msg = document.getElementById("list-msg");
if (!tbody) return;
setBusy("clusters", true);
tbody.innerHTML = "";
if (msg) msg.textContent = "";
try {
const rows = await api("/clusters");
const frag = document.createDocumentFragment();
rows.forEach(function (c) {
const tr = document.createElement("tr");
const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—";
const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
const nameEsc = escapeHtml(c.name);
const dlHref = API + "/clusters/" + encodeURIComponent(c.name) + "/kubeconfig";
tr.innerHTML =
"<td><code class=\"cluster-name\">" +
nameEsc +
@@ -223,40 +344,78 @@
"<td>" +
escapeHtml(String(wn)) +
"</td>" +
"<td class=\"actions\"></td>";
const td = tr.querySelector(".actions");
const b1 = document.createElement("button");
b1.type = "button";
b1.className = "btn-small";
b1.textContent = "Состояние";
b1.addEventListener("click", function () {
openWorkloadsModal(c.name);
});
td.appendChild(b1);
if (c.has_local_kubeconfig) {
const a = document.createElement("a");
a.href = dlHref;
a.className = "btn-secondary btn-small";
a.download = "kubeconfig-" + c.name + ".yaml";
a.textContent = "kubeconfig";
a.title = "Скачать kubeconfig";
td.appendChild(a);
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
const td = tr.querySelector(".actions-toolbar");
td.appendChild(
iconActionButton(
ICONS.state,
"Состояние: узлы и поды (kubectl get nodes / pods)",
"",
function () {
openWorkloadsModal(c.name);
},
false,
),
);
td.appendChild(
iconActionButton(
ICONS.play,
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
"",
function () {
startCluster(c.name);
},
false,
),
);
if (c.registered_in_kind) {
td.appendChild(
iconActionButton(
ICONS.stop,
"Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется",
"icon-btn--secondary",
function () {
stopCluster(c.name);
},
false,
),
);
}
const b2 = document.createElement("button");
b2.type = "button";
b2.className = "btn-small btn-danger";
b2.textContent = "Удалить";
b2.addEventListener("click", function () {
deleteCluster(c.name);
});
td.appendChild(b2);
tbody.appendChild(tr);
td.appendChild(
iconActionButton(
ICONS.download,
c.has_local_kubeconfig
? "Скачать kubeconfig для kubectl на хосте"
: "Kubeconfig ещё нет — появится после создания или подъёма кластера",
"icon-btn--secondary",
c.has_local_kubeconfig
? function () {
downloadKubeconfig(c.name);
}
: null,
!c.has_local_kubeconfig,
),
);
td.appendChild(
iconActionButton(
ICONS.trash,
"Удалить кластер (kind delete) и каталог clusters/<имя>/",
"icon-btn--danger",
function () {
deleteCluster(c.name);
},
false,
),
);
frag.appendChild(tr);
});
if (!rows.length && msg) msg.textContent = "Кластеров пока нет.";
tbody.replaceChildren(frag);
bindActionTooltipHosts(document.getElementById("tbl-clusters"));
if (msg) {
msg.textContent = rows.length ? "" : "Кластеров пока нет.";
}
} catch (e) {
if (msg) msg.textContent = "Ошибка списка: " + e.message;
} finally {
setBusy("clusters", false);
}
}
@@ -264,17 +423,19 @@
const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg");
if (!tbody) return;
setBusy("jobs", true);
tbody.innerHTML = "";
if (msg) msg.textContent = "";
try {
const rows = await api("/jobs?limit=30");
const frag = document.createDocumentFragment();
rows.forEach(function (j) {
const tr = document.createElement("tr");
const st = escapeHtml(j.status || "");
var cellMsg = (j.message || "").slice(0, 160);
var kindTag = j.kind === "start_cluster" ? "[старт] " : "";
var cellMsg = kindTag + (j.message || "").slice(0, 140);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
cellMsg =
kindTag +
j.progress_stage +
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
tr.innerHTML =
"<td><time datetime=\"" +
@@ -293,15 +454,16 @@
"<td class=\"jobs-msg-cell\">" +
escapeHtml(cellMsg) +
"</td>";
tbody.appendChild(tr);
frag.appendChild(tr);
});
if (!rows.length && msg) {
msg.textContent = "Заданий ещё не было (или контейнер перезапускали).";
tbody.replaceChildren(frag);
if (msg) {
msg.textContent = rows.length
? ""
: "Заданий ещё не было (или контейнер перезапускали).";
}
} catch (e) {
if (msg) msg.textContent = "Задания: " + e.message;
} finally {
setBusy("jobs", false);
}
}
@@ -311,11 +473,15 @@
const nodes = document.getElementById("modal-nodes");
const pods = document.getElementById("modal-pods");
const spin = document.getElementById("modal-spinner");
const modalDlWrap = document.getElementById("modal-dl-wrap");
if (!overlay) return;
hideActionTooltip();
currentModalClusterName = name;
document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
sub.textContent = "";
nodes.textContent = "";
pods.textContent = "";
if (modalDlWrap) modalDlWrap.classList.add("hidden");
if (spin) spin.classList.remove("hidden");
overlay.classList.remove("hidden");
document.body.classList.add("modal-open");
@@ -328,6 +494,7 @@
sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc;
nodes.textContent = w.nodes_output || "(пусто)";
pods.textContent = w.pods_output || "(пусто)";
if (modalDlWrap) modalDlWrap.classList.remove("hidden");
} catch (e) {
sub.textContent = "Ошибка: " + e.message;
} finally {
@@ -335,14 +502,131 @@
}
}
/** Колбэк ожидающего Promise от openConfirmModal (одно окно за раз). */
var confirmModalResolver = null;
function isConfirmModalOpen() {
const el = document.getElementById("confirm-modal-overlay");
return !!(el && !el.classList.contains("hidden"));
}
/**
* Закрыть модалку подтверждения и вернуть результат в Promise.
* @param {boolean} confirmed
*/
function closeConfirmModal(confirmed) {
const ov = document.getElementById("confirm-modal-overlay");
if (ov) ov.classList.add("hidden");
document.body.classList.remove("modal-open");
if (confirmModalResolver) {
var fn = confirmModalResolver;
confirmModalResolver = null;
fn(!!confirmed);
}
}
/**
* Показать модальное подтверждение (вместо window.confirm).
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean }} opts
* @returns {Promise<boolean>}
*/
function openConfirmModal(opts) {
return new Promise(function (resolve) {
const ov = document.getElementById("confirm-modal-overlay");
const titleEl = document.getElementById("confirm-modal-title");
const msgEl = document.getElementById("confirm-modal-message");
const okBtn = document.getElementById("confirm-modal-ok");
if (!ov || !titleEl || !msgEl || !okBtn) {
resolve(false);
return;
}
if (confirmModalResolver) {
closeConfirmModal(false);
}
confirmModalResolver = resolve;
titleEl.textContent = opts.title || "Подтвердите действие";
msgEl.textContent = opts.message || "";
okBtn.textContent = opts.confirmLabel || "Подтвердить";
okBtn.className = opts.danger ? "btn-danger" : "";
ov.classList.remove("hidden");
document.body.classList.add("modal-open");
okBtn.focus();
});
}
function closeModal() {
hideActionTooltip();
const overlay = document.getElementById("modal-overlay");
const modalDlWrap = document.getElementById("modal-dl-wrap");
if (modalDlWrap) modalDlWrap.classList.add("hidden");
currentModalClusterName = null;
if (overlay) overlay.classList.add("hidden");
document.body.classList.remove("modal-open");
}
async function stopCluster(name) {
const ok = await openConfirmModal({
title: "Остановить узлы кластера?",
message:
"Кластер «" +
name +
"»: контейнеры будут остановлены (docker/podman stop). Запись в kind сохранится — позже можно снова нажать «Старт».",
confirmLabel: "Остановить",
danger: false,
});
if (!ok) return;
try {
const res = await api("/clusters/" + encodeURIComponent(name) + "/stop", { method: "POST" });
showToast(String(res.summary || "Узлы остановлены"), false);
await loadClusters();
await loadStats();
await loadHealth();
} catch (e) {
showToast(e.message, true);
}
}
/**
* Старт: либо docker start узлов (кластер уже в kind), либо фоновый kind create по kind-config.yaml.
*/
async function startCluster(name) {
const url = API + "/clusters/" + encodeURIComponent(name) + "/start";
const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
const text = await r.text();
var data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
if (r.status === 202 && data.job_id) {
setProgressHint(name);
pollJob(data.job_id);
return;
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
showToast(String(data.summary || "Готово"), false);
await loadClusters();
await loadStats();
await loadHealth();
}
async function deleteCluster(name) {
if (!confirm("Удалить кластер «" + name + "» и папку clusters/" + name + "?")) return;
const ok = await openConfirmModal({
title: "Удалить кластер?",
message:
"Кластер «" +
name +
"»: будут выполнены kind delete и удаление каталога clusters/" +
name +
"/ на томе данных. Действие необратимо.",
confirmLabel: "Удалить",
danger: true,
});
if (!ok) return;
const msg = document.getElementById("list-msg");
if (msg) msg.textContent = "Удаление…";
try {
@@ -376,6 +660,29 @@
if (!wrap) return;
wrap.classList.toggle("hidden", !show);
wrap.setAttribute("aria-hidden", show ? "false" : "true");
if (!show) {
const logEl = document.getElementById("job-log-panel");
if (logEl) logEl.textContent = "";
}
}
/**
* Обновить текст подсказки над прогрессом (создание с нуля или старт по конфигу).
* @param {string | null} clusterName
*/
function setProgressHint(clusterName) {
const hint = document.getElementById("create-progress-hint");
if (!hint) return;
if (clusterName) {
hint.innerHTML =
"Кластер <code>" +
escapeHtml(clusterName) +
"</code>: подъём по конфигу или длительный <code>kind create</code> — ниже журнал (pull образов и ноды).";
} else {
hint.innerHTML =
"Создание кластера: шаг <code>kind create</code> может занять несколько минут при первом pull образов. " +
"Ниже — журнал в реальном времени.";
}
}
function updateCreateProgressFromJob(j) {
@@ -417,6 +724,8 @@
function pollJob(jobId) {
const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg");
const logEl = document.getElementById("job-log-panel");
if (logEl) logEl.textContent = "";
if (details) {
details.classList.remove("hidden");
details.open = false;
@@ -434,20 +743,29 @@
const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
updateCreateProgressFromJob(j);
if (logEl && j.progress_log && j.progress_log.length) {
logEl.textContent = j.progress_log.join("\n");
logEl.scrollTop = logEl.scrollHeight;
}
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob();
setProgressHint(null);
if (msg) {
if (j.status === "success") msg.textContent = "Кластер создан.";
else if (j.status === "cancelled") msg.textContent = j.message || "Создание отменено.";
if (j.status === "success") {
msg.textContent =
j.kind === "start_cluster" ? "Кластер поднят по сохранённому конфигу." : "Кластер создан.";
} else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
else msg.textContent = "Ошибка: " + (j.message || "");
}
if (j.status === "success") showToast("Кластер создан", false);
else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка создания", true);
if (j.status === "success") {
showToast(j.kind === "start_cluster" ? "Кластер запущен" : "Кластер создан", false);
} else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка", true);
await loadClusters();
await loadStats();
await loadJobs();
await loadHealth();
}
} catch (e) {
if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
@@ -474,6 +792,7 @@
const details = document.getElementById("job-details");
if (msg) msg.textContent = "";
if (details) details.classList.add("hidden");
setProgressHint(null);
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
@@ -505,6 +824,13 @@
const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal);
const modalBtnKube = document.getElementById("modal-btn-kubeconfig");
if (modalBtnKube) {
modalBtnKube.addEventListener("click", function () {
if (currentModalClusterName) downloadKubeconfig(currentModalClusterName);
});
}
const overlay = document.getElementById("modal-overlay");
if (overlay) {
overlay.addEventListener("click", function (ev) {
@@ -513,9 +839,43 @@
}
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeModal();
if (ev.key !== "Escape") return;
if (isConfirmModalOpen()) {
closeConfirmModal(false);
return;
}
hideActionTooltip();
closeModal();
});
const confirmOv = document.getElementById("confirm-modal-overlay");
const confirmCancel = document.getElementById("confirm-modal-cancel");
const confirmOk = document.getElementById("confirm-modal-ok");
if (confirmCancel) {
confirmCancel.addEventListener("click", function () {
closeConfirmModal(false);
});
}
if (confirmOk) {
confirmOk.addEventListener("click", function () {
closeConfirmModal(true);
});
}
if (confirmOv) {
confirmOv.addEventListener("click", function (ev) {
if (ev.target === confirmOv) closeConfirmModal(false);
});
}
window.addEventListener(
"scroll",
function () {
hideActionTooltip();
},
true,
);
window.addEventListener("resize", hideActionTooltip);
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
loadHealth();
@@ -523,6 +883,7 @@
loadVersions();
loadClusters();
loadJobs();
bindActionTooltipHosts(document.getElementById("modal-overlay"));
}
if (document.readyState === "loading") {

View File

@@ -0,0 +1,75 @@
/**
* Страница /documentation: загрузка README через API и рендер Markdown.
* Зависимости из репозитория: /static/js/vendor/marked.min.js, purify.min.js
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
function showErr(el, msg) {
if (!el) return;
el.textContent = msg;
el.classList.remove("hidden");
}
async function run() {
const article = document.getElementById("readme-doc-article");
const errEl = document.getElementById("readme-error");
const loadEl = document.getElementById("readme-loading");
if (!article || !loadEl) return;
if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
loadEl.classList.add("hidden");
return;
}
try {
const r = await fetch(API + "/docs/readme", {
headers: { Accept: "text/markdown, text/plain, */*" },
});
const text = await r.text();
if (!r.ok) {
var detail = text;
try {
var j = JSON.parse(text);
if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
} catch (e) {
/* сырой текст */
}
showErr(errEl, "Не удалось загрузить README: " + (detail || r.statusText));
loadEl.classList.add("hidden");
return;
}
/* marked v15: переносы строк в параграфах (breaks). */
try {
if (marked.defaults && typeof marked.defaults === "object") {
marked.defaults.breaks = true;
marked.defaults.gfm = true;
}
} catch (e) {
/* игнорируем, если defaults защищены от записи */
}
var rawHtml =
typeof marked.parse === "function" ? marked.parse(text, { async: false }) : marked(text);
article.innerHTML = DOMPurify.sanitize(rawHtml);
article.removeAttribute("hidden");
loadEl.classList.add("hidden");
} catch (e) {
showErr(errEl, "Ошибка: " + (e.message || String(e)));
loadEl.classList.add("hidden");
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
})();

8
app/static/js/vendor/ATTRIBUTION.txt vendored Normal file
View File

@@ -0,0 +1,8 @@
Вендорные скрипты для страницы /documentation (Markdown в браузере):
- marked v15.0.7 — https://github.com/markedjs/marked (лицензия MIT)
- DOMPurify v3.2.4 — https://github.com/cure53/DOMPurify (Apache-2.0 / MPL-2.0)
Файлы: marked.min.js, purify.min.js (без CDN, в репозитории).
Автор сводки: Сергей Антропов — https://devops.org.ru

6
app/static/js/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
app/static/js/vendor/purify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -196,6 +196,84 @@ body.modal-open {
font-weight: 600;
}
/* Верхний ряд: панель управления слева, статистика справа */
.top-dashboard-row {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
align-items: stretch;
}
@media (min-width: 960px) {
.top-dashboard-row {
grid-template-columns: 1fr minmax(17rem, 24rem);
}
}
.top-dashboard-row .hero-panel {
margin-bottom: 0;
}
.stats-side-card {
display: flex;
flex-direction: column;
}
.stats-side-card h2 {
margin-bottom: 0.5rem;
}
.stats-dl--compact {
flex: 1;
margin: 0;
font-size: 0.88rem;
}
.create-cluster-card {
margin-bottom: 1rem;
}
/* Форма создания: две колонки, кнопка на всю ширину */
.create-form-grid {
display: grid;
gap: 0.75rem 1.5rem;
margin-top: 0.25rem;
}
@media (min-width: 640px) {
.create-form-grid {
grid-template-columns: 1fr 1fr;
align-items: start;
}
}
.create-form-col > label:first-of-type {
margin-top: 0;
}
.create-form-span {
grid-column: 1 / -1;
}
.create-ver-hint {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
.create-form-grid .create-actions {
margin-top: 0.25rem;
}
.create-form-grid .create-actions button {
margin-top: 0;
}
.create-form-col input,
.create-form-col select {
max-width: none;
width: 100%;
}
/* Одна карточка: заголовок + описание + строка состояния среды */
.hero-panel {
margin-bottom: 1rem;
@@ -262,6 +340,29 @@ body.modal-open {
margin-top: 0;
}
/* Журнал kind create в панели прогресса (потоковые строки из API) */
.job-log-panel-wrap {
margin: 0 0 0.65rem;
}
.job-log-label {
display: block;
font-size: 0.8rem;
margin-bottom: 0.25rem;
}
.job-log-panel {
margin: 0;
max-height: min(40vh, 280px);
overflow: auto;
padding: 0.5rem 0.65rem;
font-size: 0.78rem;
line-height: 1.35;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
}
.git-hint {
font-size: 0.85rem;
margin: 0 0 0.65rem;
@@ -336,13 +437,6 @@ body.modal-open {
}
/* Состояние загрузки таблиц */
/* Пока идёт запрос к API, секция слегка приглушается */
[data-busy].is-loading {
opacity: 0.55;
pointer-events: none;
transition: opacity 0.2s ease;
}
.job-details {
margin-top: 0.75rem;
border: 1px solid var(--border);
@@ -365,6 +459,16 @@ body.modal-open {
margin-bottom: 0.5rem;
align-items: flex-start;
}
.modal-dl-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0 0 0.65rem;
}
.modal-dl-hint {
font-size: 0.8rem;
}
.modal-title-text {
margin: 0;
font-size: 1.05rem;
@@ -599,12 +703,113 @@ button.btn-small {
font-size: 0.8rem;
}
td.actions {
/* Колонка «Действия»: ширина по содержимому (иконки в ряд) */
#tbl-clusters th.col-actions,
#tbl-clusters td.actions {
width: 1%;
padding: 0.3rem 0.25rem;
text-align: center;
vertical-align: middle;
}
#tbl-clusters td.actions {
white-space: nowrap;
}
td.actions button {
margin-top: 0;
margin-right: 0.25rem;
.actions-toolbar {
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.12rem;
}
/* Обёртка иконки; текст подсказки — в #action-tooltip (JS, fixed), иначе режется overflow таблицы */
.icon-tooltip-host {
position: relative;
display: inline-flex;
vertical-align: middle;
}
/* Плавающая подсказка над/под иконкой (полный текст, переносы строк) */
.action-tooltip-floating {
position: fixed;
z-index: 400;
left: 0;
top: 0;
max-width: min(22rem, calc(100vw - 20px));
max-height: min(70vh, 22rem);
overflow-y: auto;
padding: 0.55rem 0.7rem;
box-sizing: border-box;
font-size: 0.8rem;
font-weight: 500;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
hyphens: auto;
color: var(--fg);
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.action-tooltip-floating.hidden {
display: none;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.95rem;
height: 1.95rem;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(0, 0, 0, 0.15);
color: var(--fg);
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.icon-btn:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.2);
border-color: var(--accent);
color: var(--accent);
}
.icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.icon-btn svg {
width: 1.05rem;
height: 1.05rem;
flex-shrink: 0;
}
.icon-btn--secondary {
background: transparent;
}
.icon-btn--danger {
color: #f87171;
border-color: rgba(185, 28, 28, 0.5);
background: rgba(185, 28, 28, 0.08);
}
.icon-btn--danger:hover:not(:disabled) {
color: #fca5a5;
border-color: #ef4444;
background: rgba(239, 68, 68, 0.14);
}
@media (prefers-color-scheme: light) {
.icon-btn {
background: rgba(0, 0, 0, 0.04);
}
.icon-btn--danger {
color: #b91c1c;
}
}
/* Модальное окно «Состояние кластера» */
@@ -622,6 +827,28 @@ td.actions button {
.modal-overlay.hidden {
display: none;
}
/* Поверх модалки «Состояние» (z-index 100), если когда-либо понадобится стек */
.confirm-modal-overlay {
z-index: 150;
}
.modal-box--confirm {
max-width: 24rem;
}
.confirm-modal-title {
margin: 0 0 0.5rem;
font-size: 1.05rem;
}
.confirm-modal-message {
margin: 0 0 0.25rem;
white-space: pre-wrap;
}
.confirm-modal-actions {
margin-top: 1rem;
justify-content: flex-end;
}
.confirm-modal-actions button {
margin-top: 0;
}
.modal-box {
background: var(--card);
border: 1px solid var(--border);
@@ -659,3 +886,127 @@ a.btn-danger {
button.btn-danger:hover {
filter: brightness(1.12);
}
/* Страница «Документация»: README.md → HTML (Markdown) */
.readme-doc-card {
max-width: 52rem;
margin: 0 auto 2rem;
}
.readme-doc-toolbar {
align-items: center;
margin-bottom: 0.35rem;
}
.readme-doc-toolbar .page-title {
margin: 0;
}
.readme-doc-lead {
margin: 0 0 1rem;
font-size: 0.88rem;
}
.readme-doc-loading {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.readme-doc.markdown-body {
font-size: 0.95rem;
line-height: 1.55;
}
.readme-doc.markdown-body h1,
.readme-doc.markdown-body h2,
.readme-doc.markdown-body h3,
.readme-doc.markdown-body h4 {
margin: 1.25rem 0 0.5rem;
line-height: 1.25;
font-weight: 650;
}
.readme-doc.markdown-body h1 {
font-size: 1.45rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.35rem;
}
.readme-doc.markdown-body h2 {
font-size: 1.2rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.25rem;
}
.readme-doc.markdown-body h3 {
font-size: 1.05rem;
}
.readme-doc.markdown-body p {
margin: 0.5rem 0;
}
.readme-doc.markdown-body ul,
.readme-doc.markdown-body ol {
margin: 0.5rem 0;
padding-left: 1.35rem;
}
.readme-doc.markdown-body li {
margin: 0.2rem 0;
}
.readme-doc.markdown-body a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.readme-doc.markdown-body a:hover {
filter: brightness(1.15);
}
.readme-doc.markdown-body code {
font-size: 0.88em;
padding: 0.12em 0.35em;
border-radius: 4px;
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border);
}
.readme-doc.markdown-body pre {
margin: 0.65rem 0;
padding: 0.65rem 0.85rem;
overflow-x: auto;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.25);
font-size: 0.82rem;
line-height: 1.4;
}
.readme-doc.markdown-body pre code {
padding: 0;
border: none;
background: transparent;
font-size: inherit;
}
.readme-doc.markdown-body blockquote {
margin: 0.65rem 0;
padding: 0.35rem 0.75rem;
border-left: 4px solid var(--accent);
background: rgba(59, 130, 246, 0.08);
color: var(--muted);
}
.readme-doc.markdown-body table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
font-size: 0.88rem;
}
.readme-doc.markdown-body th,
.readme-doc.markdown-body td {
border: 1px solid var(--border);
padding: 0.4rem 0.55rem;
text-align: left;
}
.readme-doc.markdown-body th {
background: rgba(0, 0, 0, 0.15);
font-weight: 600;
}
.readme-doc.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.25rem 0;
}
@media (prefers-color-scheme: light) {
.readme-doc.markdown-body code {
background: rgba(0, 0, 0, 0.06);
}
.readme-doc.markdown-body pre {
background: rgba(0, 0, 0, 0.04);
}
}

View File

@@ -21,10 +21,10 @@
<div class="nav-brand">
<span class="nav-logo" aria-hidden="true"></span>
<span class="nav-title">{{ app_title }}</span>
<span class="nav-tag muted">kind · локальные кластеры</span>
</div>
<nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link nav-pill nav-pill--home">Панель</a>
<a href="/documentation" class="nav-link nav-pill">Документация</a>
<a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a>
<a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
<a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
@@ -33,6 +33,8 @@
</header>
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
{# Плавающие подсказки для иконок (position:fixed; не режутся overflow таблицы). #}
<div id="action-tooltip" class="action-tooltip-floating hidden" role="tooltip"></div>
<main id="main-content" class="app-main" tabindex="-1">
{% block content %}{% endblock %}

View File

@@ -9,81 +9,87 @@
Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code> (не в Git)
</p>
<p class="footer-copyright">
© kind-k8s-develop ·
© {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %}
{% block content %}
<section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
<div class="hero-panel-main">
<h1 class="page-title" id="hero-title">Панель управления</h1>
<p class="muted page-lead">
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
Данные обновляются автоматически.
</p>
</div>
<div
id="status-banner"
class="hero-panel-status muted"
role="status"
aria-live="polite"
>
Проверка среды…
</div>
</section>
<section class="grid">
<div class="card">
<h2>Статистика</h2>
<dl id="stats-dl" class="stats-dl" aria-label="Сводка по кластерам и заданиям"></dl>
<p id="stats-err" class="msg hidden" role="alert"></p>
</div>
<div class="card">
<h2>Создать кластер</h2>
<form id="form-create" novalidate>
<label for="fld-name">Имя (a-z0-9-)</label>
<input
id="fld-name"
name="name"
required
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
maxlength="63"
placeholder="dev"
autocomplete="off"
/>
<label for="version-select">Тег образа kindest/node (подсказка)</label>
<div class="row">
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
<option value="">— загрузить список —</option>
</select>
</div>
<p id="ver-hint" class="muted" style="margin:0.25rem 0 0;font-size:0.85rem">
Можно ввести версию вручную ниже.
<div class="top-dashboard-row">
<section class="card hero-panel" id="hero-panel" aria-labelledby="hero-title">
<div class="hero-panel-main">
<h1 class="page-title" id="hero-title">Панель управления</h1>
<p class="muted page-lead">
Создание и удаление кластеров kind, kubeconfig и просмотр узлов/подов через kubectl внутри контейнера.
Данные обновляются автоматически.
</p>
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
<input
name="kubernetes_version"
id="kubernetes_version"
required
placeholder="1.29.4 или v1.29.4"
autocomplete="off"
/>
</div>
<div
id="status-banner"
class="hero-panel-status muted"
role="status"
aria-live="polite"
>
Проверка среды…
</div>
</section>
<label for="fld-workers">Worker-ноды (020)</label>
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
<aside class="card stats-side-card" aria-labelledby="stats-heading">
<h2 id="stats-heading">Статистика</h2>
<dl id="stats-dl" class="stats-dl stats-dl--compact" aria-label="Сводка по кластерам и заданиям"></dl>
<p id="stats-err" class="msg hidden" role="alert"></p>
</aside>
</div>
<div class="row create-actions">
<button type="submit">Создать кластер</button>
<section class="card create-cluster-card">
<h2>Создать кластер</h2>
<form id="form-create" novalidate>
<div class="create-form-grid">
<div class="create-form-col">
<label for="fld-name">Имя (a-z0-9-)</label>
<input
id="fld-name"
name="name"
required
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
maxlength="63"
placeholder="dev"
autocomplete="off"
/>
<label for="fld-workers">Worker-ноды (020)</label>
<input id="fld-workers" name="workers" type="number" min="0" max="20" value="2" />
</div>
</form>
<div class="create-form-col">
<label for="version-select">Тег образа kindest/node (подсказка) или введите версию вручную ниже</label>
<div class="row">
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
<option value="">— загрузить список —</option>
</select>
</div>
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
<input
name="kubernetes_version"
id="kubernetes_version"
required
placeholder="1.29.4 или v1.29.4"
autocomplete="off"
/>
</div>
<div class="create-form-span">
<div class="row create-actions">
<button type="submit">Создать кластер</button>
</div>
</div>
</div>
<div id="create-progress-wrap" class="create-progress-wrap hidden" aria-hidden="true">
<p class="create-progress-hint muted" id="create-progress-hint">
Идёт создание — шаг <code>kind create</code> может занять несколько минут при первом pull образов.
Идёт операция — шаг <code>kind create</code> может занять несколько минут при первом pull образов.
Ниже выводится журнал (скачивание образов и подъём нод).
</p>
<div
class="progress-track"
@@ -97,7 +103,17 @@
<div class="progress-bar" id="create-progress-bar" style="width:0%"></div>
</div>
<p class="progress-label" id="create-progress-label"></p>
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить создание</button>
<div class="job-log-panel-wrap">
<label for="job-log-panel" class="job-log-label muted">Журнал задания</label>
<pre
id="job-log-panel"
class="mono job-log-panel"
role="log"
aria-live="polite"
aria-relevant="additions"
></pre>
</div>
<button type="button" id="btn-cancel-create" class="btn-secondary">Отменить между этапами</button>
</div>
<p id="create-msg" class="msg" aria-live="polite"></p>
@@ -105,7 +121,7 @@
<summary>Технические детали (JSON)</summary>
<pre id="job-json" class="mono job-json-pre" tabindex="0"></pre>
</details>
</div>
</form>
</section>
<section class="card clusters-card">
@@ -113,7 +129,7 @@
<p class="muted git-hint">
Каталог <code>clusters/</code> в Git не коммитится — только локальные kubeconfig и meta.
</p>
<div class="table-wrap" data-busy="clusters">
<div class="table-wrap">
<table id="tbl-clusters" aria-label="Список кластеров">
<thead>
<tr>
@@ -122,7 +138,7 @@
<th>kubeconfig</th>
<th>Версия</th>
<th>Workers</th>
<th>Действия</th>
<th class="col-actions">Действия</th>
</tr>
</thead>
<tbody></tbody>
@@ -133,7 +149,7 @@
<section class="card jobs-card">
<h2>Последние задания</h2>
<div class="table-wrap" data-busy="jobs">
<div class="table-wrap">
<table id="tbl-jobs" aria-label="Последние задания создания">
<thead>
<tr>
@@ -161,6 +177,22 @@
<h3 id="modal-title" class="modal-title-text">Состояние кластера</h3>
<button type="button" class="modal-close btn-secondary" id="modal-close">Закрыть</button>
</div>
<div id="modal-dl-wrap" class="modal-dl-wrap hidden">
<span
class="icon-tooltip-host"
data-tooltip="Скачать kubeconfig для использования с kubectl на хосте"
>
<button
type="button"
class="icon-btn"
id="modal-btn-kubeconfig"
aria-label="Скачать kubeconfig для kubectl на хосте"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</span>
<span class="muted modal-dl-hint">kubeconfig</span>
</div>
<div id="modal-spinner" class="modal-spinner hidden" aria-hidden="true">
<span class="spinner" aria-label="Загрузка"></span>
<span class="muted">Запрос kubectl…</span>
@@ -172,6 +204,25 @@
<pre id="modal-pods" class="mono" role="region" aria-label="Вывод kubectl get pods"></pre>
</div>
</div>
{# Модальное подтверждение вместо window.confirm (удаление, остановка узлов). #}
<div
id="confirm-modal-overlay"
class="modal-overlay confirm-modal-overlay hidden"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-message"
>
<div class="modal-box modal-box--confirm">
<h3 id="confirm-modal-title" class="modal-title-text confirm-modal-title">Подтвердите действие</h3>
<p id="confirm-modal-message" class="modal-sub confirm-modal-message"></p>
<div class="confirm-modal-actions row spread">
<button type="button" class="btn-secondary" id="confirm-modal-cancel">Отмена</button>
<button type="button" id="confirm-modal-ok">Подтвердить</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}

View File

@@ -0,0 +1,27 @@
{# Документация: README.md через GET /api/v1/docs/readme + marked + DOMPurify из static/vendor.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block page_title %}Документация{% endblock %}
{% block content %}
<section class="card readme-doc-card" aria-labelledby="readme-doc-heading">
<div class="readme-doc-toolbar row spread">
<h1 class="page-title" id="readme-doc-heading">Документация</h1>
<a href="/" class="nav-link nav-pill nav-pill--home">← Панель</a>
</div>
<p class="muted readme-doc-lead">
Текст загружается из <code>README.md</code> (эндпоинт <code>/api/v1/docs/readme</code>).
Разбор Markdown выполняется в браузере библиотеками из каталога <code>app/static/js/vendor/</code> (без внешних CDN).
</p>
<p id="readme-loading" class="muted readme-doc-loading">Загрузка документации…</p>
<p id="readme-error" class="msg hidden" role="alert"></p>
<article id="readme-doc-article" class="readme-doc markdown-body" hidden></article>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/vendor/marked.min.js"></script>
<script src="/static/js/vendor/purify.min.js"></script>
<script src="/static/js/documentation.js" defer></script>
{% endblock %}