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:
Sergey Antropoff
2026-04-04 08:15:15 +03:00
parent 6f3daa33ec
commit 4546f50aef
19 changed files with 723 additions and 103 deletions

View File

@@ -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)

View File

@@ -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 может предложить ввод вручную.
"""

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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:

View File

@@ -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`** (0100) обновляются во время создания.
- В **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.

View File

@@ -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

View File

@@ -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/``)."""

View File

@@ -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-нод (020)")
@@ -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
View 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

View File

@@ -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") {

View File

@@ -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;

View File

@@ -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();

View File

@@ -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-ноды (020)</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-ноды (020)</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"

View File

@@ -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 %}

View File

@@ -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"