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:
@@ -7,6 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
@@ -39,8 +40,11 @@ from core.job_store import (
|
||||
get_logs_snapshot_sync,
|
||||
get_progress_sync,
|
||||
job_store,
|
||||
register_uncapped_job_log,
|
||||
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 kind_k8s_paths import clusters_dir
|
||||
from models.schemas import (
|
||||
@@ -184,6 +188,7 @@ async def list_clusters() -> list[ClusterSummary]:
|
||||
registered_in_kind=bool(summary["registered_in_kind"]),
|
||||
kind_nodes_running=bool(running_map.get(name, False)),
|
||||
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 {},
|
||||
)
|
||||
)
|
||||
@@ -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(
|
||||
"/clusters/{name}/workloads",
|
||||
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:
|
||||
register_uncapped_job_log(job_id)
|
||||
cname = body.name.strip()
|
||||
try:
|
||||
async with kind_cluster_lock:
|
||||
await job_store.set_running(job_id)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
create_cluster_non_interactive,
|
||||
name=body.name.strip(),
|
||||
name=cname,
|
||||
kubernetes_version_tag=body.kubernetes_version.strip(),
|
||||
workers=body.workers,
|
||||
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="Кластер создан")
|
||||
logger.info("create job %s: успех, кластер %s", job_id, result.cluster_name)
|
||||
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)
|
||||
|
||||
|
||||
async def _run_start_cluster_job(job_id: str, name: str, kubernetes_version_tag: str, workers: int) -> None:
|
||||
"""Фоновое создание кластера по уже сохранённому ``kind-config.yaml`` (без kind в списке)."""
|
||||
register_uncapped_job_log(job_id)
|
||||
n = name.strip()
|
||||
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(),
|
||||
name=n,
|
||||
kubernetes_version_tag=kubernetes_version_tag.strip(),
|
||||
workers=workers,
|
||||
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="Кластер поднят по сохранённому конфигу")
|
||||
logger.info("start_cluster job %s: успех, кластер %s", job_id, result.cluster_name)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ router = APIRouter(tags=["versions"])
|
||||
@router.get("/versions", summary="Теги kindest/node")
|
||||
async def list_kindest_versions() -> dict[str, object]:
|
||||
"""
|
||||
Вернуть отсортированный список стабильных тегов (как при интерактивном выборе версии в UI/CLI).
|
||||
Список тегов для выпадающего списка: первым — ``latest``, затем стабильные ``vX.Y.Z`` от новых к старым (>= 1.19).
|
||||
|
||||
При ``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:
|
||||
"""Превратить ввод в тег образа kindest/node (например 1.29.4 → v1.29.4)."""
|
||||
s = raw.strip()
|
||||
"""Превратить ввод в тег образа kindest/node (1.29.4 → v1.29.4) или ``latest``."""
|
||||
s = raw.strip().lower()
|
||||
if not s:
|
||||
return "v1.29.4"
|
||||
s = s.lower().removeprefix("v")
|
||||
if s in ("latest", "vlatest"):
|
||||
return "latest"
|
||||
s = s.removeprefix("v")
|
||||
return f"v{s}"
|
||||
|
||||
|
||||
@@ -451,7 +453,7 @@ def create_cluster_non_interactive(
|
||||
"""
|
||||
Создать кластер 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``).
|
||||
|
||||
@@ -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]:
|
||||
"""Сводка по кластеру для JSON API (без блокирующих долгих вызовов)."""
|
||||
from core.provision_log import provision_log_file_path
|
||||
|
||||
meta = read_meta_json(name) or {}
|
||||
saved_kc = clusters_dir() / name / "kubeconfig"
|
||||
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(),
|
||||
"kubeconfig_path": str(saved_kc) if saved_kc.is_file() else None,
|
||||
"meta": meta,
|
||||
"has_provision_log": provision_log_file_path(name).is_file(),
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -44,6 +44,8 @@ _cancel_events: dict[str, threading.Event] = {}
|
||||
_progress: dict[str, tuple[str, int]] = {}
|
||||
# Хвост логов для активных заданий (kind create и т.д.); после завершения копируется в JobRecord.log_lines
|
||||
_job_log_deques: dict[str, deque[str]] = {}
|
||||
# Полный журнал без лимита строк (только для зарегистрированных job_id) — пишется в clusters/<имя>/provision_log.json
|
||||
_job_uncapped_logs: dict[str, list[str]] = {}
|
||||
# Активный дочерний процесс (pull / kind create) — для принудительной отмены
|
||||
_process_lock = threading.Lock()
|
||||
_active_subprocess_by_job: dict[str, subprocess.Popen] = {}
|
||||
@@ -71,6 +73,19 @@ def _max_job_log_lines() -> int:
|
||||
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:
|
||||
"""Добавить строку в журнал задания (вызывается из worker-thread во время долгих команд)."""
|
||||
text = (line or "").rstrip()
|
||||
@@ -78,6 +93,9 @@ def append_log_sync(job_id: str, line: str) -> None:
|
||||
return
|
||||
cap = _max_job_log_lines()
|
||||
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:
|
||||
_job_log_deques[job_id] = deque(maxlen=cap)
|
||||
_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)
|
||||
_progress.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:
|
||||
|
||||
74
app/core/provision_log.py
Normal file
74
app/core/provision_log.py
Normal file
@@ -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)
|
||||
|
||||
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):
|
||||
print(f" {i:3}) {t}", flush=True)
|
||||
if len(tags) > display_n:
|
||||
|
||||
@@ -39,12 +39,13 @@
|
||||
| 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/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/{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}` |
|
||||
| GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии 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`) |
|
||||
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
|
||||
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
|
||||
@@ -60,7 +61,7 @@
|
||||
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
||||
- В ответе задания поля **`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**).
|
||||
- Буфер строк в памяти на задание: `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-коды.
|
||||
- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов).
|
||||
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.
|
||||
@@ -138,14 +139,19 @@ Accept: text/markdown
|
||||
|
||||
## 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` список пустой.
|
||||
|
||||
**Пример ответа 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": ["v1.32.0", "v1.31.4"],
|
||||
"tags": ["latest", "v1.32.0", "v1.31.4"],
|
||||
"skipped": false
|
||||
}
|
||||
```
|
||||
@@ -242,6 +248,7 @@ Accept: text/markdown
|
||||
"registered_in_kind": true,
|
||||
"kind_nodes_running": true,
|
||||
"has_local_kubeconfig": true,
|
||||
"has_provision_log": true,
|
||||
"meta": {
|
||||
"cluster_name": "dev",
|
||||
"kubernetes_version_tag": "v1.29.4",
|
||||
@@ -254,6 +261,7 @@ Accept: text/markdown
|
||||
```
|
||||
|
||||
- `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
|
||||
|
||||
`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:
|
||||
"""Единый вид тега: ``v1.29.4``."""
|
||||
s = tag.strip().lower().removeprefix("v")
|
||||
"""Единый вид тега: ``v1.29.4`` или ``latest`` (плавающий тег Docker Hub)."""
|
||||
s = tag.strip().lower()
|
||||
if s in ("latest", "vlatest"):
|
||||
return "latest"
|
||||
s = s.removeprefix("v")
|
||||
return f"v{s}"
|
||||
|
||||
|
||||
@@ -86,8 +89,9 @@ def _default_max_hub_pages() -> int:
|
||||
"""Верхняя граница числа запросов к API Hub (защита от бесконечного цикла)."""
|
||||
raw = (os.environ.get("KIND_K8S_HUB_TAGS_MAX_PAGES") or "").strip()
|
||||
if raw.isdigit():
|
||||
return max(1, min(int(raw), 200))
|
||||
return 60
|
||||
return max(1, min(int(raw), 500))
|
||||
# Старые семверы (1.19.x …) часто только на поздних страницах Hub; при необходимости задайте KIND_K8S_HUB_TAGS_MAX_PAGES (до 500).
|
||||
return 120
|
||||
|
||||
|
||||
def fetch_kindest_node_tags(
|
||||
@@ -97,7 +101,9 @@ def fetch_kindest_node_tags(
|
||||
timeout: float = 45.0,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Загрузить теги с Docker Hub (постранично), вернуть отсортированный список ``vX.Y.Z``.
|
||||
Загрузить теги с Docker Hub (постранично).
|
||||
|
||||
Возвращает список: первым элементом всегда ``latest``, далее стабильные ``vX.Y.Z`` от новых к старым (>= minimum).
|
||||
|
||||
При ошибке сети или HTTP возвращает пустой список (в лог — предупреждение).
|
||||
"""
|
||||
@@ -135,12 +141,14 @@ def fetch_kindest_node_tags(
|
||||
)
|
||||
|
||||
tags = merge_sort_unique_tags(collected)
|
||||
out = ["latest"] + tags
|
||||
logger.info(
|
||||
"kindest/node: собрано %s стабильных тегов >= %s.%s.%s (страниц API: %s)",
|
||||
"kindest/node: в списке %s тегов (включая latest), стабильных семверов %s >= %s.%s.%s (страниц API: %s)",
|
||||
len(out),
|
||||
len(tags),
|
||||
minimum[0],
|
||||
minimum[1],
|
||||
minimum[2],
|
||||
page_idx,
|
||||
)
|
||||
return tags
|
||||
return out
|
||||
|
||||
13
app/main.py
13
app/main.py
@@ -16,8 +16,8 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
@@ -96,6 +96,15 @@ async def ui_redirect() -> RedirectResponse:
|
||||
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)")
|
||||
async def documentation_page(request: Request) -> HTMLResponse:
|
||||
"""Оболочка страницы: Markdown подгружается с ``GET /api/v1/docs/readme``, рендер в браузере (marked + DOMPurify из ``/static/js/vendor/``)."""
|
||||
|
||||
@@ -18,7 +18,7 @@ class ClusterCreateRequest(BaseModel):
|
||||
kubernetes_version: str = Field(
|
||||
...,
|
||||
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)")
|
||||
|
||||
@@ -59,6 +59,10 @@ class ClusterSummary(BaseModel):
|
||||
description="Запущены контейнеры узлов kind (имя-control-plane, имя-worker…; docker/podman ps)",
|
||||
)
|
||||
has_local_kubeconfig: bool
|
||||
has_provision_log: bool = Field(
|
||||
default=False,
|
||||
description="Есть сохранённый полный журнал развёртывания (provision_log.json)",
|
||||
)
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
14
app/static/favicon.svg
Normal file
14
app/static/favicon.svg
Normal file
@@ -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 |
@@ -35,6 +35,18 @@
|
||||
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
||||
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() {
|
||||
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
|
||||
}
|
||||
@@ -115,6 +127,37 @@
|
||||
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
|
||||
* @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) {
|
||||
const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig";
|
||||
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>',
|
||||
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>',
|
||||
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} */
|
||||
@@ -618,32 +663,139 @@
|
||||
const sel = document.getElementById("version-select");
|
||||
const verInput = document.getElementById("kubernetes_version");
|
||||
if (!sel || !verInput) return;
|
||||
cachedKindestTags = null;
|
||||
cachedKindestVersionsLoadError = false;
|
||||
cachedKindestTagsSkipped = false;
|
||||
sel.innerHTML = "<option value=\"\">— загрузка —</option>";
|
||||
try {
|
||||
const data = await api("/versions");
|
||||
cachedKindestTagsSkipped = !!data.skipped;
|
||||
sel.innerHTML = "";
|
||||
if (!data.tags || !data.tags.length) {
|
||||
cachedKindestTags = [];
|
||||
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
|
||||
return;
|
||||
}
|
||||
cachedKindestTags = data.tags.slice();
|
||||
const opt0 = document.createElement("option");
|
||||
opt0.value = "";
|
||||
opt0.textContent = "— выберите тег —";
|
||||
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");
|
||||
o.value = t;
|
||||
o.textContent = t;
|
||||
o.textContent =
|
||||
t === "latest" ? "latest (самый новый образ kindest/node)" : t;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
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) {
|
||||
cachedKindestVersionsLoadError = true;
|
||||
cachedKindestTags = null;
|
||||
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) {
|
||||
if (status === "success") return "badge badge-ok";
|
||||
if (status === "failed") return "badge badge-err";
|
||||
@@ -749,6 +901,19 @@
|
||||
!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(
|
||||
iconActionButton(
|
||||
ICONS.trash,
|
||||
@@ -923,18 +1088,26 @@
|
||||
*/
|
||||
function closeConfirmModal(confirmed) {
|
||||
const ov = document.getElementById("confirm-modal-overlay");
|
||||
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||
if (ov) ov.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
||||
if (confirmModalResolver) {
|
||||
var fn = confirmModalResolver;
|
||||
confirmModalResolver = null;
|
||||
fn(!!confirmed);
|
||||
var out = confirmModalIsAlert ? true : !!confirmed;
|
||||
confirmModalIsAlert = false;
|
||||
fn(out);
|
||||
} else {
|
||||
confirmModalIsAlert = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать модальное подтверждение (вместо 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>}
|
||||
*/
|
||||
function openConfirmModal(opts) {
|
||||
@@ -943,18 +1116,30 @@
|
||||
const titleEl = document.getElementById("confirm-modal-title");
|
||||
const msgEl = document.getElementById("confirm-modal-message");
|
||||
const okBtn = document.getElementById("confirm-modal-ok");
|
||||
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||
if (!ov || !titleEl || !msgEl || !okBtn) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
/* Снять предыдущее ожидание без логики «alert = всегда true» (иначе Promise подтверждения ломается). */
|
||||
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;
|
||||
confirmModalIsAlert = !!opts.alert;
|
||||
titleEl.textContent = opts.title || "Подтвердите действие";
|
||||
msgEl.textContent = opts.message || "";
|
||||
okBtn.textContent = opts.confirmLabel || "Подтвердить";
|
||||
okBtn.textContent = opts.confirmLabel || (confirmModalIsAlert ? "Понятно" : "Подтвердить");
|
||||
okBtn.className = opts.danger ? "btn-danger" : "";
|
||||
if (cancelBtn) {
|
||||
cancelBtn.classList.toggle("hidden", confirmModalIsAlert);
|
||||
}
|
||||
ov.classList.remove("hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
okBtn.focus();
|
||||
@@ -1256,10 +1441,47 @@
|
||||
if (details) details.classList.add("hidden");
|
||||
setProgressHint(null, "create");
|
||||
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 = {
|
||||
name: String(fd.get("name") || "").trim(),
|
||||
kubernetes_version: String(fd.get("kubernetes_version") || "").trim(),
|
||||
workers: parseInt(String(fd.get("workers") || "0"), 10),
|
||||
name: nameRaw,
|
||||
kubernetes_version: k8sVer,
|
||||
workers: workersParsed,
|
||||
};
|
||||
try {
|
||||
const res = await api("/clusters", {
|
||||
@@ -1310,7 +1532,11 @@
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
if (isConfirmModalOpen()) {
|
||||
closeConfirmModal(false);
|
||||
closeConfirmModal(confirmModalIsAlert ? true : false);
|
||||
return;
|
||||
}
|
||||
if (isProvisionLogModalOpen()) {
|
||||
closeProvisionLogModal();
|
||||
return;
|
||||
}
|
||||
hideActionTooltip();
|
||||
@@ -1332,7 +1558,18 @@
|
||||
}
|
||||
if (confirmOv) {
|
||||
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();
|
||||
}
|
||||
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
||||
bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -112,14 +112,34 @@ body.modal-open {
|
||||
justify-content: flex-end;
|
||||
flex: 1 1 auto;
|
||||
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;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
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 в отдельном окне */
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
z-index: 160;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
@@ -339,14 +359,6 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext)
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Страница /documentation: основной блок и подвал на всю ширину окна */
|
||||
.doc-layout-full .app-main,
|
||||
.doc-layout-full .app-footer {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
@@ -690,54 +702,112 @@ html[data-theme="light"] .cluster-resources-expand-cell {
|
||||
}
|
||||
|
||||
.cluster-create-hero {
|
||||
margin-bottom: 1.35rem;
|
||||
}
|
||||
|
||||
/* Карточка формы: чуть плотнее внутри, заголовок отступает от полей */
|
||||
.card.create-cluster-card {
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.create-cluster-card {
|
||||
margin-bottom: 1rem;
|
||||
.create-cluster-card__title {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
/* Форма создания: две колонки, кнопка на всю ширину */
|
||||
/* Сетка 2×2: имя | тег, затем подсказка на всю ширину, workers | версия; выравнивание по нижнему краю ячеек */
|
||||
.create-form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem 1.5rem;
|
||||
margin-top: 0.25rem;
|
||||
gap: 0.4rem 1.15rem;
|
||||
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) {
|
||||
.create-form-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: start;
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
|
||||
.create-form-col > label:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.create-form-span {
|
||||
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 {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
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 {
|
||||
margin-top: 0.25rem;
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.15rem;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -1342,6 +1412,26 @@ html[data-theme="light"] .icon-btn--danger {
|
||||
.confirm-modal-overlay {
|
||||
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 {
|
||||
max-width: 24rem;
|
||||
text-align: center;
|
||||
@@ -1407,17 +1497,13 @@ button.btn-danger:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Хлебные крошки на странице документации */
|
||||
/* Хлебные крошки на странице документации — ширина как у .app-main (с дашбордом). */
|
||||
.doc-breadcrumbs {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto 0.65rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.doc-layout-full .doc-breadcrumbs {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
margin: 0 0 0.65rem;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.doc-breadcrumbs__list {
|
||||
display: flex;
|
||||
@@ -1455,16 +1541,12 @@ button.btn-danger:hover {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Страница «Документация»: Markdown → карточки секций */
|
||||
/* Страница «Документация»: Markdown → карточки; ширина блока = ширина main (как card на дашборде). */
|
||||
.readme-doc-shell {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.doc-layout-full .readme-doc-shell {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.readme-doc-loading {
|
||||
margin: 0 0 0.75rem;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
{# Тема до первой отрисовки: localStorage kind_k8s_theme или prefers-color-scheme #}
|
||||
<script>
|
||||
@@ -52,8 +54,11 @@
|
||||
</a>
|
||||
{# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #}
|
||||
<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>
|
||||
<a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
|
||||
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -90,6 +95,16 @@
|
||||
data-open-window="kind_health"
|
||||
>Health</a>
|
||||
</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>
|
||||
</div>
|
||||
<button type="button" id="theme-toggle" class="theme-toggle theme-toggle--nav-end">
|
||||
@@ -199,12 +214,14 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", function () {
|
||||
closeApiMenu();
|
||||
});
|
||||
dd.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
/* Закрытие по клику вне блока API (фаза capture: не конфликтует с открытием по кнопке). */
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
if (!dd.contains(e.target)) closeApiMenu();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") closeApiMenu();
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
</section>
|
||||
|
||||
<section class="card create-cluster-card">
|
||||
<h2>Новый кластер</h2>
|
||||
<h2 class="create-cluster-card__title">Новый кластер</h2>
|
||||
<form id="form-create" novalidate>
|
||||
<div class="create-form-grid">
|
||||
<div class="create-form-col">
|
||||
<div class="create-form-field">
|
||||
<label for="fld-name">Имя (a-z0-9-)</label>
|
||||
<input
|
||||
id="fld-name"
|
||||
@@ -36,18 +36,31 @@
|
||||
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>
|
||||
|
||||
<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>
|
||||
<div class="create-form-field">
|
||||
<label for="version-select">
|
||||
<a
|
||||
href="https://hub.docker.com/r/kindest/node/tags"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
<input
|
||||
name="kubernetes_version"
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{# Полная ширина контента и подвала — см. .doc-layout-full в style.css #}
|
||||
{% block body_extra_class %} doc-layout-full{% endblock %}
|
||||
{# Ширина колонки как у панели: .app-main max-width 72rem (секция «Ресурсы узлов (сводка)» и др.). #}
|
||||
|
||||
{% block page_title %}Документация{% endblock %}
|
||||
|
||||
|
||||
@@ -42,6 +42,34 @@
|
||||
</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
|
||||
id="confirm-modal-overlay"
|
||||
class="modal-overlay confirm-modal-overlay hidden"
|
||||
|
||||
Reference in New Issue
Block a user