diff --git a/README.md b/README.md
index 85f6b98..08b38f9 100644
--- a/README.md
+++ b/README.md
@@ -148,8 +148,8 @@ docker compose exec kind-k8s-web kubectl --kubeconfig=/work/clusters/<имя>/ku
| **`KIND_K8S_PATCH_KUBECONFIG`** | контейнер | Патч `server` в kubeconfig для хоста; по умолчанию **включено** (`1` в compose и в `make setup`) |
| **`CONTAINER_CLI`** | контейнер | CLI для `docker port` / `podman port` (`docker` или `podman`) |
| **`KIND_K8S_SKIP_VERSION_LIST`** | контейнер | Не ходить в Docker Hub за тегами |
-| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько тегов отдавать в API/UI |
-| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Лимит страниц API Hub |
+| **`KIND_K8S_VERSION_LIST_DISPLAY`** | контейнер | Сколько строк показывать в **интерактивном CLI** при выборе версии (веб-UI выводит полный список из API) |
+| **`KIND_K8S_HUB_TAGS_MAX_PAGES`** | контейнер | Сколько страниц Docker Hub обходить при сборе тегов (старые 1.19.x часто на поздних страницах; в коде по умолчанию **120**, максимум **500**) |
| **`KIND_K8S_DEBUG`** | контейнер | `1`/`true`/`yes`/`да` — уровень DEBUG в логах |
| **`KIND_K8S_JOB_LOG_MAX_LINES`** | приложение | Сколько строк журнала хранить в памяти на задание (старые вытесняются); по умолчанию **2500** |
| **`KIND_K8S_JOB_API_LOG_MAX_LINES`** | приложение | Сколько строк отдавать в **GET /api/v1/jobs/{id}** (хвост); по умолчанию **5000**, максимум **20000** |
diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py
index c7ecf62..2501e2d 100644
--- a/app/api/v1/endpoints/clusters.py
+++ b/app/api/v1/endpoints/clusters.py
@@ -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)
diff --git a/app/api/v1/endpoints/versions.py b/app/api/v1/endpoints/versions.py
index a12e561..e3a4d90 100644
--- a/app/api/v1/endpoints/versions.py
+++ b/app/api/v1/endpoints/versions.py
@@ -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 может предложить ввод вручную.
"""
diff --git a/app/core/cluster_lifecycle.py b/app/core/cluster_lifecycle.py
index fff0eed..ba74449 100644
--- a/app/core/cluster_lifecycle.py
+++ b/app/core/cluster_lifecycle.py
@@ -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
diff --git a/app/core/job_store.py b/app/core/job_store.py
index 7db1138..74f0813 100644
--- a/app/core/job_store.py
+++ b/app/core/job_store.py
@@ -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:
diff --git a/app/core/provision_log.py b/app/core/provision_log.py
new file mode 100644
index 0000000..2848c9d
--- /dev/null
+++ b/app/core/provision_log.py
@@ -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
diff --git a/app/create_cluster.py b/app/create_cluster.py
index c949c70..6992ae2 100644
--- a/app/create_cluster.py
+++ b/app/create_cluster.py
@@ -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:
diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md
index ee02e20..8ebf58e 100644
--- a/app/docs/api_routes.md
+++ b/app/docs/api_routes.md
@@ -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.
diff --git a/app/kindest_node_tags.py b/app/kindest_node_tags.py
index 8bc60c0..f6a6f80 100644
--- a/app/kindest_node_tags.py
+++ b/app/kindest_node_tags.py
@@ -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
diff --git a/app/main.py b/app/main.py
index d81fdb6..c743425 100644
--- a/app/main.py
+++ b/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/``)."""
diff --git a/app/models/schemas.py b/app/models/schemas.py
index 50598a4..d84c580 100644
--- a/app/models/schemas.py
+++ b/app/models/schemas.py
@@ -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)
diff --git a/app/static/favicon.svg b/app/static/favicon.svg
new file mode 100644
index 0000000..b26a321
--- /dev/null
+++ b/app/static/favicon.svg
@@ -0,0 +1,14 @@
+
+
+
diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js
index c97232d..e227686 100644
--- a/app/static/js/dashboard.js
+++ b/app/static/js/dashboard.js
@@ -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 @@
'',
trash:
'',
+ logFile:
+ '',
};
/** @type {ReturnType | 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 = "";
try {
const data = await api("/versions");
+ cachedKindestTagsSkipped = !!data.skipped;
sel.innerHTML = "";
if (!data.tags || !data.tags.length) {
+ cachedKindestTags = [];
sel.innerHTML = "";
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 = "";
}
}
+ /**
+ * Имя кластера: 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}
*/
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") {
diff --git a/app/static/style.css b/app/static/style.css
index bd5a710..e4fd96b 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -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;
diff --git a/app/templates/base.html b/app/templates/base.html
index 03665ac..f43564b 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -5,6 +5,8 @@
+
+
{% block page_title %}{{ app_title }}{% endblock %} — kind
{# Тема до первой отрисовки: localStorage kind_k8s_theme или prefers-color-scheme #}