UI: навигация, документация, favicon; журнал развёртывания; валидация формы
- Меню API: ссылка «Теги образов», обёртка прокрутки пилюль, z-index и padding против обрезки hover - Документация: ширина колонки как у дашборда (72rem) - Favicon SVG + GET /favicon.ico, link в base.html - provision_log.json, GET .../provision-log, кнопка в таблице кластеров - Валидация create: имя, workers, тег kindest; модалка alert - Прочие правки из сессии (clusters, job_store, стили, шаблоны)
This commit is contained in:
@@ -148,8 +148,8 @@ docker compose exec kind-k8s-web kubectl --kubeconfig=/work/clusters/<имя>/ku
|
|||||||
| **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig для хоста; по умолчанию **включено** (`1` в compose и в `make setup`) |
|
| **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig для хоста; по умолчанию **включено** (`1` в compose и в `make setup`) |
|
||||||
| **`CONTAINER_CLI`** | контейнер | CLI для `docker port` / `podman port` (`docker` или `podman`) |
|
| **`CONTAINER_CLI`** | контейнер | CLI для `docker port` / `podman port` (`docker` или `podman`) |
|
||||||
| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами |
|
| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами |
|
||||||
| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов отдавать в API/UI |
|
| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк показывать в **интерактивном CLI** при выборе версии (веб-UI выводит полный список из API) |
|
||||||
| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub |
|
| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Сколько страниц Docker Hub обходить при сборе тегов (старые 1.19.x часто на поздних страницах; в коде по умолчанию **120**, максимум **500**) |
|
||||||
| **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах |
|
| **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах |
|
||||||
| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Сколько строк журнала хранить в памяти на задание (старые вытесняются); по умолчанию **2500** |
|
| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Сколько строк журнала хранить в памяти на задание (старые вытесняются); по умолчанию **2500** |
|
||||||
| **`KIND_K8S_JOB_API_LOG_MAX_LINES`** | приложение | Сколько строк отдавать в **GET /api/v1/jobs/{id}** (хвост); по умолчанию **5000**, максимум **20000** |
|
| **`KIND_K8S_JOB_API_LOG_MAX_LINES`** | приложение | Сколько строк отдавать в **GET /api/v1/jobs/{id}** (хвост); по умолчанию **5000**, максимум **20000** |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -39,8 +40,11 @@ from core.job_store import (
|
|||||||
get_logs_snapshot_sync,
|
get_logs_snapshot_sync,
|
||||||
get_progress_sync,
|
get_progress_sync,
|
||||||
job_store,
|
job_store,
|
||||||
|
register_uncapped_job_log,
|
||||||
request_cancel_sync,
|
request_cancel_sync,
|
||||||
|
take_uncapped_log_finalize,
|
||||||
)
|
)
|
||||||
|
from core.provision_log import provision_log_file_path, write_cluster_provision_log
|
||||||
from core.kind_guard import kind_cluster_lock
|
from core.kind_guard import kind_cluster_lock
|
||||||
from kind_k8s_paths import clusters_dir
|
from kind_k8s_paths import clusters_dir
|
||||||
from models.schemas import (
|
from models.schemas import (
|
||||||
@@ -184,6 +188,7 @@ async def list_clusters() -> list[ClusterSummary]:
|
|||||||
registered_in_kind=bool(summary["registered_in_kind"]),
|
registered_in_kind=bool(summary["registered_in_kind"]),
|
||||||
kind_nodes_running=bool(running_map.get(name, False)),
|
kind_nodes_running=bool(running_map.get(name, False)),
|
||||||
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
|
has_local_kubeconfig=bool(summary["has_local_kubeconfig"]),
|
||||||
|
has_provision_log=bool(summary.get("has_provision_log")),
|
||||||
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
|
meta=dict(summary["meta"]) if isinstance(summary.get("meta"), dict) else {},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -211,6 +216,27 @@ async def download_kubeconfig(name: str) -> FileResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/clusters/{name}/provision-log",
|
||||||
|
summary="Журнал развёртывания (JSON)",
|
||||||
|
responses={404: {"description": "Файл не найден"}},
|
||||||
|
)
|
||||||
|
async def get_cluster_provision_log(name: str) -> JSONResponse:
|
||||||
|
"""Полный журнал последнего создания/старта кластера (``provision_log.json`` в каталоге кластера)."""
|
||||||
|
if not validate_cluster_name(name):
|
||||||
|
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||||
|
path = provision_log_file_path(name)
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Журнал развёртывания не найден")
|
||||||
|
try:
|
||||||
|
raw = await asyncio.to_thread(path.read_text, encoding="utf-8")
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("provision_log для %s не прочитан: %s", name, e)
|
||||||
|
raise HTTPException(status_code=500, detail="Не удалось прочитать журнал") from e
|
||||||
|
return JSONResponse(content=data)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/clusters/{name}/workloads",
|
"/clusters/{name}/workloads",
|
||||||
response_model=ClusterWorkloadsResponse,
|
response_model=ClusterWorkloadsResponse,
|
||||||
@@ -254,13 +280,15 @@ async def get_cluster(name: str) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
||||||
|
register_uncapped_job_log(job_id)
|
||||||
|
cname = body.name.strip()
|
||||||
try:
|
try:
|
||||||
async with kind_cluster_lock:
|
async with kind_cluster_lock:
|
||||||
await job_store.set_running(job_id)
|
await job_store.set_running(job_id)
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
create_cluster_non_interactive,
|
create_cluster_non_interactive,
|
||||||
name=body.name.strip(),
|
name=cname,
|
||||||
kubernetes_version_tag=body.kubernetes_version.strip(),
|
kubernetes_version_tag=body.kubernetes_version.strip(),
|
||||||
workers=body.workers,
|
workers=body.workers,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -291,18 +319,34 @@ async def _run_create_job(job_id: str, body: ClusterCreateRequest) -> None:
|
|||||||
await job_store.set_success(job_id, result=payload, message="Кластер создан")
|
await job_store.set_success(job_id, result=payload, message="Кластер создан")
|
||||||
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
|
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
|
||||||
finally:
|
finally:
|
||||||
|
lines = take_uncapped_log_finalize(job_id)
|
||||||
|
rec = await job_store.get(job_id)
|
||||||
|
if rec is not None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
lambda: write_cluster_provision_log(
|
||||||
|
cluster_name=cname,
|
||||||
|
job_id=job_id,
|
||||||
|
job_kind="create_cluster",
|
||||||
|
status=rec.status,
|
||||||
|
message=rec.message,
|
||||||
|
lines=lines,
|
||||||
|
result=rec.result,
|
||||||
|
),
|
||||||
|
)
|
||||||
end_job_tracking(job_id)
|
end_job_tracking(job_id)
|
||||||
|
|
||||||
|
|
||||||
async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: str, workers: int) -> None:
|
async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: str, workers: int) -> None:
|
||||||
"""Фоновое создание кластера по уже сохранённому ``kind-config.yaml`` (без kind в списке)."""
|
"""Фоновое создание кластера по уже сохранённому ``kind-config.yaml`` (без kind в списке)."""
|
||||||
|
register_uncapped_job_log(job_id)
|
||||||
|
n = name.strip()
|
||||||
try:
|
try:
|
||||||
async with kind_cluster_lock:
|
async with kind_cluster_lock:
|
||||||
await job_store.set_running(job_id)
|
await job_store.set_running(job_id)
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
create_cluster_non_interactive,
|
create_cluster_non_interactive,
|
||||||
name=name.strip(),
|
name=n,
|
||||||
kubernetes_version_tag=kubernetes_version_tag.strip(),
|
kubernetes_version_tag=kubernetes_version_tag.strip(),
|
||||||
workers=workers,
|
workers=workers,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -334,6 +378,20 @@ async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag:
|
|||||||
await job_store.set_success(job_id, result=payload, message="Кластер поднят по сохранённому конфигу")
|
await job_store.set_success(job_id, result=payload, message="Кластер поднят по сохранённому конфигу")
|
||||||
logger.info("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name)
|
logger.info("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name)
|
||||||
finally:
|
finally:
|
||||||
|
lines = take_uncapped_log_finalize(job_id)
|
||||||
|
rec = await job_store.get(job_id)
|
||||||
|
if rec is not None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
lambda: write_cluster_provision_log(
|
||||||
|
cluster_name=n,
|
||||||
|
job_id=job_id,
|
||||||
|
job_kind="start_cluster",
|
||||||
|
status=rec.status,
|
||||||
|
message=rec.message,
|
||||||
|
lines=lines,
|
||||||
|
result=rec.result,
|
||||||
|
),
|
||||||
|
)
|
||||||
end_job_tracking(job_id)
|
end_job_tracking(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ router = APIRouter(tags=["versions"])
|
|||||||
@router.get("/versions", summary="Теги kindest/node")
|
@router.get("/versions", summary="Теги kindest/node")
|
||||||
async def list_kindest_versions() -> dict[str, object]:
|
async def list_kindest_versions() -> dict[str, object]:
|
||||||
"""
|
"""
|
||||||
Вернуть отсортированный список стабильных тегов (как при интерактивном выборе версии в UI/CLI).
|
Список тегов для выпадающего списка: первым — ``latest``, затем стабильные ``vX.Y.Z`` от новых к старым (>= 1.19).
|
||||||
|
|
||||||
При ``KIND_K8S_SKIP_VERSION_LIST=1`` возвращает пустой список — UI может предложить ввод вручную.
|
При ``KIND_K8S_SKIP_VERSION_LIST=1`` возвращает пустой список — UI может предложить ввод вручную.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -75,11 +75,13 @@ def validate_cluster_name(name: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_k8s_version(raw: str) -> str:
|
def normalize_k8s_version(raw: str) -> str:
|
||||||
"""Превратить ввод в тег образа kindest/node (например 1.29.4 → v1.29.4)."""
|
"""Превратить ввод в тег образа kindest/node (1.29.4 → v1.29.4) или ``latest``."""
|
||||||
s = raw.strip()
|
s = raw.strip().lower()
|
||||||
if not s:
|
if not s:
|
||||||
return "v1.29.4"
|
return "v1.29.4"
|
||||||
s = s.lower().removeprefix("v")
|
if s in ("latest", "vlatest"):
|
||||||
|
return "latest"
|
||||||
|
s = s.removeprefix("v")
|
||||||
return f"v{s}"
|
return f"v{s}"
|
||||||
|
|
||||||
|
|
||||||
@@ -451,7 +453,7 @@ def create_cluster_non_interactive(
|
|||||||
"""
|
"""
|
||||||
Создать кластер kind без диалогов.
|
Создать кластер kind без диалогов.
|
||||||
|
|
||||||
``kubernetes_version_tag`` — тег kindest/node (например ``v1.29.4``), см. ``normalize_tag_v_prefix``.
|
``kubernetes_version_tag`` — тег kindest/node: ``latest``, ``v1.29.4`` и т.д. (см. ``normalize_tag_v_prefix``).
|
||||||
|
|
||||||
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
|
``job_id`` — если задан, обновляется прогресс и проверяется отмена (см. ``job_store``).
|
||||||
|
|
||||||
@@ -915,6 +917,8 @@ def kubectl_pods_all_namespaces(*, kubeconfig: str | Path) -> tuple[int, str]:
|
|||||||
|
|
||||||
def cluster_summary_for_api(name: str) -> dict[str, object]:
|
def cluster_summary_for_api(name: str) -> dict[str, object]:
|
||||||
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
|
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
|
||||||
|
from core.provision_log import provision_log_file_path
|
||||||
|
|
||||||
meta = read_meta_json(name) or {}
|
meta = read_meta_json(name) or {}
|
||||||
saved_kc = clusters_dir() / name / "kubeconfig"
|
saved_kc = clusters_dir() / name / "kubeconfig"
|
||||||
in_kind = name in list_registered_kind_clusters()
|
in_kind = name in list_registered_kind_clusters()
|
||||||
@@ -924,5 +928,6 @@ def cluster_summary_for_api(name: str) -> dict[str, object]:
|
|||||||
"has_local_kubeconfig": saved_kc.is_file(),
|
"has_local_kubeconfig": saved_kc.is_file(),
|
||||||
"kubeconfig_path": str(saved_kc) if saved_kc.is_file() else None,
|
"kubeconfig_path": str(saved_kc) if saved_kc.is_file() else None,
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
|
"has_provision_log": provision_log_file_path(name).is_file(),
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ _cancel_events: dict[str, threading.Event] = {}
|
|||||||
_progress: dict[str, tuple[str, int]] = {}
|
_progress: dict[str, tuple[str, int]] = {}
|
||||||
# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines
|
# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines
|
||||||
_job_log_deques: dict[str, deque[str]] = {}
|
_job_log_deques: dict[str, deque[str]] = {}
|
||||||
|
# Полный журнал без лимита строк (только для зарегистрированных job_id) — пишется в clusters/<имя>/provision_log.json
|
||||||
|
_job_uncapped_logs: dict[str, list[str]] = {}
|
||||||
# Активный дочерний процесс (pull / kind create) — для принудительной отмены
|
# Активный дочерний процесс (pull / kind create) — для принудительной отмены
|
||||||
_process_lock = threading.Lock()
|
_process_lock = threading.Lock()
|
||||||
_active_subprocess_by_job: dict[str, subprocess.Popen] = {}
|
_active_subprocess_by_job: dict[str, subprocess.Popen] = {}
|
||||||
@@ -71,6 +73,19 @@ def _max_job_log_lines() -> int:
|
|||||||
return 500
|
return 500
|
||||||
|
|
||||||
|
|
||||||
|
def register_uncapped_job_log(job_id: str) -> None:
|
||||||
|
"""Включить накопление полного журнала по ``job_id`` (создание/старт кластера → JSON в каталоге кластера)."""
|
||||||
|
with _thread_lock:
|
||||||
|
_job_uncapped_logs[job_id] = []
|
||||||
|
|
||||||
|
|
||||||
|
def take_uncapped_log_finalize(job_id: str) -> list[str]:
|
||||||
|
"""Забрать полный журнал и снять регистрацию (вызывать один раз при завершении задания)."""
|
||||||
|
with _thread_lock:
|
||||||
|
lst = _job_uncapped_logs.pop(job_id, None)
|
||||||
|
return list(lst) if lst else []
|
||||||
|
|
||||||
|
|
||||||
def append_log_sync(job_id: str, line: str) -> None:
|
def append_log_sync(job_id: str, line: str) -> None:
|
||||||
"""Добавить строку в журнал задания (вызывается из worker-thread во время долгих команд)."""
|
"""Добавить строку в журнал задания (вызывается из worker-thread во время долгих команд)."""
|
||||||
text = (line or "").rstrip()
|
text = (line or "").rstrip()
|
||||||
@@ -78,6 +93,9 @@ def append_log_sync(job_id: str, line: str) -> None:
|
|||||||
return
|
return
|
||||||
cap = _max_job_log_lines()
|
cap = _max_job_log_lines()
|
||||||
with _thread_lock:
|
with _thread_lock:
|
||||||
|
uncapped = _job_uncapped_logs.get(job_id)
|
||||||
|
if uncapped is not None:
|
||||||
|
uncapped.append(text)
|
||||||
if job_id not in _job_log_deques:
|
if job_id not in _job_log_deques:
|
||||||
_job_log_deques[job_id] = deque(maxlen=cap)
|
_job_log_deques[job_id] = deque(maxlen=cap)
|
||||||
_job_log_deques[job_id].append(text)
|
_job_log_deques[job_id].append(text)
|
||||||
@@ -116,6 +134,7 @@ def end_job_tracking(job_id: str) -> None:
|
|||||||
_cancel_events.pop(job_id, None)
|
_cancel_events.pop(job_id, None)
|
||||||
_progress.pop(job_id, None)
|
_progress.pop(job_id, None)
|
||||||
_job_log_deques.pop(job_id, None)
|
_job_log_deques.pop(job_id, None)
|
||||||
|
_job_uncapped_logs.pop(job_id, None)
|
||||||
|
|
||||||
|
|
||||||
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
|
def set_progress_sync(job_id: str, stage: str, percent: int) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Сохранение полного журнала развёртывания кластера в ``clusters/<имя>/provision_log.json``.
|
||||||
|
|
||||||
|
Автор: Сергей Антропов
|
||||||
|
Сайт: https://devops.org.ru
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from kind_k8s_paths import clusters_dir
|
||||||
|
|
||||||
|
logger = logging.getLogger("kind_k8s.provision_log")
|
||||||
|
|
||||||
|
PROVISION_LOG_FILENAME = "provision_log.json"
|
||||||
|
PROVISION_LOG_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def provision_log_file_path(cluster_name: str) -> Path:
|
||||||
|
"""Путь к JSON с журналом операции для кластера ``cluster_name``."""
|
||||||
|
return clusters_dir() / cluster_name.strip() / PROVISION_LOG_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def write_cluster_provision_log(
|
||||||
|
*,
|
||||||
|
cluster_name: str,
|
||||||
|
job_id: str,
|
||||||
|
job_kind: str,
|
||||||
|
status: str,
|
||||||
|
message: str | None,
|
||||||
|
lines: list[str],
|
||||||
|
result: dict[str, Any] | None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""
|
||||||
|
Атомарно записать полный журнал (все строки без обрезки буфера задания).
|
||||||
|
|
||||||
|
Возвращает путь к файлу или ``None``, если каталог кластера не существует.
|
||||||
|
"""
|
||||||
|
name = cluster_name.strip()
|
||||||
|
cdir = clusters_dir() / name
|
||||||
|
if not cdir.is_dir():
|
||||||
|
logger.debug("Каталог кластера отсутствует, provision_log не пишем: %s", cdir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = cdir / PROVISION_LOG_FILENAME
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"version": PROVISION_LOG_VERSION,
|
||||||
|
"job_id": job_id,
|
||||||
|
"kind": job_kind,
|
||||||
|
"cluster_name": name,
|
||||||
|
"finished_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"status": status,
|
||||||
|
"message": message,
|
||||||
|
"lines": list(lines),
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
try:
|
||||||
|
text = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||||
|
tmp.write_text(text, encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
logger.info("Сохранён журнал развёртывания: %s (%s строк)", path, len(lines))
|
||||||
|
return path
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Не удалось записать %s: %s", path, e)
|
||||||
|
try:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
@@ -115,7 +115,10 @@ def _interactive_k8s_version_tag() -> str:
|
|||||||
return normalize_k8s_version(raw)
|
return normalize_k8s_version(raw)
|
||||||
|
|
||||||
display_n = _display_limit_for_version_list()
|
display_n = _display_limit_for_version_list()
|
||||||
print(f"\nДоступные стабильные версии (всего {len(tags)}), от новых к старым:", flush=True)
|
print(
|
||||||
|
f"\nДоступные теги (всего {len(tags)}): сначала latest, затем стабильные семверы от новых к старым:",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
for i, t in enumerate(tags[:display_n], start=1):
|
for i, t in enumerate(tags[:display_n], start=1):
|
||||||
print(f" {i:3}) {t}", flush=True)
|
print(f" {i:3}) {t}", flush=True)
|
||||||
if len(tags) > display_n:
|
if len(tags) > display_n:
|
||||||
|
|||||||
+45
-4
@@ -39,12 +39,13 @@
|
|||||||
| GET | `/api/v1/docs/file` | Текст одного **`.md`** под `app/docs/` (query `path=app/docs/…`; для `/documentation?path=…`) |
|
| GET | `/api/v1/docs/file` | Текст одного **`.md`** под `app/docs/` (query `path=app/docs/…`; для `/documentation?path=…`) |
|
||||||
| GET | `/api/v1/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` |
|
| GET | `/api/v1/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` |
|
||||||
| GET | `/api/v1/stats` | Сводка для дашборда |
|
| GET | `/api/v1/stats` | Сводка для дашборда |
|
||||||
| GET | `/api/v1/clusters` | Список кластеров |
|
| GET | `/api/v1/clusters` | Список кластеров (поле `has_provision_log` — есть ли `clusters/<имя>/provision_log.json`) |
|
||||||
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) |
|
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) |
|
||||||
| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers` или `kind_config`); журнал — `GET /jobs/{job_id}` |
|
| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers` или `kind_config`); журнал — `GET /jobs/{job_id}` |
|
||||||
| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов в фоне (**202** + `job_id`, `mode`: `stop`); журнал — `GET /jobs/{job_id}` |
|
| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов в фоне (**202** + `job_id`, `mode`: `stop`); журнал — `GET /jobs/{job_id}` |
|
||||||
| GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig |
|
| GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig |
|
||||||
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
|
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
|
||||||
|
| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** (JSON с диска) |
|
||||||
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
|
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
|
||||||
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
|
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
|
||||||
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
|
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
||||||
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
|
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
|
||||||
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
|
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
|
||||||
- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются.
|
- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются. Для **create_cluster** и **start_cluster** полный журнал без обрезки дополнительно сохраняется в **`clusters/<имя>/provision_log.json`** (перезапись при каждом завершении такого задания); чтение — **GET /api/v1/clusters/{name}/provision-log**.
|
||||||
- Для **`docker pull`**: если в справке **`docker pull --help`** объявлен флаг **`--progress`**, при **`KIND_K8S_DOCKER_PULL_PLAIN=1`** вызывается **`--progress=plain`** без PTY; на старых CLI флаг не передаётся (нет строки «unknown flag» в журнале). Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды.
|
- Для **`docker pull`**: если в справке **`docker pull --help`** объявлен флаг **`--progress`**, при **`KIND_K8S_DOCKER_PULL_PLAIN=1`** вызывается **`--progress=plain`** без PTY; на старых CLI флаг не передаётся (нет строки «unknown flag» в журнале). Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды.
|
||||||
- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов).
|
- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов).
|
||||||
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
|
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
|
||||||
@@ -138,14 +139,19 @@ Accept: text/markdown
|
|||||||
|
|
||||||
## GET /api/v1/versions
|
## GET /api/v1/versions
|
||||||
|
|
||||||
Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI).
|
Теги `kindest/node` с Docker Hub для выпадающего списка в UI.
|
||||||
|
|
||||||
|
- Первым элементом всегда идёт **`latest`** (плавающий тег «самый новый образ»).
|
||||||
|
- Далее стабильные семверы **`vX.Y.Z`** без суффиксов (`-rc` и т.п.), **от новых к старым**, минимум **1.19.0**.
|
||||||
|
- Полный список семверов зависит от числа обходимых страниц API Hub: переменная **`KIND_K8S_HUB_TAGS_MAX_PAGES`** (по умолчанию в коде — 120 страниц, максимум при явной настройке — 500). Раньше UI обрезал список до 100 пунктов — сейчас отображаются все пришедшие теги.
|
||||||
|
|
||||||
При `KIND_K8S_SKIP_VERSION_LIST=1` список пустой.
|
При `KIND_K8S_SKIP_VERSION_LIST=1` список пустой.
|
||||||
|
|
||||||
**Пример ответа 200:**
|
**Пример ответа 200:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tags": ["v1.32.0", "v1.31.4"],
|
"tags": ["latest", "v1.32.0", "v1.31.4"],
|
||||||
"skipped": false
|
"skipped": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -242,6 +248,7 @@ Accept: text/markdown
|
|||||||
"registered_in_kind": true,
|
"registered_in_kind": true,
|
||||||
"kind_nodes_running": true,
|
"kind_nodes_running": true,
|
||||||
"has_local_kubeconfig": true,
|
"has_local_kubeconfig": true,
|
||||||
|
"has_provision_log": true,
|
||||||
"meta": {
|
"meta": {
|
||||||
"cluster_name": "dev",
|
"cluster_name": "dev",
|
||||||
"kubernetes_version_tag": "v1.29.4",
|
"kubernetes_version_tag": "v1.29.4",
|
||||||
@@ -254,6 +261,7 @@ Accept: text/markdown
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `kind_nodes_running` — в списке процессов контейнерного движка есть узлы kind с префиксом имени (`имя-control-plane`, `имя-worker`…); для UI: при `true` показывается действие «Стоп», иначе при необходимости подъёма — «Старт».
|
- `kind_nodes_running` — в списке процессов контейнерного движка есть узлы kind с префиксом имени (`имя-control-plane`, `имя-worker`…); для UI: при `true` показывается действие «Стоп», иначе при необходимости подъёма — «Старт».
|
||||||
|
- `has_provision_log` — в каталоге кластера есть файл **`provision_log.json`** (последнее завершённое задание создания или старта по сохранённому конфигу).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,6 +335,39 @@ Accept: text/markdown
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/clusters/{name}/provision-log
|
||||||
|
|
||||||
|
Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster** или **start_cluster** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`).
|
||||||
|
|
||||||
|
**Ошибка 404:** файла нет (кластер ещё не создавали/не стартовали с сохранением журнала, или каталог без записи).
|
||||||
|
|
||||||
|
**Ошибка 400:** некорректное имя кластера.
|
||||||
|
|
||||||
|
**Пример ответа 200 (JSON):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"job_id": "a1b2c3d4",
|
||||||
|
"kind": "create_cluster",
|
||||||
|
"cluster_name": "dev",
|
||||||
|
"finished_at_utc": "2026-04-04T14:30:00+00:00",
|
||||||
|
"status": "success",
|
||||||
|
"message": "Кластер создан",
|
||||||
|
"lines": [
|
||||||
|
"Creating cluster \"dev\" ...",
|
||||||
|
" • Ensuring node image (kindest/node:v1.29.4) 🖼"
|
||||||
|
],
|
||||||
|
"result": {
|
||||||
|
"cluster_name": "dev",
|
||||||
|
"kubernetes_version_tag": "v1.29.4",
|
||||||
|
"kubeconfig_path": "/work/clusters/dev/kubeconfig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET /api/v1/clusters/{name}/workloads
|
## GET /api/v1/clusters/{name}/workloads
|
||||||
|
|
||||||
`kubectl get nodes -o wide` и `kubectl get pods -A` по сохранённому kubeconfig.
|
`kubectl get nodes -o wide` и `kubectl get pods -A` по сохранённому kubeconfig.
|
||||||
|
|||||||
@@ -42,8 +42,11 @@ def parse_semver_tag(name: str) -> tuple[int, int, int] | None:
|
|||||||
|
|
||||||
|
|
||||||
def normalize_tag_v_prefix(tag: str) -> str:
|
def normalize_tag_v_prefix(tag: str) -> str:
|
||||||
"""Единый вид тега: ``v1.29.4``."""
|
"""Единый вид тега: ``v1.29.4`` или ``latest`` (плавающий тег Docker Hub)."""
|
||||||
s = tag.strip().lower().removeprefix("v")
|
s = tag.strip().lower()
|
||||||
|
if s in ("latest", "vlatest"):
|
||||||
|
return "latest"
|
||||||
|
s = s.removeprefix("v")
|
||||||
return f"v{s}"
|
return f"v{s}"
|
||||||
|
|
||||||
|
|
||||||
@@ -86,8 +89,9 @@ def _default_max_hub_pages() -> int:
|
|||||||
"""Верхняя граница числа запросов к API Hub (защита от бесконечного цикла)."""
|
"""Верхняя граница числа запросов к API Hub (защита от бесконечного цикла)."""
|
||||||
raw = (os.environ.get("KIND_K8S_HUB_TAGS_MAX_PAGES") or "").strip()
|
raw = (os.environ.get("KIND_K8S_HUB_TAGS_MAX_PAGES") or "").strip()
|
||||||
if raw.isdigit():
|
if raw.isdigit():
|
||||||
return max(1, min(int(raw), 200))
|
return max(1, min(int(raw), 500))
|
||||||
return 60
|
# Старые семверы (1.19.x …) часто только на поздних страницах Hub; при необходимости задайте KIND_K8S_HUB_TAGS_MAX_PAGES (до 500).
|
||||||
|
return 120
|
||||||
|
|
||||||
|
|
||||||
def fetch_kindest_node_tags(
|
def fetch_kindest_node_tags(
|
||||||
@@ -97,7 +101,9 @@ def fetch_kindest_node_tags(
|
|||||||
timeout: float = 45.0,
|
timeout: float = 45.0,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Загрузить теги с Docker Hub (постранично), вернуть отсортированный список ``vX.Y.Z``.
|
Загрузить теги с Docker Hub (постранично).
|
||||||
|
|
||||||
|
Возвращает список: первым элементом всегда ``latest``, далее стабильные ``vX.Y.Z`` от новых к старым (>= minimum).
|
||||||
|
|
||||||
При ошибке сети или HTTP возвращает пустой список (в лог — предупреждение).
|
При ошибке сети или HTTP возвращает пустой список (в лог — предупреждение).
|
||||||
"""
|
"""
|
||||||
@@ -135,12 +141,14 @@ def fetch_kindest_node_tags(
|
|||||||
)
|
)
|
||||||
|
|
||||||
tags = merge_sort_unique_tags(collected)
|
tags = merge_sort_unique_tags(collected)
|
||||||
|
out = ["latest"] + tags
|
||||||
logger.info(
|
logger.info(
|
||||||
"kindest/node: собрано %s стабильных тегов >= %s.%s.%s (страниц API: %s)",
|
"kindest/node: в списке %s тегов (включая latest), стабильных семверов %s >= %s.%s.%s (страниц API: %s)",
|
||||||
|
len(out),
|
||||||
len(tags),
|
len(tags),
|
||||||
minimum[0],
|
minimum[0],
|
||||||
minimum[1],
|
minimum[1],
|
||||||
minimum[2],
|
minimum[2],
|
||||||
page_idx,
|
page_idx,
|
||||||
)
|
)
|
||||||
return tags
|
return out
|
||||||
|
|||||||
+11
-2
@@ -16,8 +16,8 @@ import os
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -96,6 +96,15 @@ async def ui_redirect() -> RedirectResponse:
|
|||||||
return RedirectResponse(url="/", status_code=307)
|
return RedirectResponse(url="/", status_code=307)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon_ico() -> FileResponse:
|
||||||
|
"""Браузеры по умолчанию запрашивают ``/favicon.ico``; отдаём SVG в стиле логотипа Kubernetes."""
|
||||||
|
path = _static_dir / "favicon.svg"
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="favicon не найден")
|
||||||
|
return FileResponse(path, media_type="image/svg+xml", filename="favicon.ico")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/documentation", response_class=HTMLResponse, summary="Документация (README)")
|
@app.get("/documentation", response_class=HTMLResponse, summary="Документация (README)")
|
||||||
async def documentation_page(request: Request) -> HTMLResponse:
|
async def documentation_page(request: Request) -> HTMLResponse:
|
||||||
"""Оболочка страницы: Markdown подгружается с ``GET /api/v1/docs/readme``, рендер в браузере (marked + DOMPurify из ``/static/js/vendor/``)."""
|
"""Оболочка страницы: Markdown подгружается с ``GET /api/v1/docs/readme``, рендер в браузере (marked + DOMPurify из ``/static/js/vendor/``)."""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class ClusterCreateRequest(BaseModel):
|
|||||||
kubernetes_version: str = Field(
|
kubernetes_version: str = Field(
|
||||||
...,
|
...,
|
||||||
min_length=1,
|
min_length=1,
|
||||||
description="Версия Kubernetes / тег kindest/node, например 1.29.4 или v1.29.4",
|
description="Тег kindest/node: latest, 1.29.4, v1.29.4 и т.д.",
|
||||||
)
|
)
|
||||||
workers: int = Field(2, ge=0, le=20, description="Число worker-нод (0–20)")
|
workers: int = Field(2, ge=0, le=20, description="Число worker-нод (0–20)")
|
||||||
|
|
||||||
@@ -59,6 +59,10 @@ class ClusterSummary(BaseModel):
|
|||||||
description="Запущены контейнеры узлов kind (имя-control-plane, имя-worker…; docker/podman ps)",
|
description="Запущены контейнеры узлов kind (имя-control-plane, имя-worker…; docker/podman ps)",
|
||||||
)
|
)
|
||||||
has_local_kubeconfig: bool
|
has_local_kubeconfig: bool
|
||||||
|
has_provision_log: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Есть сохранённый полный журнал развёртывания (provision_log.json)",
|
||||||
|
)
|
||||||
meta: dict[str, Any] = Field(default_factory=dict)
|
meta: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Favicon 2 AB8;5 ;>3>B8?0 Kubernetes (:0: 2 H0?:5: static/icons/kubernetes.svg).
|
||||||
|
2B>@: !5@359 =B@>?>2 https://devops.org.ru
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-hidden="true">
|
||||||
|
<!-- Прозрачный фон: читается и на светлой вкладке, и на тёмной теме браузера. -->
|
||||||
|
<g transform="translate(4 4)">
|
||||||
|
<path
|
||||||
|
fill="#326ce5"
|
||||||
|
d="M10.204 14.35l.007.01-.999 2.413a5.171 5.171 0 0 1-2.075-2.597l2.578-.437.004.005a.44.44 0 0 1 .484.606zm-.833-2.129a.44.44 0 0 0 .173-.756l.002-.011L7.585 9.7a5.143 5.143 0 0 0-.73 3.255l2.514-.725.002-.009zm1.145-1.98a.44.44 0 0 0 .699-.337l.01-.005.15-2.62a5.144 5.144 0 0 0-3.01 1.442l2.147 1.523.004-.002zm.76 2.75l.723.349.722-.347.18-.78-.5-.623h-.804l-.5.623.179.779zm1.5-3.095a.44.44 0 0 0 .7.336l.008.003 2.134-1.513a5.188 5.188 0 0 0-2.992-1.442l.148 2.615.002.001zm10.876 5.97l-5.773 7.181a1.6 1.6 0 0 1-1.248.594l-9.261.003a1.6 1.6 0 0 1-1.247-.596l-5.776-7.18a1.583 1.583 0 0 1-.307-1.34L2.1 5.573c.108-.47.425-.864.863-1.073L11.305.513a1.606 1.606 0 0 1 1.385 0l8.345 3.985c.438.209.755.604.863 1.073l2.062 8.955c.108.47-.005.963-.308 1.34zm-3.289-2.057c-.042-.01-.103-.026-.145-.034-.174-.033-.315-.025-.479-.038-.35-.037-.638-.067-.895-.148-.105-.04-.18-.165-.216-.216l-.201-.059a6.45 6.45 0 0 0-.105-2.332 6.465 6.465 0 0 0-.936-2.163c.052-.047.15-.133.177-.159.008-.09.001-.183.094-.282.197-.185.444-.338.743-.522.142-.084.273-.137.415-.242.032-.024.076-.062.11-.089.24-.191.295-.52.123-.736-.172-.216-.506-.236-.745-.045-.034.027-.08.062-.111.088-.134.116-.217.23-.33.35-.246.25-.45.458-.673.609-.097.056-.239.037-.303.033l-.19.135a6.545 6.545 0 0 0-4.146-2.003l-.012-.223c-.065-.062-.143-.115-.163-.25-.022-.268.015-.557.057-.905.023-.163.061-.298.068-.475.001-.04-.001-.099-.001-.142 0-.306-.224-.555-.5-.555-.275 0-.499.249-.499.555l.001.014c0 .041-.002.092 0 .128.006.177.044.312.067.475.042.348.078.637.056.906a.545.545 0 0 1-.162.258l-.012.211a6.424 6.424 0 0 0-4.166 2.003 8.373 8.373 0 0 1-.18-.128c-.09.012-.18.04-.297-.029-.223-.15-.427-.358-.673-.608-.113-.12-.195-.234-.329-.349-.03-.026-.077-.062-.111-.088a.594.594 0 0 0-.348-.132.481.481 0 0 0-.398.176c-.172.216-.117.546.123.737l.007.005.104.083c.142.105.272.159.414.242.299.185.546.338.743.522.076.082.09.226.1.288l.16.143a6.462 6.462 0 0 0-1.02 4.506l-.208.06c-.055.072-.133.184-.215.217-.257.081-.546.11-.895.147-.164.014-.305.006-.48.039-.037.007-.09.02-.133.03l-.004.002-.007.002c-.295.071-.484.342-.423.608.061.267.349.429.645.365l.007-.001.01-.003.129-.029c.17-.046.294-.113.448-.172.33-.118.604-.217.87-.256.112-.009.23.069.288.101l.217-.037a6.5 6.5 0 0 0 2.88 3.596l-.09.218c.033.084.069.199.044.282-.097.252-.263.517-.452.813-.091.136-.185.242-.268.399-.02.037-.045.095-.064.134-.128.275-.034.591.213.71.248.12.556-.007.69-.282v-.002c.02-.039.046-.09.062-.127.07-.162.094-.301.144-.458.132-.332.205-.68.387-.897.05-.06.13-.082.215-.105l.113-.205a6.453 6.453 0 0 0 4.609.012l.106.192c.086.028.18.042.256.155.136.232.229.507.342.84.05.156.074.295.145.457.016.037.043.09.062.129.133.276.442.402.69.282.247-.118.341-.435.213-.71-.02-.039-.045-.096-.065-.134-.083-.156-.177-.261-.268-.398-.19-.296-.346-.541-.443-.793-.04-.13.007-.21.038-.294-.018-.022-.059-.144-.083-.202a6.499 6.499 0 0 0 2.88-3.622c.064.01.176.03.213.038.075-.05.144-.114.28-.104.266.039.54.138.87.256.154.06.277.128.448.173.036.01.088.019.13.028l.009.003.007.001c.297.064.584-.098.645-.365.06-.266-.128-.537-.423-.608zM16.4 9.701l-1.95 1.746v.005a.44.44 0 0 0 .173.757l.003.01 2.526.728a5.199 5.199 0 0 0-.108-1.674A5.208 5.208 0 0 0 16.4 9.7zm-4.013 5.325a.437.437 0 0 0-.404-.232.44.44 0 0 0-.372.233h-.002l-1.268 2.292a5.164 5.164 0 0 0 3.326.003l-1.27-2.296h-.01zm1.888-1.293a.44.44 0 0 0-.27.036.44.44 0 0 0-.214.572l-.003.004 1.01 2.438a5.15 5.15 0 0 0 2.081-2.615l-2.6-.44-.004.005z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
+250
-12
@@ -35,6 +35,18 @@
|
|||||||
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
||||||
var cachedLocalClusterDirsCount = null;
|
var cachedLocalClusterDirsCount = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кэш GET /versions для проверки тега перед созданием кластера.
|
||||||
|
* null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов.
|
||||||
|
*/
|
||||||
|
var cachedKindestTags = null;
|
||||||
|
/** true, если запрос /versions упал (сеть, 5xx и т.д.). */
|
||||||
|
var cachedKindestVersionsLoadError = false;
|
||||||
|
/** true, если сервер вернул skipped (например KIND_K8S_SKIP_VERSION_LIST). */
|
||||||
|
var cachedKindestTagsSkipped = false;
|
||||||
|
/** Режим модалки подтверждения: только кнопка «Понятно» (Escape закрывает как OK). */
|
||||||
|
var confirmModalIsAlert = false;
|
||||||
|
|
||||||
function isHomePage() {
|
function isHomePage() {
|
||||||
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
|
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
|
||||||
}
|
}
|
||||||
@@ -115,6 +127,37 @@
|
|||||||
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
|
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
|
||||||
* @param {string} clusterName
|
* @param {string} clusterName
|
||||||
*/
|
*/
|
||||||
|
function isProvisionLogModalOpen() {
|
||||||
|
const el = document.getElementById("provision-log-modal-overlay");
|
||||||
|
return !!(el && !el.classList.contains("hidden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProvisionLogModal() {
|
||||||
|
const el = document.getElementById("provision-log-modal-overlay");
|
||||||
|
if (el) el.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке.
|
||||||
|
* @param {string} clusterName
|
||||||
|
*/
|
||||||
|
function openProvisionLogModal(clusterName) {
|
||||||
|
const overlay = document.getElementById("provision-log-modal-overlay");
|
||||||
|
const sub = document.getElementById("provision-log-modal-sub");
|
||||||
|
const pre = document.getElementById("provision-log-body");
|
||||||
|
if (!overlay || !pre) return;
|
||||||
|
pre.textContent = "Загрузка…";
|
||||||
|
if (sub) sub.textContent = "Кластер: " + clusterName;
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
api("/clusters/" + encodeURIComponent(clusterName) + "/provision-log")
|
||||||
|
.then(function (data) {
|
||||||
|
pre.textContent = JSON.stringify(data, null, 2);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
pre.textContent = "Ошибка: " + e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function downloadKubeconfig(clusterName) {
|
function downloadKubeconfig(clusterName) {
|
||||||
const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig";
|
const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig";
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -137,6 +180,8 @@
|
|||||||
'<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>',
|
'<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:
|
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>',
|
'<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>',
|
||||||
|
logFile:
|
||||||
|
'<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||||
@@ -618,32 +663,139 @@
|
|||||||
const sel = document.getElementById("version-select");
|
const sel = document.getElementById("version-select");
|
||||||
const verInput = document.getElementById("kubernetes_version");
|
const verInput = document.getElementById("kubernetes_version");
|
||||||
if (!sel || !verInput) return;
|
if (!sel || !verInput) return;
|
||||||
|
cachedKindestTags = null;
|
||||||
|
cachedKindestVersionsLoadError = false;
|
||||||
|
cachedKindestTagsSkipped = false;
|
||||||
sel.innerHTML = "<option value=\"\">— загрузка —</option>";
|
sel.innerHTML = "<option value=\"\">— загрузка —</option>";
|
||||||
try {
|
try {
|
||||||
const data = await api("/versions");
|
const data = await api("/versions");
|
||||||
|
cachedKindestTagsSkipped = !!data.skipped;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = "";
|
||||||
if (!data.tags || !data.tags.length) {
|
if (!data.tags || !data.tags.length) {
|
||||||
|
cachedKindestTags = [];
|
||||||
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
|
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cachedKindestTags = data.tags.slice();
|
||||||
const opt0 = document.createElement("option");
|
const opt0 = document.createElement("option");
|
||||||
opt0.value = "";
|
opt0.value = "";
|
||||||
opt0.textContent = "— выберите тег —";
|
opt0.textContent = "— выберите тег —";
|
||||||
sel.appendChild(opt0);
|
sel.appendChild(opt0);
|
||||||
data.tags.slice(0, 100).forEach(function (t) {
|
/* Весь список с бэкенда: первым идёт latest, далее семверы от новых к старым (раньше slice(0,100) отрезал 1.19 и др.) */
|
||||||
|
data.tags.forEach(function (t) {
|
||||||
const o = document.createElement("option");
|
const o = document.createElement("option");
|
||||||
o.value = t;
|
o.value = t;
|
||||||
o.textContent = t;
|
o.textContent =
|
||||||
|
t === "latest" ? "latest (самый новый образ kindest/node)" : t;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
});
|
});
|
||||||
sel.onchange = function () {
|
sel.onchange = function () {
|
||||||
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
|
if (!sel.value) return;
|
||||||
|
verInput.value = sel.value === "latest" ? "latest" : sel.value.replace(/^v/, "");
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
cachedKindestVersionsLoadError = true;
|
||||||
|
cachedKindestTags = null;
|
||||||
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
|
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имя кластера: DNS-подмножество, как на бэкенде (a-z0-9-, не длиннее 63).
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
function isValidClusterName(name) {
|
||||||
|
const s = String(name || "").trim();
|
||||||
|
if (!s || s.length > 63) return false;
|
||||||
|
return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Число worker-нод 0…20; иначе null.
|
||||||
|
* @param {unknown} raw
|
||||||
|
* @returns {number | null}
|
||||||
|
*/
|
||||||
|
function parseWorkersBounded(raw) {
|
||||||
|
const n = parseInt(String(raw === undefined || raw === null ? "" : raw), 10);
|
||||||
|
if (Number.isNaN(n) || n < 0 || n > 20) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализация тега kindest/node для сравнения с каталогом (latest или vX.Y.Z).
|
||||||
|
* @param {string} raw
|
||||||
|
*/
|
||||||
|
function normalizeKindestTagForLookup(raw) {
|
||||||
|
let s = String(raw || "").trim().toLowerCase();
|
||||||
|
if (!s) return "";
|
||||||
|
if (s === "latest" || s === "vlatest") return "latest";
|
||||||
|
s = s.replace(/^v/, "");
|
||||||
|
return "v" + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка поля версии перед POST /clusters: каталог доступен и тег в списке.
|
||||||
|
* @param {string} kubernetesVersionRaw
|
||||||
|
* @returns {{ ok: true } | { ok: false, modalTitle: string, modalMessage: string }}
|
||||||
|
*/
|
||||||
|
function validateCreateFormKubernetesVersion(kubernetesVersionRaw) {
|
||||||
|
const normalized = normalizeKindestTagForLookup(kubernetesVersionRaw);
|
||||||
|
if (!normalized) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
modalTitle: "Нет версии Kubernetes",
|
||||||
|
modalMessage:
|
||||||
|
"Заполните поле «Версия Kubernetes / тег node» (например 1.29.4, v1.29.4 или latest) или выберите значение в списке «Тег образа».",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedKindestVersionsLoadError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
modalTitle: "Каталог тегов недоступен",
|
||||||
|
modalMessage:
|
||||||
|
"Не удалось загрузить список тегов kindest/node с сервера. Проверьте соединение и обновите страницу. Пока каталог не загружен, нельзя убедиться, что образ существует.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedKindestTags === null) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
modalTitle: "Список тегов загружается",
|
||||||
|
modalMessage:
|
||||||
|
"Подождите, пока в поле «Тег образа» завершится загрузка (не должно оставаться «— загрузка —»), и повторите отправку формы.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cachedKindestTags.length) {
|
||||||
|
const skipHint = cachedKindestTagsSkipped
|
||||||
|
? " На сервере отключена выдача списка (переменная KIND_K8S_SKIP_VERSION_LIST)."
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
modalTitle: "Каталог тегов недоступен",
|
||||||
|
modalMessage:
|
||||||
|
"Приложение не получило ни одного тега kindest/node для проверки." +
|
||||||
|
skipHint +
|
||||||
|
" Создание кластера с непроверенным тегом отключено. Включите загрузку каталога или обратитесь к администратору.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonSet = new Set(cachedKindestTags.map(normalizeKindestTagForLookup));
|
||||||
|
if (canonSet.has(normalized)) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
modalTitle: "Тег не найден в каталоге",
|
||||||
|
modalMessage:
|
||||||
|
"Значение «" +
|
||||||
|
String(kubernetesVersionRaw).trim() +
|
||||||
|
"» отсутствует в списке доступных тегов kindest/node в приложении. Выберите тег из списка «Тег образа» или введите поддерживаемый семвер (см. подпись поля и Docker Hub).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function jobBadgeClass(status) {
|
function jobBadgeClass(status) {
|
||||||
if (status === "success") return "badge badge-ok";
|
if (status === "success") return "badge badge-ok";
|
||||||
if (status === "failed") return "badge badge-err";
|
if (status === "failed") return "badge badge-err";
|
||||||
@@ -749,6 +901,19 @@
|
|||||||
!c.has_local_kubeconfig,
|
!c.has_local_kubeconfig,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (c.has_provision_log) {
|
||||||
|
td.appendChild(
|
||||||
|
iconActionButton(
|
||||||
|
ICONS.logFile,
|
||||||
|
"Журнал развёртывания: полный JSON последнего создания или старта кластера",
|
||||||
|
"icon-btn--secondary",
|
||||||
|
function () {
|
||||||
|
openProvisionLogModal(c.name);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
td.appendChild(
|
td.appendChild(
|
||||||
iconActionButton(
|
iconActionButton(
|
||||||
ICONS.trash,
|
ICONS.trash,
|
||||||
@@ -923,18 +1088,26 @@
|
|||||||
*/
|
*/
|
||||||
function closeConfirmModal(confirmed) {
|
function closeConfirmModal(confirmed) {
|
||||||
const ov = document.getElementById("confirm-modal-overlay");
|
const ov = document.getElementById("confirm-modal-overlay");
|
||||||
|
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||||
if (ov) ov.classList.add("hidden");
|
if (ov) ov.classList.add("hidden");
|
||||||
document.body.classList.remove("modal-open");
|
document.body.classList.remove("modal-open");
|
||||||
|
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
||||||
if (confirmModalResolver) {
|
if (confirmModalResolver) {
|
||||||
var fn = confirmModalResolver;
|
var fn = confirmModalResolver;
|
||||||
confirmModalResolver = null;
|
confirmModalResolver = null;
|
||||||
fn(!!confirmed);
|
var out = confirmModalIsAlert ? true : !!confirmed;
|
||||||
|
confirmModalIsAlert = false;
|
||||||
|
fn(out);
|
||||||
|
} else {
|
||||||
|
confirmModalIsAlert = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Показать модальное подтверждение (вместо window.confirm).
|
* Показать модальное подтверждение (вместо window.confirm).
|
||||||
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean }} opts
|
* При opts.alert — одна кнопка «Понятно», клик по фону и Escape закрывают как подтверждение.
|
||||||
|
*
|
||||||
|
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: boolean }} opts
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
function openConfirmModal(opts) {
|
function openConfirmModal(opts) {
|
||||||
@@ -943,18 +1116,30 @@
|
|||||||
const titleEl = document.getElementById("confirm-modal-title");
|
const titleEl = document.getElementById("confirm-modal-title");
|
||||||
const msgEl = document.getElementById("confirm-modal-message");
|
const msgEl = document.getElementById("confirm-modal-message");
|
||||||
const okBtn = document.getElementById("confirm-modal-ok");
|
const okBtn = document.getElementById("confirm-modal-ok");
|
||||||
|
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||||
if (!ov || !titleEl || !msgEl || !okBtn) {
|
if (!ov || !titleEl || !msgEl || !okBtn) {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* Снять предыдущее ожидание без логики «alert = всегда true» (иначе Promise подтверждения ломается). */
|
||||||
if (confirmModalResolver) {
|
if (confirmModalResolver) {
|
||||||
closeConfirmModal(false);
|
const prevFn = confirmModalResolver;
|
||||||
|
confirmModalResolver = null;
|
||||||
|
confirmModalIsAlert = false;
|
||||||
|
prevFn(false);
|
||||||
|
ov.classList.add("hidden");
|
||||||
|
document.body.classList.remove("modal-open");
|
||||||
|
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
confirmModalResolver = resolve;
|
confirmModalResolver = resolve;
|
||||||
|
confirmModalIsAlert = !!opts.alert;
|
||||||
titleEl.textContent = opts.title || "Подтвердите действие";
|
titleEl.textContent = opts.title || "Подтвердите действие";
|
||||||
msgEl.textContent = opts.message || "";
|
msgEl.textContent = opts.message || "";
|
||||||
okBtn.textContent = opts.confirmLabel || "Подтвердить";
|
okBtn.textContent = opts.confirmLabel || (confirmModalIsAlert ? "Понятно" : "Подтвердить");
|
||||||
okBtn.className = opts.danger ? "btn-danger" : "";
|
okBtn.className = opts.danger ? "btn-danger" : "";
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.classList.toggle("hidden", confirmModalIsAlert);
|
||||||
|
}
|
||||||
ov.classList.remove("hidden");
|
ov.classList.remove("hidden");
|
||||||
document.body.classList.add("modal-open");
|
document.body.classList.add("modal-open");
|
||||||
okBtn.focus();
|
okBtn.focus();
|
||||||
@@ -1256,10 +1441,47 @@
|
|||||||
if (details) details.classList.add("hidden");
|
if (details) details.classList.add("hidden");
|
||||||
setProgressHint(null, "create");
|
setProgressHint(null, "create");
|
||||||
const fd = new FormData(form);
|
const fd = new FormData(form);
|
||||||
|
const nameRaw = String(fd.get("name") || "").trim();
|
||||||
|
const k8sVer = String(fd.get("kubernetes_version") || "").trim();
|
||||||
|
const workersParsed = parseWorkersBounded(fd.get("workers"));
|
||||||
|
|
||||||
|
if (!isValidClusterName(nameRaw)) {
|
||||||
|
await openConfirmModal({
|
||||||
|
alert: true,
|
||||||
|
title: "Некорректное имя кластера",
|
||||||
|
message:
|
||||||
|
"Имя должно состоять из строчных латинских букв, цифр и дефиса (a-z, 0-9, -), начинаться и заканчиваться буквой или цифрой, длина не более 63 символов. Пример: dev, my-cluster-1.",
|
||||||
|
confirmLabel: "Понятно",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workersParsed === null) {
|
||||||
|
await openConfirmModal({
|
||||||
|
alert: true,
|
||||||
|
title: "Некорректное число worker-нод",
|
||||||
|
message:
|
||||||
|
"Укажите целое число worker-нод от 0 до 20 включительно.",
|
||||||
|
confirmLabel: "Понятно",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verCheck = validateCreateFormKubernetesVersion(k8sVer);
|
||||||
|
if (!verCheck.ok) {
|
||||||
|
await openConfirmModal({
|
||||||
|
alert: true,
|
||||||
|
title: verCheck.modalTitle,
|
||||||
|
message: verCheck.modalMessage,
|
||||||
|
confirmLabel: "Понятно",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
name: String(fd.get("name") || "").trim(),
|
name: nameRaw,
|
||||||
kubernetes_version: String(fd.get("kubernetes_version") || "").trim(),
|
kubernetes_version: k8sVer,
|
||||||
workers: parseInt(String(fd.get("workers") || "0"), 10),
|
workers: workersParsed,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const res = await api("/clusters", {
|
const res = await api("/clusters", {
|
||||||
@@ -1310,7 +1532,11 @@
|
|||||||
document.addEventListener("keydown", function (ev) {
|
document.addEventListener("keydown", function (ev) {
|
||||||
if (ev.key !== "Escape") return;
|
if (ev.key !== "Escape") return;
|
||||||
if (isConfirmModalOpen()) {
|
if (isConfirmModalOpen()) {
|
||||||
closeConfirmModal(false);
|
closeConfirmModal(confirmModalIsAlert ? true : false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isProvisionLogModalOpen()) {
|
||||||
|
closeProvisionLogModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hideActionTooltip();
|
hideActionTooltip();
|
||||||
@@ -1332,7 +1558,18 @@
|
|||||||
}
|
}
|
||||||
if (confirmOv) {
|
if (confirmOv) {
|
||||||
confirmOv.addEventListener("click", function (ev) {
|
confirmOv.addEventListener("click", function (ev) {
|
||||||
if (ev.target === confirmOv) closeConfirmModal(false);
|
if (ev.target === confirmOv) closeConfirmModal(confirmModalIsAlert ? true : false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const provLogOv = document.getElementById("provision-log-modal-overlay");
|
||||||
|
const provLogClose = document.getElementById("provision-log-modal-close");
|
||||||
|
if (provLogClose) {
|
||||||
|
provLogClose.addEventListener("click", closeProvisionLogModal);
|
||||||
|
}
|
||||||
|
if (provLogOv) {
|
||||||
|
provLogOv.addEventListener("click", function (ev) {
|
||||||
|
if (ev.target === provLogOv) closeProvisionLogModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,6 +1602,7 @@
|
|||||||
loadVersions();
|
loadVersions();
|
||||||
}
|
}
|
||||||
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
||||||
|
bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
+124
-42
@@ -112,14 +112,34 @@ body.modal-open {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
/* Не overflow-x: здесь — иначе обрезается .nav-dropdown__menu (absolute под кнопкой API). */
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
/* Горизонтальная прокрутка только подписей «Панель» / «Документация», без выпадающего меню. */
|
||||||
|
.nav-links-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
/*
|
||||||
|
* При overflow-x: auto вертикаль становится auto — обрезает translateY(-1px) и тень у .nav-pill:hover.
|
||||||
|
* Внутренний padding даёт место сверху/снизу; API вне .nav-links-scroll, там обрезки не было.
|
||||||
|
* z-index как у .nav-dropdown — общий stacking context при overflow.
|
||||||
|
*/
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
padding-bottom: 0.45rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 160;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Выпадающее меню «API»: Swagger, ReDoc, Health в отдельном окне */
|
/* Выпадающее меню «API»: Swagger, ReDoc, Health в отдельном окне */
|
||||||
.nav-dropdown {
|
.nav-dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 160;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -339,14 +359,6 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext)
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Страница /documentation: основной блок и подвал на всю ширину окна */
|
|
||||||
.doc-layout-full .app-main,
|
|
||||||
.doc-layout-full .app-footer {
|
|
||||||
max-width: none;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
max-width: 72rem;
|
max-width: 72rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -690,54 +702,112 @@ html[data-theme="light"] .cluster-resources-expand-cell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cluster-create-hero {
|
.cluster-create-hero {
|
||||||
|
margin-bottom: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Карточка формы: чуть плотнее внутри, заголовок отступает от полей */
|
||||||
|
.card.create-cluster-card {
|
||||||
|
padding: 0.85rem 1rem 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-cluster-card {
|
.create-cluster-card__title {
|
||||||
margin-bottom: 1rem;
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.02rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Форма создания: две колонки, кнопка на всю ширину */
|
/* Сетка 2×2: имя | тег, затем подсказка на всю ширину, workers | версия; выравнивание по нижнему краю ячеек */
|
||||||
.create-form-grid {
|
.create-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem 1.5rem;
|
gap: 0.4rem 1.15rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-field > label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form-grid .create-form-field input,
|
||||||
|
.create-form-grid .create-form-field select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
min-height: 2.3rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.create-form-grid {
|
.create-form-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
align-items: start;
|
align-items: end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form-col > label:first-of-type {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form-span {
|
.create-form-span {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ссылка на актуальные теги kindest/node (Docker Hub), новая вкладка */
|
||||||
|
.create-form-doc-link {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.create-form-doc-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.create-form-doc-link:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.create-form-doc-link__ext {
|
||||||
|
margin-left: 0.12rem;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.create-form-label-rest {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.create-ver-hint {
|
.create-ver-hint {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-ver-hint--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0.1rem 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form-grid .create-actions {
|
.create-form-grid .create-actions {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.35rem;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form-grid .create-actions button {
|
.create-form-grid .create-actions button {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-form-col input,
|
|
||||||
.create-form-col select {
|
|
||||||
max-width: none;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Одна карточка: заголовок + описание + строка состояния среды */
|
/* Одна карточка: заголовок + описание + строка состояния среды */
|
||||||
.hero-panel {
|
.hero-panel {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -1342,6 +1412,26 @@ html[data-theme="light"] .icon-btn--danger {
|
|||||||
.confirm-modal-overlay {
|
.confirm-modal-overlay {
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
}
|
}
|
||||||
|
/* Журнал развёртывания: между окном «Состояние» (100) и подтверждением (150) */
|
||||||
|
.provision-log-modal-overlay {
|
||||||
|
z-index: 120;
|
||||||
|
}
|
||||||
|
.modal-box--provision-log .provision-log-body {
|
||||||
|
max-height: min(70vh, 32rem);
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .modal-box--provision-log .provision-log-body {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
.modal-box--confirm {
|
.modal-box--confirm {
|
||||||
max-width: 24rem;
|
max-width: 24rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1407,17 +1497,13 @@ button.btn-danger:hover {
|
|||||||
filter: brightness(1.12);
|
filter: brightness(1.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Хлебные крошки на странице документации */
|
/* Хлебные крошки на странице документации — ширина как у .app-main (с дашбордом). */
|
||||||
.doc-breadcrumbs {
|
.doc-breadcrumbs {
|
||||||
max-width: 52rem;
|
|
||||||
margin: 0 auto 0.65rem;
|
|
||||||
padding: 0 0.15rem;
|
|
||||||
}
|
|
||||||
.doc-layout-full .doc-breadcrumbs {
|
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-left: 0;
|
width: 100%;
|
||||||
margin-right: 0;
|
margin: 0 0 0.65rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.doc-breadcrumbs__list {
|
.doc-breadcrumbs__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1455,16 +1541,12 @@ button.btn-danger:hover {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Страница «Документация»: Markdown → карточки секций */
|
/* Страница «Документация»: Markdown → карточки; ширина блока = ширина main (как card на дашборде). */
|
||||||
.readme-doc-shell {
|
.readme-doc-shell {
|
||||||
max-width: 52rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.doc-layout-full .readme-doc-shell {
|
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.readme-doc-loading {
|
.readme-doc-loading {
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
|
|||||||
+25
-8
@@ -5,6 +5,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="alternate icon" href="/favicon.ico" />
|
||||||
<title>{% block page_title %}{{ app_title }}{% endblock %} — kind</title>
|
<title>{% block page_title %}{{ app_title }}{% endblock %} — kind</title>
|
||||||
{# Тема до первой отрисовки: localStorage kind_k8s_theme или prefers-color-scheme #}
|
{# Тема до первой отрисовки: localStorage kind_k8s_theme или prefers-color-scheme #}
|
||||||
<script>
|
<script>
|
||||||
@@ -52,8 +54,11 @@
|
|||||||
</a>
|
</a>
|
||||||
{# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #}
|
{# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #}
|
||||||
<nav class="nav-links top-nav-actions" aria-label="Разделы и оформление">
|
<nav class="nav-links top-nav-actions" aria-label="Разделы и оформление">
|
||||||
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
|
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
|
||||||
<a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
|
<div class="nav-links-scroll">
|
||||||
|
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
|
||||||
|
<a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
|
||||||
|
</div>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -90,6 +95,16 @@
|
|||||||
data-open-window="kind_health"
|
data-open-window="kind_health"
|
||||||
>Health</a>
|
>Health</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href="https://hub.docker.com/r/kindest/node/tags"
|
||||||
|
class="nav-dropdown__link nav-pill--ext"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Список тегов kindest/node на Docker Hub (новое окно)"
|
||||||
|
>Теги образов</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="theme-toggle" class="theme-toggle theme-toggle--nav-end">
|
<button type="button" id="theme-toggle" class="theme-toggle theme-toggle--nav-end">
|
||||||
@@ -199,12 +214,14 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("click", function () {
|
/* Закрытие по клику вне блока API (фаза capture: не конфликтует с открытием по кнопке). */
|
||||||
closeApiMenu();
|
document.addEventListener(
|
||||||
});
|
"click",
|
||||||
dd.addEventListener("click", function (e) {
|
function (e) {
|
||||||
e.stopPropagation();
|
if (!dd.contains(e.target)) closeApiMenu();
|
||||||
});
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
document.addEventListener("keydown", function (e) {
|
document.addEventListener("keydown", function (e) {
|
||||||
if (e.key === "Escape") closeApiMenu();
|
if (e.key === "Escape") closeApiMenu();
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card create-cluster-card">
|
<section class="card create-cluster-card">
|
||||||
<h2>Новый кластер</h2>
|
<h2 class="create-cluster-card__title">Новый кластер</h2>
|
||||||
<form id="form-create" novalidate>
|
<form id="form-create" novalidate>
|
||||||
<div class="create-form-grid">
|
<div class="create-form-grid">
|
||||||
<div class="create-form-col">
|
<div class="create-form-field">
|
||||||
<label for="fld-name">Имя (a-z0-9-)</label>
|
<label for="fld-name">Имя (a-z0-9-)</label>
|
||||||
<input
|
<input
|
||||||
id="fld-name"
|
id="fld-name"
|
||||||
@@ -36,18 +36,31 @@
|
|||||||
placeholder="dev"
|
placeholder="dev"
|
||||||
autocomplete="off"
|
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>
|
</div>
|
||||||
|
|
||||||
<div class="create-form-col">
|
<div class="create-form-field">
|
||||||
<label for="version-select">Тег образа kindest/node (подсказка) или введите версию вручную ниже</label>
|
<label for="version-select">
|
||||||
<div class="row">
|
<a
|
||||||
<select id="version-select" name="kubernetes_version_preset" aria-describedby="ver-hint">
|
href="https://hub.docker.com/r/kindest/node/tags"
|
||||||
<option value="">— загрузить список —</option>
|
target="_blank"
|
||||||
</select>
|
rel="noopener noreferrer"
|
||||||
</div>
|
class="create-form-doc-link"
|
||||||
|
title="Список тегов kindest/node на Docker Hub (новое окно)"
|
||||||
|
>Тег образа</a><span class="create-form-doc-link__ext" aria-hidden="true">↗</span>
|
||||||
|
<span class="create-form-label-rest">
|
||||||
|
kindest/node — список или версия вручную ниже</span>
|
||||||
|
</label>
|
||||||
|
<select id="version-select" name="kubernetes_version_preset">
|
||||||
|
<option value="">— загрузить список —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="create-form-field">
|
||||||
|
<label for="fld-workers">Worker-ноды (0–20)</label>
|
||||||
|
<input id="fld-workers" name="workers" type="number" min="0" max="20" step="1" value="2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="create-form-field">
|
||||||
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
|
<label for="kubernetes_version">Версия Kubernetes / тег node</label>
|
||||||
<input
|
<input
|
||||||
name="kubernetes_version"
|
name="kubernetes_version"
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{# Полная ширина контента и подвала — см. .doc-layout-full в style.css #}
|
{# Ширина колонки как у панели: .app-main max-width 72rem (секция «Ресурсы узлов (сводка)» и др.). #}
|
||||||
{% block body_extra_class %} doc-layout-full{% endblock %}
|
|
||||||
|
|
||||||
{% block page_title %}Документация{% endblock %}
|
{% block page_title %}Документация{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="provision-log-modal-overlay"
|
||||||
|
class="modal-overlay provision-log-modal-overlay hidden"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="provision-log-modal-title"
|
||||||
|
>
|
||||||
|
<div class="modal-box modal-box--provision-log">
|
||||||
|
<div class="modal-head modal-head--cluster">
|
||||||
|
<h3 id="provision-log-modal-title" class="modal-title-text modal-title-text--center">
|
||||||
|
Журнал развёртывания
|
||||||
|
</h3>
|
||||||
|
<div class="modal-toolbar" role="toolbar" aria-label="Закрыть окно">
|
||||||
|
<button type="button" class="modal-close btn-secondary" id="provision-log-modal-close">
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="provision-log-modal-sub" class="muted modal-sub" aria-live="polite"></p>
|
||||||
|
<pre
|
||||||
|
id="provision-log-body"
|
||||||
|
class="mono provision-log-body"
|
||||||
|
role="region"
|
||||||
|
aria-label="JSON журнала развёртывания"
|
||||||
|
></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="confirm-modal-overlay"
|
id="confirm-modal-overlay"
|
||||||
class="modal-overlay confirm-modal-overlay hidden"
|
class="modal-overlay confirm-modal-overlay hidden"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ _SETUP_DEFAULTS: dict[str, str] = {
|
|||||||
"CONTAINER_CLI": "docker",
|
"CONTAINER_CLI": "docker",
|
||||||
"KIND_K8S_SKIP_VERSION_LIST": "",
|
"KIND_K8S_SKIP_VERSION_LIST": "",
|
||||||
"KIND_K8S_VERSION_LIST_DISPLAY": "50",
|
"KIND_K8S_VERSION_LIST_DISPLAY": "50",
|
||||||
"KIND_K8S_HUB_TAGS_MAX_PAGES": "60",
|
"KIND_K8S_HUB_TAGS_MAX_PAGES": "120",
|
||||||
"KIND_K8S_DEBUG": "",
|
"KIND_K8S_DEBUG": "",
|
||||||
"KIND_K8S_WAIT_NODES": "1",
|
"KIND_K8S_WAIT_NODES": "1",
|
||||||
"KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300",
|
"KIND_K8S_WAIT_NODES_TIMEOUT_SEC": "300",
|
||||||
@@ -107,8 +107,16 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
|
|||||||
"KIND_K8S_SKIP_VERSION_LIST",
|
"KIND_K8S_SKIP_VERSION_LIST",
|
||||||
"1 — не запрашивать теги kindest/node с Docker Hub (изолированная сеть); иначе пусто.",
|
"1 — не запрашивать теги kindest/node с Docker Hub (изолированная сеть); иначе пусто.",
|
||||||
),
|
),
|
||||||
_EnvPrompt(None, "KIND_K8S_VERSION_LIST_DISPLAY", "Сколько тегов отдавать в API/списке версий в UI."),
|
_EnvPrompt(
|
||||||
_EnvPrompt(None, "KIND_K8S_HUB_TAGS_MAX_PAGES", "Лимит страниц при обходе Docker Hub API."),
|
None,
|
||||||
|
"KIND_K8S_VERSION_LIST_DISPLAY",
|
||||||
|
"Сколько строк показывать в интерактивном CLI при выборе версии (веб-UI отдаёт полный список).",
|
||||||
|
),
|
||||||
|
_EnvPrompt(
|
||||||
|
None,
|
||||||
|
"KIND_K8S_HUB_TAGS_MAX_PAGES",
|
||||||
|
"Лимит страниц Docker Hub API при сборе тегов (по умолчанию в коде 120, макс. 500); больше страниц — больше старых семверов, дольше запрос.",
|
||||||
|
),
|
||||||
_EnvPrompt(None, "KIND_K8S_DEBUG", "1/true/yes — уровень DEBUG в логах приложения; иначе пусто (INFO)."),
|
_EnvPrompt(None, "KIND_K8S_DEBUG", "1/true/yes — уровень DEBUG в логах приложения; иначе пусто (INFO)."),
|
||||||
_EnvPrompt(
|
_EnvPrompt(
|
||||||
None,
|
None,
|
||||||
|
|||||||
Reference in New Issue
Block a user