From 4546f50aef4c71e6746683e5b404cce12f5a6c36 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 08:15:15 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F,=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F,=20favicon;=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=20=D1=80=D0=B0=D0=B7=D0=B2=D1=91=D1=80?= =?UTF-8?q?=D1=82=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F;=20=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Меню 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, стили, шаблоны) --- README.md | 4 +- app/api/v1/endpoints/clusters.py | 62 ++++- app/api/v1/endpoints/versions.py | 2 +- app/core/cluster_lifecycle.py | 13 +- app/core/job_store.py | 19 ++ app/core/provision_log.py | 74 ++++++ app/create_cluster.py | 5 +- app/docs/api_routes.md | 49 +++- app/kindest_node_tags.py | 22 +- app/main.py | 13 +- app/models/schemas.py | 6 +- app/static/favicon.svg | 14 + app/static/js/dashboard.js | 262 ++++++++++++++++++- app/static/style.css | 166 +++++++++--- app/templates/base.html | 33 ++- app/templates/cluster_create.html | 37 ++- app/templates/documentation.html | 3 +- app/templates/partials/dashboard_modals.html | 28 ++ scripts/setup_env_interactive.py | 14 +- 19 files changed, 723 insertions(+), 103 deletions(-) create mode 100644 app/core/provision_log.py create mode 100644 app/static/favicon.svg 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 #}