Веб-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:
@@ -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:
|
||||
|
||||
53
app/api/v1/endpoints/docs_readme.py
Normal file
53
app/api/v1/endpoints/docs_readme.py
Normal 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",
|
||||
)
|
||||
@@ -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="")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
88
app/core/readme_doc.py
Normal 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")
|
||||
@@ -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`** (0–100) обновляются во время создания.
|
||||
- Поле **`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",
|
||||
|
||||
15
app/main.py
15
app/main.py
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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="Прогресс 0–100 для индикатора в UI")
|
||||
progress_log: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Хвост лога (kind create, этапы); обновляется при опросе GET /jobs/{id}",
|
||||
)
|
||||
|
||||
|
||||
class ClusterSummary(BaseModel):
|
||||
|
||||
@@ -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") {
|
||||
|
||||
75
app/static/js/documentation.js
Normal file
75
app/static/js/documentation.js
Normal 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
8
app/static/js/vendor/ATTRIBUTION.txt
vendored
Normal 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
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
3
app/static/js/vendor/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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-ноды (0–20)</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-ноды (0–20)</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 %}
|
||||
|
||||
27
app/templates/documentation.html
Normal file
27
app/templates/documentation.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user