From f01db8d4b7493ba794cc90661ca8e8bd73352850 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Sat, 4 Apr 2026 06:27:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20kubectl=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80?= =?UTF-8?q?=D0=B0;=20Kind=20Clusters=20Dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Цель make docker|podman kubectl CLUSTER=… (KUBECTL_ARGS) — exec kubectl в kind-k8s-web - README: без kubectl на хосте; раздел про проверку API из контейнера - create_cluster/cluster_status: подсказки для UI, make kubectl и exec в контейнере - app/docs: api_routes.md и README.md про kubectl и API workloads - Прочее: переименование проекта, документация, UI документации (ранее в рабочем дереве) --- Makefile | 24 ++- README.md | 41 +++-- app/api/v1/endpoints/docs_readme.py | 43 ++++- app/cluster_status.py | 13 +- app/core/__init__.py | 2 +- app/core/config.py | 2 +- app/core/readme_doc.py | 47 +++++- app/create_cluster.py | 12 +- app/delete_cluster.py | 2 +- app/docs/README.md | 2 +- app/docs/api_routes.md | 39 ++++- app/main.py | 4 +- app/static/js/documentation.js | 243 ++++++++++++++++++++++++---- app/static/style.css | 106 +++++++----- app/templates/base.html | 8 +- app/templates/documentation.html | 18 +-- scripts/setup_env_interactive.py | 8 +- 17 files changed, 483 insertions(+), 131 deletions(-) diff --git a/Makefile b/Makefile index c32c026..f1d80f1 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -# kind-k8s-develop — веб-интерфейс (FastAPI) для kind. +# Kind Clusters Dashboard — веб-интерфейс (FastAPI) для kind. # Создание кластеров — в браузере: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT; 6000 на хосте — ERR_UNSAFE_PORT в Chrome). # # Все операции с Compose только с явным выбором среды: # make docker up | make docker down | make docker logs | … # make podman up | make podman down | … # make docker rebuild / make podman rebuild — образ без кэша и пересоздание контейнера -# Без префикса docker/podman цели up/down/logs/ps/compose-build/rebuild/check-docker завершатся с подсказкой. +# Без префикса docker/podman цели up/down/logs/ps/compose-build/rebuild/check-docker/kubectl завершатся с подсказкой. # # Автор: Сергей Антропов — https://devops.org.ru @@ -15,13 +15,16 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS))) COMPOSE := docker compose endif -.PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build rebuild +.PHONY: help docker podman _require_runtime up down logs ps setup clusters-dir check-docker compose-build rebuild kubectl KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py PYTHON ?= python3 # При «exec format error» у kind: make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64 COMPOSE_BUILD_FLAGS ?= +# Для цели kubectl: имя кластера и аргументы kubectl после --kubeconfig (по умолчанию: get nodes). +CLUSTER ?= +KUBECTL_ARGS ?= get nodes help: ## Справка по целям @echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:" @@ -32,6 +35,7 @@ help: ## Справка по целям @echo " make docker compose-build / make podman compose-build" @echo " make docker rebuild / make podman rebuild (build --no-cache + up --force-recreate)" @echo " make docker check-docker / make podman check-docker" + @echo " make docker kubectl CLUSTER=<имя> — kubectl в контейнере (см. KUBECTL_ARGS, по умолчанию get nodes)" @echo "Без установки Compose: make setup, make clusters-dir (python3 для setup)." @grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?##"} {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' @@ -41,12 +45,12 @@ docker: ## Маркер среды: задайте вторую цель (нап podman: ## Маркер среды: задайте вторую цель (например: make podman up) @: -# Общая проверка: цели up/down/logs/ps/compose-build/rebuild/check-docker — только make docker … / make podman … +# Общая проверка: цели up/down/logs/ps/compose-build/rebuild/check-docker/kubectl — только make docker … / make podman … _require_runtime: @if [ -z "$(COMPOSE)" ]; then \ echo >&2 "Укажите среду в той же команде, что и цель:"; \ echo >&2 " make docker up | make podman up"; \ - echo >&2 " make docker down | make docker logs | make docker ps | make docker compose-build | make docker rebuild | make docker check-docker"; \ + echo >&2 " make docker down | make docker logs | make docker ps | make docker compose-build | make docker rebuild | make docker check-docker | make docker kubectl CLUSTER=…"; \ echo >&2 " (или то же с префиксом podman)"; \ exit 1; \ fi @@ -77,6 +81,16 @@ check-docker: _require_runtime ## (с docker/podman) Проверить CLI и c @$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; } @echo "$(COMPOSE): OK" +# kubectl и kind в образе; kubeconfig в томе /work/clusters//kubeconfig — kubectl на хосте не нужен. +kubectl: _require_runtime ## (с docker/podman) kubectl в контейнере: CLUSTER=имя [KUBECTL_ARGS="get pods -A"] + @if [ -z "$(CLUSTER)" ]; then \ + echo >&2 "Задайте CLUSTER=<имя_кластера> (каталог в ./clusters/)."; \ + echo >&2 "Пример: make docker kubectl CLUSTER=dev"; \ + echo >&2 "Свои подкоманды: make docker kubectl CLUSTER=dev KUBECTL_ARGS=\"get pods -A\""; \ + exit 1; \ + fi + cd "$(KIND_K8S_DIR)" && $(COMPOSE) exec kind-k8s-web kubectl --kubeconfig=/work/clusters/$(CLUSTER)/kubeconfig $(KUBECTL_ARGS) + compose-build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) diff --git a/README.md b/README.md index 61f3713..b80cb05 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# kind-k8s-develop — локальные кластеры Kubernetes (kind) +# Kind Clusters Dashboard — локальные кластеры Kubernetes (kind) Образ **kind-k8s-tools:local** и **Makefile** поднимают **веб-интерфейс** (FastAPI) на порту **8080** на хосте по умолчанию (или **`KIND_K8S_WEB_PORT`** в `.env`; внутри контейнера приложение слушает **6000**). Порт **6000 на хосте** не используем по умолчанию: Chrome и другие браузеры на Chromium отдают **ERR_UNSAFE_PORT**. Через браузер создаёте и удаляете кластеры, смотрите статистику и вывод `kubectl`. **kubeconfig** сохраняется в `clusters/<имя>/`. На хосте достаточно **Docker** (или Podman) и **make**; **kind** и **kubectl** — внутри контейнера. @@ -19,7 +19,7 @@ - Быстро получить Kubernetes без облака (интеграционные тесты, манифесты, обучение). - Версия кластера и число worker-нод задаются **в веб-UI** (или через REST API / скрипты в контейнере). - Количество кластеров **не ограничено** кодом (ограничения — ресурсы хоста и Docker). -- Артефакты на хосте: `clusters/<имя>/` — удобно указать путь к `kubeconfig` в приложении или в `kubectl`. +- Артефакты на хосте: `clusters/<имя>/` — том для `kubeconfig` (доступен в контейнере как `/work/clusters/<имя>/`). ## Веб-интерфейс @@ -43,9 +43,10 @@ | **Docker** + **Compose v2** (или **Podman** + compose) | Сборка образа и запуск веб-сервиса | | **make** | `make docker up` / `make podman up` и вспомогательные цели | | **python3** | Только для **`make setup`** (создание `.env`) | -| **kubectl** (опционально) | Проверка API с хоста: `kubectl --kubeconfig=clusters/<имя>/kubeconfig get nodes` | -**В образ не обязательно ставить на хост:** kind, Python приложения — они внутри контейнера. +**На хост не нужны:** **kind**, **kubectl**, Python приложения — всё это в образе `kind-k8s-tools:local` и выполняется в контейнере **`kind-k8s-web`**. Проверка API и узлов: **веб-интерфейс** (кластер → узлы/поды) или **`make docker kubectl CLUSTER=<имя>`** / **`make podman kubectl …`** (см. ниже) — `kubectl` вызывается через **`docker compose exec`** / **`podman compose exec`** внутри уже запущенного сервиса. + +Файл **`clusters/<имя>/kubeconfig`** на хосте можно использовать **опционально**, если у вас локально установлен kubectl (например IDE или отладка) — после патча apiserver обычно указывает на `127.0.0.1:<порт>`. Смонтированы **сокет** Docker/Podman и каталог **`./clusters`** → в контейнере **`/work/clusters`**. Каталог **`./app`** монтируется в **`/opt/kind-k8s/app`** для разработки без пересборки образа. Файл **`./README.md`** монтируется в **`/opt/kind-k8s/README.md`** (страница **«Документация»** и **`GET /api/v1/docs/readme`** без пересборки образа). @@ -54,14 +55,14 @@ ## Быстрый старт ```bash -cd kind-k8s-develop +# Из корня клонированного репозитория Kind Clusters Dashboard (рядом с Makefile): make setup # опционально: интерактивно создать .env (Enter — дефолты из скрипта) make docker check-docker # или: make podman check-docker make docker up # или: make podman up # Браузер: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT в .env; не 6000 на хосте — Chrome ERR_UNSAFE_PORT) ``` -Из родительского каталога: `make -C kind-k8s-develop docker up`. +Из родительского каталога: `make -C <каталог-корня-репозитория> docker up` (подставьте путь к каталогу с `Makefile`). **Логи, статус и остановка:** `make docker logs` / `make podman logs` (follow), `make docker ps` / `make podman ps`, `make docker down` / `make podman down`. @@ -87,7 +88,26 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \ Рабочий каталог сервиса в образе — `/opt/kind-k8s/app`; том `clusters/` и сокет те же, что у `docker compose up`. -После успешного `kind create` по умолчанию выполняется **`kubectl wait`** готовности нод (`KIND_K8S_WAIT_NODES`, `KIND_K8S_WAIT_NODES_TIMEOUT_SEC` в `.env`). +### kubectl без установки на хост + +Пока запущен веб-сервис (`make docker up` или `make podman up`), **kubectl** из образа: + +```bash +# По умолчанию: get nodes (kubeconfig: /work/clusters/<имя>/kubeconfig внутри контейнера) +make docker kubectl CLUSTER=<имя_кластера> +# или: make podman kubectl CLUSTER=<имя_кластера> + +make docker kubectl CLUSTER=<имя> KUBECTL_ARGS="get pods -A" +make docker kubectl CLUSTER=<имя> KUBECTL_ARGS="config view --minify" +``` + +Эквивалент вручную (из корня репозитория, **Docker**): + +```bash +docker compose exec kind-k8s-web kubectl --kubeconfig=/work/clusters/<имя>/kubeconfig get nodes +``` + +После успешного `kind create` по умолчанию выполняется **`kubectl wait`** готовности нод (`KIND_K8S_WAIT_NODES`, `KIND_K8S_WAIT_NODES_TIMEOUT_SEC` в `.env`) — тоже **внутри** контейнера приложения. ## Команды Makefile @@ -101,11 +121,12 @@ docker compose run --rm --entrypoint python3 kind-k8s-web \ | `make docker compose-build` / `make podman compose-build` | Собрать образ `kind-k8s-tools:local` | | `make docker rebuild` / `make podman rebuild` | Пересборка образа **без кэша** (`build --no-cache`) и пересоздание контейнера (`up -d --force-recreate`) | | `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` | +| `make docker kubectl CLUSTER=…` / `make podman kubectl CLUSTER=…` | **kubectl** в контейнере `kind-k8s-web` (опционально `KUBECTL_ARGS="…"`; по умолчанию `get nodes`). Сервис должен быть **up**. | | `make setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) | | `make clusters-dir` | Создать каталог `clusters/` | -| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild`, `check-docker` | +| `make docker …` / `make podman …` | Префикс **обязателен** для целей `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild`, `check-docker`, `kubectl` | -Цели `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild` и `check-docker` **без** `docker`/`podman` в той же команде завершатся с подсказкой. +Цели `up`, `down`, `logs`, `ps`, `compose-build`, `rebuild`, `check-docker` и `kubectl` **без** `docker`/`podman` в той же команде завершатся с подсказкой. ## Переменные окружения @@ -179,6 +200,6 @@ make podman up - Образ `kindest/node:v…` должен быть доступен для pull. - На **Windows** без WSL удобнее WSL2 + Docker Desktop. -- Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера). +- **kubectl** на хосте **не обязателен**: используйте веб-UI или **`make docker kubectl`** / **`make podman kubectl`** (см. выше). - История заданий создания в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается. - При **`exec format error`** у kind пересоберите образ: `make docker rebuild COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `compose-build` без `--no-cache`, или `linux/amd64`). diff --git a/app/api/v1/endpoints/docs_readme.py b/app/api/v1/endpoints/docs_readme.py index 64e05a6..b81fbbd 100644 --- a/app/api/v1/endpoints/docs_readme.py +++ b/app/api/v1/endpoints/docs_readme.py @@ -1,4 +1,4 @@ -"""Отдача сырого README.md для клиентского рендера Markdown (marked в static). +"""Отдача README.md и файлов ``app/docs/*.md`` для клиентского рендера Markdown (marked в static). Автор: Сергей Антропов Сайт: https://devops.org.ru @@ -9,10 +9,10 @@ from __future__ import annotations import asyncio import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from fastapi.responses import PlainTextResponse -from core.readme_doc import read_readme_text +from core.readme_doc import read_app_docs_file_text, read_readme_text logger = logging.getLogger("kind_k8s.api.docs_readme") @@ -51,3 +51,40 @@ async def get_readme_markdown() -> PlainTextResponse: content=text, media_type="text/markdown; charset=utf-8", ) + + +@router.get( + "/docs/file", + response_class=PlainTextResponse, + summary="Файл Markdown под app/docs/", + responses={400: {"description": "Некорректный путь"}, 403: {"description": "Вне app/docs"}, 404: {"description": "Нет файла"}}, +) +async def get_app_docs_file( + path: str = Query( + ..., + description="Относительный путь, например app/docs/api_routes.md", + examples=["app/docs/api_routes.md"], + ), +) -> PlainTextResponse: + """ + Только файлы ``*.md`` с префиксом ``app/docs/`` (без ``..``). + + Ссылки из README ведут на ``/documentation?path=...``; клиент запрашивает этот эндпоинт. + """ + try: + + def _read() -> str: + return read_app_docs_file_text(path) + + text = await asyncio.to_thread(_read) + except FileNotFoundError: + logger.info("GET /docs/file: не найден или запрещён путь %s", path) + raise HTTPException( + status_code=404, + detail="Файл не найден или путь не разрешён (ожидается app/docs/<имя>.md).", + ) from None + logger.debug("GET /docs/file: отдан %s", path) + return PlainTextResponse( + content=text, + media_type="text/markdown; charset=utf-8", + ) diff --git a/app/cluster_status.py b/app/cluster_status.py index fd71d15..9d4367e 100755 --- a/app/cluster_status.py +++ b/app/cluster_status.py @@ -104,7 +104,7 @@ def _print_cluster(name: str, *, kube_path_saved: Path | None) -> None: use_path: str | None = None if kube_path_saved and kube_path_saved.is_file(): use_path = str(kube_path_saved) - print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после создания кластера).") + print(" Проверка API: kubectl с сохранённым kubeconfig (тот же файл, что пишет create / UI).") tmp_kc: str | None = None if not use_path: @@ -148,7 +148,16 @@ def main() -> None: ) sys.exit(127) if not shutil.which("kubectl"): - print("Не найден kubectl.", file=sys.stderr) + print("Не найден kubectl в PATH.", file=sys.stderr) + print( + " Запустите скрипт внутри контейнера приложения (там kubectl в образе), например:", + file=sys.stderr, + ) + print( + " docker compose exec kind-k8s-web python3 /opt/kind-k8s/app/cluster_status.py <имя>", + file=sys.stderr, + ) + print(" Либо смотрите узлы в веб-интерфейсе; на хост kubectl не обязателен.", file=sys.stderr) sys.exit(127) names = _kind_cluster_names() diff --git a/app/core/__init__.py b/app/core/__init__.py index e37f910..dbb1897 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,4 +1,4 @@ -"""Вспомогательная логика для API и общих операций kind-k8s-develop. +"""Вспомогательная логика для API и общих операций Kind Clusters Dashboard. Автор: Сергей Антропов Сайт: https://devops.org.ru diff --git a/app/core/config.py b/app/core/config.py index 323c7bb..6852c10 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -12,7 +12,7 @@ from __future__ import annotations from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -_DEFAULT_TITLE = "kind-k8s-develop" +_DEFAULT_TITLE = "Kind Clusters Dashboard" class Settings(BaseSettings): diff --git a/app/core/readme_doc.py b/app/core/readme_doc.py index e31a665..82d1487 100644 --- a/app/core/readme_doc.py +++ b/app/core/readme_doc.py @@ -1,10 +1,11 @@ -"""Чтение README.md для API ``GET /api/v1/docs/readme`` и страницы «Документация». +"""Чтение README.md и безопасное чтение ``app/docs/*.md`` для API документации. Разметка Markdown преобразуется в браузере: ``/static/js/vendor/marked.min.js`` и ``purify.min.js`` (файлы входят в репозиторий, без CDN). -Путь к файлу: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``; -в Docker-образе — ``/opt/kind-k8s/README.md``. +README: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``; +в Docker-образе — ``/opt/kind-k8s/README.md``. Файлы под ``app/docs/`` — через +``resolve_app_docs_markdown`` / ``read_app_docs_file_text`` (эндпоинт ``GET /api/v1/docs/file``). Автор: Сергей Антропов Сайт: https://devops.org.ru @@ -86,3 +87,43 @@ def read_readme_text() -> str: [str(x) for x in _candidates_without_env()], ) raise FileNotFoundError("README.md") + + +def repo_root() -> Path: + """Корень репозитория (родитель каталога ``app/``).""" + return _LIB_FILE.parents[2] + + +def resolve_app_docs_markdown(relative_path: str) -> Path | None: + """ + Безопасно разрешить путь к ``.md`` только внутри ``app/docs/``. + + Ожидается вид ``app/docs/api_routes.md`` (без ``..`` и абсолютных путей). + """ + raw = (relative_path or "").strip().lstrip("/").replace("\\", "/") + if not raw or ".." in raw or "\x00" in raw: + return None + if not raw.endswith(".md"): + return None + if not raw.startswith("app/docs/"): + return None + base = repo_root() + try: + full = (base / raw).resolve() + allowed = (base / "app" / "docs").resolve() + full.relative_to(allowed) + except (ValueError, OSError): + return None + if not full.is_file(): + return None + return full + + +def read_app_docs_file_text(relative_path: str) -> str: + """Прочитать UTF-8 текст файла под ``app/docs/``; ``FileNotFoundError`` если путь недопустим или файла нет.""" + p = resolve_app_docs_markdown(relative_path) + if p is None: + raise FileNotFoundError(relative_path) + text = p.read_text(encoding="utf-8") + logger.debug("Документ app/docs: %s, %s символов", p, len(text)) + return text diff --git a/app/create_cluster.py b/app/create_cluster.py index f4088c2..c949c70 100644 --- a/app/create_cluster.py +++ b/app/create_cluster.py @@ -153,7 +153,7 @@ def _run_interactive() -> None: if not _which("kind"): print("Не найден бинарник kind.", file=sys.stderr) print(" Установка kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", file=sys.stderr) - print(" Через Docker: make -C kind-k8s-develop docker up и веб-интерфейс.", file=sys.stderr) + print(" Через Docker: из корня репозитория выполните «make docker up» и откройте веб-интерфейс.", file=sys.stderr) sys.exit(127) cli = _container_cli_bin() if not _which(cli): @@ -194,13 +194,15 @@ def _run_interactive() -> None: print("\nГотово.") print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}") if _in_container(): - print(f" Том на хосте: kind-k8s-develop/clusters/{result.cluster_name}/ (рядом с Makefile)") + print(f" Том на хосте: clusters/{result.cluster_name}/ в корне репозитория (рядом с Makefile)") print( - f' Проверка с хоста (из каталога репозитория): kubectl --kubeconfig="$(pwd)/clusters/{result.cluster_name}/kubeconfig" get nodes', + f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes", ) else: - print(f" Проверка: KUBECONFIG={result.kubeconfig_path} kubectl get nodes") - print(f" Или: kubectl --kubeconfig={result.kubeconfig_path} get nodes") + print(" Проверка без kubectl на хосте: веб-интерфейс (кластер → узлы/поды) или из корня репозитория:") + print(f" make docker kubectl CLUSTER={result.cluster_name} # или: make podman kubectl …") + print(" (сервис kind-k8s-web должен быть запущен: make docker up).") + print(f" Локально при установленном kubectl: kubectl --kubeconfig={result.kubeconfig_path} get nodes") if result.kubeconfig_patched_for_host: print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.") if result.nodes_ready is False and result.nodes_ready_message: diff --git a/app/delete_cluster.py b/app/delete_cluster.py index 7d90eae..5c9b938 100644 --- a/app/delete_cluster.py +++ b/app/delete_cluster.py @@ -42,7 +42,7 @@ def _interactive() -> None: CLUSTERS_DIR = clusters_dir() if not shutil.which("kind"): print("Не найден kind.", file=sys.stderr) - print(" Через Docker: make -C kind-k8s-develop docker up и удаление в веб-интерфейсе.", file=sys.stderr) + print(" Через Docker: из корня репозитория «make docker up» и удаление в веб-интерфейсе.", file=sys.stderr) sys.exit(127) clusters = _list_kind_clusters() diff --git a/app/docs/README.md b/app/docs/README.md index 9fbbf2b..e630e6d 100644 --- a/app/docs/README.md +++ b/app/docs/README.md @@ -6,6 +6,6 @@ |------|------------| | [api_routes.md](api_routes.md) | Полное описание REST API `/api/v1/*` с примерами JSON (ориентир для фронтенда и клиентов). | -После запуска: **Swagger** — `/docs`, **ReDoc** — `/redoc`, **Health** — `/api/v1/health` (тот же порт, что и UI). С дашборда эти ссылки открываются в **отдельном окне** браузера. +После запуска: **Swagger** — `/docs`, **ReDoc** — `/redoc`, **Health** — `/api/v1/health` (тот же порт, что и UI). С дашборда эти ссылки открываются в **отдельном окне** браузера. **kubectl** на машине разработчика не нужен: он в образе; см. **README.md** — цель **`make docker kubectl`** / **`make podman kubectl`** и API **`/api/v1/clusters/{name}/workloads`**. **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index e8f12d5..6169d63 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -1,4 +1,4 @@ -# Описание REST API веб-интерфейса kind-k8s-develop +# Описание REST API веб-интерфейса Kind Clusters Dashboard **Базовый префикс:** `/api/v1` **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) @@ -10,7 +10,7 @@ | Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) | | ReDoc | `http://127.0.0.1:<порт>/redoc` | | Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` | -| Документация проекта | `http://127.0.0.1:<порт>/documentation` — **README.md**: текст с `GET /api/v1/docs/readme`, рендер **Markdown** в браузере (**marked** + **DOMPurify** из `app/static/js/vendor/`, без CDN) | +| Документация проекта | `http://127.0.0.1:<порт>/documentation` — по умолчанию **README** (`GET /api/v1/docs/readme`); ссылки на `app/docs/*.md` открываются в той же странице (`?path=...` + `GET /api/v1/docs/file`); рендер **Markdown** в браузере (**marked** + **DOMPurify** из `app/static/js/vendor/`, без CDN) | | Этот файл | `app/docs/api_routes.md` в репозитории | С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). Пункт **Документация** открывает `GET /documentation` в той же вкладке. @@ -20,12 +20,14 @@ | Маршрут | Описание | |---------|----------| | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** `kind create`, отмена), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | -| `GET /documentation` | HTML-оболочка; контент — запрос к **`GET /api/v1/docs/readme`** и разбор Markdown скриптами из **`/static/js/vendor/`** (marked, DOMPurify). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | +| `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path` — **`GET /api/v1/docs/readme`**, с `?path=app/docs/…` — **`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Секции **H2** показываются отдельными карточками (заголовок и тело). В шапке активна пилюля **Документация**. Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /ui` | Редирект **307** на `/` (удобный ярлык). | -| `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | +| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `` (по умолчанию `/api/v1`). | Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/documentation.html` (README). +**kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API (**`GET /api/v1/clusters/{name}/workloads`**) и веб-UI. Для интерактивной консоли из корня репозитория при запущенном compose: **`make docker kubectl CLUSTER=<имя>`** (или **`make podman kubectl …`**), внутри контейнера kubeconfig — **`/work/clusters/<имя>/kubeconfig`**. Подробности — **README.md** (раздел «kubectl без установки на хост»). + --- ## Сводка маршрутов API @@ -33,7 +35,8 @@ | Метод | Путь | Кратко | |-------|------|--------| | GET | `/api/v1/health` | Среда: kind, kubectl, движок контейнеров | -| GET | `/api/v1/docs/readme` | Текст **README.md** (`text/markdown`; для страницы `/documentation`) | +| GET | `/api/v1/docs/readme` | Текст **README.md** (`text/markdown`; страница `/documentation` без `path`) | +| GET | `/api/v1/docs/file` | Текст одного **`.md`** под `app/docs/` (query `path=app/docs/…`; для `/documentation?path=…`) | | GET | `/api/v1/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` | | GET | `/api/v1/stats` | Сводка для дашборда | | GET | `/api/v1/clusters` | Список кластеров | @@ -103,6 +106,32 @@ --- +## GET /api/v1/docs/file + +Параметр запроса **`path`** — относительный путь вида **`app/docs/<имя>.md`**. Допускаются только такие пути (префикс `app/docs/`, расширение `.md`, без `..`); иначе **404**. + +Тело ответа — UTF-8 Markdown, **`Content-Type: text/markdown; charset=utf-8`**. + +**Пример запроса:** + +```http +GET /api/v1/docs/file?path=app%2Fdocs%2Fapi_routes.md HTTP/1.1 +Host: 127.0.0.1:8080 +Accept: text/markdown +``` + +**Пример начала тела ответа 200** (не JSON, текст Markdown): + +```markdown +# Описание REST API веб-интерфейса Kind Clusters Dashboard + +**Базовый префикс:** `/api/v1` +``` + +Логика проверки пути: `app/core/readme_doc.py` (`resolve_app_docs_markdown`). + +--- + ## GET /api/v1/versions Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI). diff --git a/app/main.py b/app/main.py index fc043b5..a06327b 100644 --- a/app/main.py +++ b/app/main.py @@ -71,7 +71,7 @@ async def dashboard(request: Request) -> HTMLResponse: return templates.TemplateResponse( request, "dashboard.html", - {"app_title": settings.app_title}, + {"app_title": settings.app_title, "nav_active": "panel"}, ) @@ -92,5 +92,5 @@ async def documentation_page(request: Request) -> HTMLResponse: return templates.TemplateResponse( request, "documentation.html", - {"app_title": settings.app_title}, + {"app_title": settings.app_title, "nav_active": "documentation"}, ) diff --git a/app/static/js/documentation.js b/app/static/js/documentation.js index 4912933..1bd9ef6 100644 --- a/app/static/js/documentation.js +++ b/app/static/js/documentation.js @@ -1,5 +1,5 @@ /** - * Страница /documentation: загрузка README через API и рендер Markdown. + * Страница /documentation: README и файлы app/docs/*.md через API, рендер Markdown. * Зависимости из репозитория: /static/js/vendor/marked.min.js, purify.min.js * * Автор: Сергей Антропов @@ -8,8 +8,9 @@ (function () { "use strict"; - const body = document.body; - const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + var body = document.body; + var API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); + var DOC_BASE = "/documentation"; function showErr(el, msg) { if (!el) return; @@ -17,56 +18,236 @@ el.classList.remove("hidden"); } - async function run() { - const article = document.getElementById("readme-doc-article"); - const errEl = document.getElementById("readme-error"); - const loadEl = document.getElementById("readme-loading"); - if (!article || !loadEl) return; + function hideErr(el) { + if (!el) return; + el.textContent = ""; + el.classList.add("hidden"); + } + function getPathFromLocation() { + var q = new URLSearchParams(window.location.search || ""); + var p = (q.get("path") || "").trim(); + return p || null; + } + + function docUrlForPath(relPath) { + if (!relPath) return DOC_BASE; + return DOC_BASE + "?path=" + encodeURIComponent(relPath); + } + + /** + * Внутренняя ссылка на .md под app/docs/: полный путь или относительно текущего файла. + */ + function resolveInternalMdHref(href, currentDocPath) { + if (!href) return null; + var h = href.trim(); + if (h.startsWith("#")) return null; + if (/^https?:\/\//i.test(h)) return null; + if (/^mailto:/i.test(h)) return null; + + var abs = h.match(/^(\.\/)?(app\/docs\/[^?#]+\.md)([\?#].*)?$/i); + if (abs) { + return { path: abs[2].replace(/\\/g, "/"), tail: abs[3] || "" }; + } + if (!currentDocPath || currentDocPath.indexOf("app/docs/") !== 0) return null; + if (h.indexOf("..") >= 0) return null; + + var baseDir = currentDocPath.replace(/[^/]+\.md$/i, ""); + if (!baseDir) baseDir = "app/docs/"; + try { + var u = new URL(h, "http://doc.local/" + baseDir); + var pathname = decodeURIComponent(u.pathname.replace(/^\/+/, "")); + if (pathname.indexOf("..") >= 0) return null; + if (!pathname.startsWith("app/docs/") || !pathname.endsWith(".md")) return null; + var tail = (u.hash || "") + (u.search || ""); + return { path: pathname, tail: tail }; + } catch (e) { + return null; + } + } + + function rewriteMdLinks(container, currentDocPath) { + container.querySelectorAll("a[href]").forEach(function (a) { + var href = a.getAttribute("href"); + var resolved = resolveInternalMdHref(href, currentDocPath); + if (!resolved) return; + a.setAttribute("href", docUrlForPath(resolved.path) + resolved.tail); + a.classList.add("doc-md-link"); + }); + } + + /** + * До первого H2 — одна карточка; для каждого H2 — карточка заголовка и карточка тела до следующего H2. + */ + function sectionizeFromNodes(sourceRoot) { + var page = document.createElement("div"); + page.className = "readme-doc-page-inner"; + var root = document.createElement("div"); + root.className = "readme-doc-root"; + page.appendChild(root); + + var nodes = Array.prototype.slice.call(sourceRoot.childNodes); + var i = 0; + + function appendCard(classExtra, chunk) { + if (!chunk.length) return; + var card = document.createElement("section"); + card.className = "card readme-section-card markdown-body " + classExtra; + chunk.forEach(function (n) { + card.appendChild(n); + }); + root.appendChild(card); + } + + function flushIntro() { + var chunk = []; + while (i < nodes.length) { + var n = nodes[i]; + if (n.nodeType === 1 && n.tagName === "H2") break; + chunk.push(n); + i++; + } + appendCard("readme-section-card--intro", chunk); + } + + function flushH2Pair() { + if (i >= nodes.length) return; + var h2 = nodes[i]; + if (h2.nodeType !== 1 || h2.tagName !== "H2") return; + i++; + appendCard("readme-section-card--head", [h2]); + + var bodyChunk = []; + while (i < nodes.length) { + var n = nodes[i]; + if (n.nodeType === 1 && n.tagName === "H2") break; + bodyChunk.push(n); + i++; + } + appendCard("readme-section-card--body", bodyChunk); + } + + flushIntro(); + while (i < nodes.length) flushH2Pair(); + return page; + } + + function renderMarkdownToRoot(text, docPath, rootEl, errEl, loadEl) { if (typeof marked === "undefined" || typeof DOMPurify === "undefined") { showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/."); - loadEl.classList.add("hidden"); + if (loadEl) loadEl.classList.add("hidden"); return; } try { - const r = await fetch(API + "/docs/readme", { - headers: { Accept: "text/markdown, text/plain, */*" }, - }); - const text = await r.text(); - if (!r.ok) { - var detail = text; - try { - var j = JSON.parse(text); - if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); - } catch (e) { - /* сырой текст */ - } - showErr(errEl, "Не удалось загрузить README: " + (detail || r.statusText)); - loadEl.classList.add("hidden"); - return; - } - - /* marked v15: переносы строк в параграфах (breaks). */ try { if (marked.defaults && typeof marked.defaults === "object") { marked.defaults.breaks = true; marked.defaults.gfm = true; } - } catch (e) { - /* игнорируем, если defaults защищены от записи */ + } catch (e1) { + /* ignore */ } var rawHtml = typeof marked.parse === "function" ? marked.parse(text, { async: false }) : marked(text); - article.innerHTML = DOMPurify.sanitize(rawHtml); - article.removeAttribute("hidden"); + + var temp = document.createElement("div"); + temp.innerHTML = DOMPurify.sanitize(rawHtml); + rewriteMdLinks(temp, docPath); + + while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild); + var pageInner = sectionizeFromNodes(temp); + rootEl.appendChild(pageInner); + rootEl.removeAttribute("hidden"); + } catch (e2) { + showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2))); + } + } + + function fetchUrlForPath(docPath) { + if (!docPath) return API + "/docs/readme"; + return API + "/docs/file?path=" + encodeURIComponent(docPath); + } + + async function loadDocumentation(docPath, opts) { + opts = opts || {}; + var push = opts.pushState === true; + var replace = opts.replaceState === true; + + var rootEl = document.getElementById("readme-doc-root"); + var errEl = document.getElementById("readme-error"); + var loadEl = document.getElementById("readme-loading"); + if (!rootEl || !loadEl) return; + + hideErr(errEl); + rootEl.setAttribute("hidden", "hidden"); + while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild); + loadEl.classList.remove("hidden"); + + var url = fetchUrlForPath(docPath); + try { + var r = await fetch(url, { + headers: { Accept: "text/markdown, text/plain, */*" }, + }); + var text = await r.text(); + if (!r.ok) { + var detail = text; + try { + var j = JSON.parse(text); + if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); + } catch (e0) { + /* raw */ + } + showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText)); + loadEl.classList.add("hidden"); + return; + } + + renderMarkdownToRoot(text, docPath, rootEl, errEl, loadEl); loadEl.classList.add("hidden"); + + var newUrl = docUrlForPath(docPath); + if (push) { + window.history.pushState({ docPath: docPath }, "", newUrl); + } else if (replace) { + window.history.replaceState({ docPath: docPath }, "", newUrl); + } } catch (e) { showErr(errEl, "Ошибка: " + (e.message || String(e))); loadEl.classList.add("hidden"); } } + function onPopState() { + loadDocumentation(getPathFromLocation(), {}); + } + + function onRootClick(e) { + var a = e.target && e.target.closest ? e.target.closest("a.doc-md-link") : null; + if (!a || !a.getAttribute("href")) return; + var href = a.getAttribute("href"); + if (href.indexOf(DOC_BASE) !== 0) return; + e.preventDefault(); + try { + var u = new URL(href, window.location.origin); + var p = (u.searchParams.get("path") || "").trim() || null; + loadDocumentation(p, { pushState: true }); + } catch (e1) { + loadDocumentation(getPathFromLocation(), { pushState: true }); + } + } + + function run() { + var rootEl = document.getElementById("readme-doc-root"); + if (!rootEl) return; + + window.addEventListener("popstate", onPopState); + rootEl.addEventListener("click", onRootClick); + + var initial = getPathFromLocation(); + loadDocumentation(initial, { replaceState: true }); + } + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", run); } else { diff --git a/app/static/style.css b/app/static/style.css index bc53d8c..783fd0e 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,4 +1,4 @@ -/* Минимальные стили веб-интерфейса kind-k8s-develop. +/* Минимальные стили веб-интерфейса Kind Clusters Dashboard. Автор: Сергей Антропов — https://devops.org.ru */ :root { @@ -146,6 +146,21 @@ body.modal-open { background: linear-gradient(145deg, rgba(59, 130, 246, 0.35), rgba(59, 130, 246, 0.15)); } +/* Активный раздел (панель или документация) */ +.nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) { + border-color: rgba(59, 130, 246, 0.85); + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35), 0 2px 14px rgba(59, 130, 246, 0.18); + color: #93c5fd; +} +.nav-link.nav-pill.nav-pill--home.nav-pill--active { + background: linear-gradient(145deg, rgba(59, 130, 246, 0.42), rgba(59, 130, 246, 0.18)); +} +@media (prefers-color-scheme: light) { + .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) { + color: #1e40af; + } +} + /* Внешние окна: иконка «новое окно» */ .nav-link.nav-pill--ext::after { content: "↗"; @@ -887,78 +902,89 @@ button.btn-danger:hover { filter: brightness(1.12); } -/* Страница «Документация»: README.md → HTML (Markdown) */ -.readme-doc-card { +/* Страница «Документация»: Markdown → карточки секций */ +.readme-doc-shell { max-width: 52rem; - margin: 0 auto 2rem; -} -.readme-doc-toolbar { - align-items: center; - margin-bottom: 0.35rem; -} -.readme-doc-toolbar .page-title { - margin: 0; -} -.readme-doc-lead { - margin: 0 0 1rem; - font-size: 0.88rem; + margin: 0 auto; } .readme-doc-loading { margin: 0 0 0.75rem; font-size: 0.9rem; } -.readme-doc.markdown-body { +.readme-doc-page { + width: 100%; +} +.readme-doc-page-inner { + width: 100%; +} +.readme-doc-root { + display: flex; + flex-direction: column; + gap: 1rem; +} +.readme-section-card { + margin: 0; +} +.readme-section-card--head h2 { + margin: 0; + padding: 0; + border-bottom: none; +} +.readme-section-card.markdown-body { font-size: 0.95rem; line-height: 1.55; } -.readme-doc.markdown-body h1, -.readme-doc.markdown-body h2, -.readme-doc.markdown-body h3, -.readme-doc.markdown-body h4 { +.readme-section-card.markdown-body h1, +.readme-section-card.markdown-body h2, +.readme-section-card.markdown-body h3, +.readme-section-card.markdown-body h4 { margin: 1.25rem 0 0.5rem; line-height: 1.25; font-weight: 650; } -.readme-doc.markdown-body h1 { +.readme-section-card.markdown-body h1 { font-size: 1.45rem; border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; } -.readme-doc.markdown-body h2 { +.readme-section-card.markdown-body h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; } -.readme-doc.markdown-body h3 { +.readme-section-card--head.markdown-body h2 { + margin: 0; +} +.readme-section-card.markdown-body h3 { font-size: 1.05rem; } -.readme-doc.markdown-body p { +.readme-section-card.markdown-body p { margin: 0.5rem 0; } -.readme-doc.markdown-body ul, -.readme-doc.markdown-body ol { +.readme-section-card.markdown-body ul, +.readme-section-card.markdown-body ol { margin: 0.5rem 0; padding-left: 1.35rem; } -.readme-doc.markdown-body li { +.readme-section-card.markdown-body li { margin: 0.2rem 0; } -.readme-doc.markdown-body a { +.readme-section-card.markdown-body a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } -.readme-doc.markdown-body a:hover { +.readme-section-card.markdown-body a:hover { filter: brightness(1.15); } -.readme-doc.markdown-body code { +.readme-section-card.markdown-body code { font-size: 0.88em; padding: 0.12em 0.35em; border-radius: 4px; background: rgba(0, 0, 0, 0.22); border: 1px solid var(--border); } -.readme-doc.markdown-body pre { +.readme-section-card.markdown-body pre { margin: 0.65rem 0; padding: 0.65rem 0.85rem; overflow-x: auto; @@ -968,45 +994,45 @@ button.btn-danger:hover { font-size: 0.82rem; line-height: 1.4; } -.readme-doc.markdown-body pre code { +.readme-section-card.markdown-body pre code { padding: 0; border: none; background: transparent; font-size: inherit; } -.readme-doc.markdown-body blockquote { +.readme-section-card.markdown-body blockquote { margin: 0.65rem 0; padding: 0.35rem 0.75rem; border-left: 4px solid var(--accent); background: rgba(59, 130, 246, 0.08); color: var(--muted); } -.readme-doc.markdown-body table { +.readme-section-card.markdown-body table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; font-size: 0.88rem; } -.readme-doc.markdown-body th, -.readme-doc.markdown-body td { +.readme-section-card.markdown-body th, +.readme-section-card.markdown-body td { border: 1px solid var(--border); padding: 0.4rem 0.55rem; text-align: left; } -.readme-doc.markdown-body th { +.readme-section-card.markdown-body th { background: rgba(0, 0, 0, 0.15); font-weight: 600; } -.readme-doc.markdown-body hr { +.readme-section-card.markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; } @media (prefers-color-scheme: light) { - .readme-doc.markdown-body code { + .readme-section-card.markdown-body code { background: rgba(0, 0, 0, 0.06); } - .readme-doc.markdown-body pre { + .readme-section-card.markdown-body pre { background: rgba(0, 0, 0, 0.04); } } diff --git a/app/templates/base.html b/app/templates/base.html index d318138..6d99219 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,4 +1,4 @@ -{# Общий каркас страниц веб-интерфейса kind-k8s-develop. +{# Общий каркас страниц веб-интерфейса Kind Clusters Dashboard. Автор: Сергей Антропов — https://devops.org.ru #} @@ -23,8 +23,8 @@ {{ app_title }}