Документация и kubectl из контейнера; Kind Clusters Dashboard

- Цель 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 документации (ранее в рабочем дереве)
This commit is contained in:
Sergey Antropoff
2026-04-04 06:27:18 +03:00
parent c1e867a01f
commit d1a01cca9e
17 changed files with 483 additions and 131 deletions
+19 -5
View File
@@ -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). # Создание кластеров — в браузере: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT; 6000 на хосте — ERR_UNSAFE_PORT в Chrome).
# #
# Все операции с Compose только с явным выбором среды: # Все операции с Compose только с явным выбором среды:
# make docker up | make docker down | make docker logs | … # make docker up | make docker down | make docker logs | …
# make podman up | make podman down | … # make podman up | make podman down | …
# make docker rebuild / make podman rebuild — образ без кэша и пересоздание контейнера # 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 # Автор: Сергей Антропов — https://devops.org.ru
@@ -15,13 +15,16 @@ else ifneq (,$(filter docker,$(MAKECMDGOALS)))
COMPOSE := docker compose COMPOSE := docker compose
endif 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)))) KIND_K8S_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py SETUP_ENV_SCRIPT := $(KIND_K8S_DIR)/scripts/setup_env_interactive.py
PYTHON ?= python3 PYTHON ?= python3
# При «exec format error» у kind: make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64 # При «exec format error» у kind: make docker compose-build COMPOSE_BUILD_FLAGS=--platform linux/arm64
COMPOSE_BUILD_FLAGS ?= COMPOSE_BUILD_FLAGS ?=
# Для цели kubectl: имя кластера и аргументы kubectl после --kubeconfig (по умолчанию: get nodes).
CLUSTER ?=
KUBECTL_ARGS ?= get nodes
help: ## Справка по целям help: ## Справка по целям
@echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:" @echo "Веб-UI kind — только с выбором Docker или Podman в одной команде с целью:"
@@ -32,6 +35,7 @@ help: ## Справка по целям
@echo " make docker compose-build / make podman compose-build" @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 rebuild / make podman rebuild (build --no-cache + up --force-recreate)"
@echo " make docker check-docker / make podman check-docker" @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)." @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}' @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) 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: _require_runtime:
@if [ -z "$(COMPOSE)" ]; then \ @if [ -z "$(COMPOSE)" ]; then \
echo >&2 "Укажите среду в той же команде, что и цель:"; \ echo >&2 "Укажите среду в той же команде, что и цель:"; \
echo >&2 " make docker up | make podman up"; \ 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)"; \ echo >&2 " (или то же с префиксом podman)"; \
exit 1; \ exit 1; \
fi 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; } @$(COMPOSE) version >/dev/null 2>&1 || { echo >&2 "Команда «$(COMPOSE) version» недоступна."; exit 1; }
@echo "$(COMPOSE): OK" @echo "$(COMPOSE): OK"
# kubectl и kind в образе; kubeconfig в томе /work/clusters/<CLUSTER>/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 compose-build: _require_runtime clusters-dir ## (с docker/podman) Собрать образ kind-k8s-tools:local
cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS) cd "$(KIND_K8S_DIR)" && $(COMPOSE) build $(COMPOSE_BUILD_FLAGS)
+31 -10
View File
@@ -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** — внутри контейнера. Образ **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 без облака (интеграционные тесты, манифесты, обучение). - Быстро получить Kubernetes без облака (интеграционные тесты, манифесты, обучение).
- Версия кластера и число worker-нод задаются **в веб-UI** (или через REST API / скрипты в контейнере). - Версия кластера и число worker-нод задаются **в веб-UI** (или через REST API / скрипты в контейнере).
- Количество кластеров **не ограничено** кодом (ограничения — ресурсы хоста и Docker). - Количество кластеров **не ограничено** кодом (ограничения — ресурсы хоста и Docker).
- Артефакты на хосте: `clusters/<имя>/`удобно указать путь к `kubeconfig` в приложении или в `kubectl`. - Артефакты на хосте: `clusters/<имя>/`том для `kubeconfig` (доступен в контейнере как `/work/clusters/<имя>/`).
## Веб-интерфейс ## Веб-интерфейс
@@ -43,9 +43,10 @@
| **Docker** + **Compose v2** (или **Podman** + compose) | Сборка образа и запуск веб-сервиса | | **Docker** + **Compose v2** (или **Podman** + compose) | Сборка образа и запуск веб-сервиса |
| **make** | `make docker up` / `make podman up` и вспомогательные цели | | **make** | `make docker up` / `make podman up` и вспомогательные цели |
| **python3** | Только для **`make setup`** (создание `.env`) | | **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`** без пересборки образа). Смонтированы **сокет** 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 ```bash
cd kind-k8s-develop # Из корня клонированного репозитория Kind Clusters Dashboard (рядом с Makefile):
make setup # опционально: интерактивно создать .env (Enter — дефолты из скрипта) make setup # опционально: интерактивно создать .env (Enter — дефолты из скрипта)
make docker check-docker # или: make podman check-docker make docker check-docker # или: make podman check-docker
make docker up # или: make podman up make docker up # или: make podman up
# Браузер: http://127.0.0.1:8080 (порт: KIND_K8S_WEB_PORT в .env; не 6000 на хосте — Chrome ERR_UNSAFE_PORT) # Браузер: 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`. **Логи, статус и остановка:** `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`. Рабочий каталог сервиса в образе — `/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 ## Команды 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 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 rebuild` / `make podman rebuild` | Пересборка образа **без кэша** (`build --no-cache`) и пересоздание контейнера (`up -d --force-recreate`) |
| `make docker check-docker` / `make podman check-docker` | Проверить выбранный CLI и `compose version` | | `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 setup` | Интерактивно создать `.env` (список переменных в `scripts/setup_env_interactive.py`) |
| `make clusters-dir` | Создать каталог `clusters/` | | `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. - Образ `kindest/node:v…` должен быть доступен для pull.
- На **Windows** без WSL удобнее WSL2 + Docker Desktop. - На **Windows** без WSL удобнее WSL2 + Docker Desktop.
- Для проверки с хоста нужен отдельный **kubectl** (в образе kubectl только внутри контейнера). - **kubectl** на хосте **не обязателен**: используйте веб-UI или **`make docker kubectl`** / **`make podman kubectl`** (см. выше).
- История заданий создания в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается. - История заданий создания в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается.
- При **`exec format error`** у kind пересоберите образ: `make docker rebuild COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `compose-build` без `--no-cache`, или `linux/amd64`). - При **`exec format error`** у kind пересоберите образ: `make docker rebuild COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или `compose-build` без `--no-cache`, или `linux/amd64`).
+40 -3
View File
@@ -1,4 +1,4 @@
"""Отдача сырого README.md для клиентского рендера Markdown (marked в static). """Отдача README.md и файлов ``app/docs/*.md`` для клиентского рендера Markdown (marked в static).
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
@@ -9,10 +9,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse 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") logger = logging.getLogger("kind_k8s.api.docs_readme")
@@ -51,3 +51,40 @@ async def get_readme_markdown() -> PlainTextResponse:
content=text, content=text,
media_type="text/markdown; charset=utf-8", 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",
)
+11 -2
View File
@@ -104,7 +104,7 @@ def _print_cluster(name: str, *, kube_path_saved: Path | None) -> None:
use_path: str | None = None use_path: str | None = None
if kube_path_saved and kube_path_saved.is_file(): if kube_path_saved and kube_path_saved.is_file():
use_path = str(kube_path_saved) use_path = str(kube_path_saved)
print(" Проверка API: kubectl с сохранённым kubeconfig (как на хосте после создания кластера).") print(" Проверка API: kubectl с сохранённым kubeconfig (тот же файл, что пишет create / UI).")
tmp_kc: str | None = None tmp_kc: str | None = None
if not use_path: if not use_path:
@@ -148,7 +148,16 @@ def main() -> None:
) )
sys.exit(127) sys.exit(127)
if not shutil.which("kubectl"): 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) sys.exit(127)
names = _kind_cluster_names() names = _kind_cluster_names()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Вспомогательная логика для API и общих операций kind-k8s-develop. """Вспомогательная логика для API и общих операций Kind Clusters Dashboard.
Автор: Сергей Антропов Автор: Сергей Антропов
Сайт: https://devops.org.ru Сайт: https://devops.org.ru
+1 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from pydantic import Field, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
_DEFAULT_TITLE = "kind-k8s-develop" _DEFAULT_TITLE = "Kind Clusters Dashboard"
class Settings(BaseSettings): class Settings(BaseSettings):
+44 -3
View File
@@ -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`` и Разметка Markdown преобразуется в браузере: ``/static/js/vendor/marked.min.js`` и
``purify.min.js`` (файлы входят в репозиторий, без CDN). ``purify.min.js`` (файлы входят в репозиторий, без CDN).
Путь к файлу: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``; README: ``KIND_K8S_README_PATH`` или ``README.md`` в корне рядом с ``app/``;
в Docker-образе — ``/opt/kind-k8s/README.md``. в 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 Сайт: https://devops.org.ru
@@ -86,3 +87,43 @@ def read_readme_text() -> str:
[str(x) for x in _candidates_without_env()], [str(x) for x in _candidates_without_env()],
) )
raise FileNotFoundError("README.md") 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
+7 -5
View File
@@ -153,7 +153,7 @@ def _run_interactive() -> None:
if not _which("kind"): if not _which("kind"):
print("Не найден бинарник kind.", file=sys.stderr) print("Не найден бинарник kind.", file=sys.stderr)
print(" Установка kind на хост: https://kind.sigs.k8s.io/docs/user/quick-start/#installation", 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) sys.exit(127)
cli = _container_cli_bin() cli = _container_cli_bin()
if not _which(cli): if not _which(cli):
@@ -194,13 +194,15 @@ def _run_interactive() -> None:
print("\nГотово.") print("\nГотово.")
print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}") print(f" kubeconfig (в среде запуска): {result.kubeconfig_path}")
if _in_container(): if _in_container():
print(f" Том на хосте: kind-k8s-develop/clusters/{result.cluster_name}/ (рядом с Makefile)") print(f" Том на хосте: clusters/{result.cluster_name}/ в корне репозитория (рядом с Makefile)")
print( print(
f' Проверка с хоста (из каталога репозитория): kubectl --kubeconfig="$(pwd)/clusters/{result.cluster_name}/kubeconfig" get nodes', f" Проверка в этом контейнере: kubectl --kubeconfig=/work/clusters/{result.cluster_name}/kubeconfig get nodes",
) )
else: else:
print(f" Проверка: KUBECONFIG={result.kubeconfig_path} kubectl get nodes") print(" Проверка без kubectl на хосте: веб-интерфейс (кластер → узлы/поды) или из корня репозитория:")
print(f" Или: kubectl --kubeconfig={result.kubeconfig_path} get nodes") 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: if result.kubeconfig_patched_for_host:
print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.") print(" apiserver настроен на 127.0.0.1:<порт> для доступа с хоста.")
if result.nodes_ready is False and result.nodes_ready_message: if result.nodes_ready is False and result.nodes_ready_message:
+1 -1
View File
@@ -42,7 +42,7 @@ def _interactive() -> None:
CLUSTERS_DIR = clusters_dir() CLUSTERS_DIR = clusters_dir()
if not shutil.which("kind"): if not shutil.which("kind"):
print("Не найден kind.", file=sys.stderr) 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) sys.exit(127)
clusters = _list_kind_clusters() clusters = _list_kind_clusters()
+1 -1
View File
@@ -6,6 +6,6 @@
|------|------------| |------|------------|
| [api_routes.md](api_routes.md) | Полное описание REST API `/api/v1/*` с примерами JSON (ориентир для фронтенда и клиентов). | | [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) **Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
+34 -5
View File
@@ -1,4 +1,4 @@
# Описание REST API веб-интерфейса kind-k8s-develop # Описание REST API веб-интерфейса Kind Clusters Dashboard
**Базовый префикс:** `/api/v1` **Базовый префикс:** `/api/v1`
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) **Автор:** Сергей Антропов — [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) | | Swagger UI (OpenAPI) | `http://127.0.0.1:<порт>/docs` (порт на хосте по умолчанию **8080**, см. `KIND_K8S_WEB_PORT`; 6000 на хосте блокируется Chrome) |
| ReDoc | `http://127.0.0.1:<порт>/redoc` | | ReDoc | `http://127.0.0.1:<порт>/redoc` |
| Health (JSON) | `http://127.0.0.1:<порт>/api/v1/health` | | 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` в репозитории | | Этот файл | `app/docs/api_routes.md` в репозитории |
С **веб-панели** (`GET /`) пункты меню **Swagger**, **ReDoc** и **Health** вызывают `window.open` с именами окон `kind_swagger`, `kind_redoc`, `kind_health` (отдельное окно, повторный клик переиспользует то же окно). Пункт **Документация** открывает `GET /documentation` в той же вкладке. С **веб-панели** (`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 /` | 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 /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипт панели (`js/dashboard.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/documentation.html` (README). Шаблоны: `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 ## Сводка маршрутов API
@@ -33,7 +35,8 @@
| Метод | Путь | Кратко | | Метод | Путь | Кратко |
|-------|------|--------| |-------|------|--------|
| GET | `/api/v1/health` | Среда: kind, kubectl, движок контейнеров | | 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/versions` | Теги `kindest/node` (Docker Hub) или пусто при `KIND_K8S_SKIP_VERSION_LIST` |
| GET | `/api/v1/stats` | Сводка для дашборда | | GET | `/api/v1/stats` | Сводка для дашборда |
| GET | `/api/v1/clusters` | Список кластеров | | 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 ## GET /api/v1/versions
Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI). Список стабильных тегов `kindest/node` с Docker Hub (для выпадающего списка в UI).
+2 -2
View File
@@ -71,7 +71,7 @@ async def dashboard(request: Request) -> HTMLResponse:
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"dashboard.html", "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( return templates.TemplateResponse(
request, request,
"documentation.html", "documentation.html",
{"app_title": settings.app_title}, {"app_title": settings.app_title, "nav_active": "documentation"},
) )
+212 -31
View File
@@ -1,5 +1,5 @@
/** /**
* Страница /documentation: загрузка README через API и рендер Markdown. * Страница /documentation: README и файлы app/docs/*.md через API, рендер Markdown.
* Зависимости из репозитория: /static/js/vendor/marked.min.js, purify.min.js * Зависимости из репозитория: /static/js/vendor/marked.min.js, purify.min.js
* *
* Автор: Сергей Антропов * Автор: Сергей Антропов
@@ -8,8 +8,9 @@
(function () { (function () {
"use strict"; "use strict";
const body = document.body; var body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, ""); var API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
var DOC_BASE = "/documentation";
function showErr(el, msg) { function showErr(el, msg) {
if (!el) return; if (!el) return;
@@ -17,56 +18,236 @@
el.classList.remove("hidden"); el.classList.remove("hidden");
} }
async function run() { function hideErr(el) {
const article = document.getElementById("readme-doc-article"); if (!el) return;
const errEl = document.getElementById("readme-error"); el.textContent = "";
const loadEl = document.getElementById("readme-loading"); el.classList.add("hidden");
if (!article || !loadEl) return; }
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") { if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/."); showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
loadEl.classList.add("hidden"); if (loadEl) loadEl.classList.add("hidden");
return; return;
} }
try { 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 { try {
if (marked.defaults && typeof marked.defaults === "object") { if (marked.defaults && typeof marked.defaults === "object") {
marked.defaults.breaks = true; marked.defaults.breaks = true;
marked.defaults.gfm = true; marked.defaults.gfm = true;
} }
} catch (e) { } catch (e1) {
/* игнорируем, если defaults защищены от записи */ /* ignore */
} }
var rawHtml = var rawHtml =
typeof marked.parse === "function" ? marked.parse(text, { async: false }) : marked(text); 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"); 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) { } catch (e) {
showErr(errEl, "Ошибка: " + (e.message || String(e))); showErr(errEl, "Ошибка: " + (e.message || String(e)));
loadEl.classList.add("hidden"); 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") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run); document.addEventListener("DOMContentLoaded", run);
} else { } else {
+66 -40
View File
@@ -1,4 +1,4 @@
/* Минимальные стили веб-интерфейса kind-k8s-develop. /* Минимальные стили веб-интерфейса Kind Clusters Dashboard.
Автор: Сергей Антропов — https://devops.org.ru */ Автор: Сергей Антропов — https://devops.org.ru */
:root { :root {
@@ -146,6 +146,21 @@ body.modal-open {
background: linear-gradient(145deg, rgba(59, 130, 246, 0.35), rgba(59, 130, 246, 0.15)); 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 { .nav-link.nav-pill--ext::after {
content: "↗"; content: "↗";
@@ -887,78 +902,89 @@ button.btn-danger:hover {
filter: brightness(1.12); filter: brightness(1.12);
} }
/* Страница «Документация»: README.md → HTML (Markdown) */ /* Страница «Документация»: Markdown → карточки секций */
.readme-doc-card { .readme-doc-shell {
max-width: 52rem; max-width: 52rem;
margin: 0 auto 2rem; margin: 0 auto;
}
.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;
} }
.readme-doc-loading { .readme-doc-loading {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
font-size: 0.9rem; 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; font-size: 0.95rem;
line-height: 1.55; line-height: 1.55;
} }
.readme-doc.markdown-body h1, .readme-section-card.markdown-body h1,
.readme-doc.markdown-body h2, .readme-section-card.markdown-body h2,
.readme-doc.markdown-body h3, .readme-section-card.markdown-body h3,
.readme-doc.markdown-body h4 { .readme-section-card.markdown-body h4 {
margin: 1.25rem 0 0.5rem; margin: 1.25rem 0 0.5rem;
line-height: 1.25; line-height: 1.25;
font-weight: 650; font-weight: 650;
} }
.readme-doc.markdown-body h1 { .readme-section-card.markdown-body h1 {
font-size: 1.45rem; font-size: 1.45rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding-bottom: 0.35rem; padding-bottom: 0.35rem;
} }
.readme-doc.markdown-body h2 { .readme-section-card.markdown-body h2 {
font-size: 1.2rem; font-size: 1.2rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding-bottom: 0.25rem; 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; font-size: 1.05rem;
} }
.readme-doc.markdown-body p { .readme-section-card.markdown-body p {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.readme-doc.markdown-body ul, .readme-section-card.markdown-body ul,
.readme-doc.markdown-body ol { .readme-section-card.markdown-body ol {
margin: 0.5rem 0; margin: 0.5rem 0;
padding-left: 1.35rem; padding-left: 1.35rem;
} }
.readme-doc.markdown-body li { .readme-section-card.markdown-body li {
margin: 0.2rem 0; margin: 0.2rem 0;
} }
.readme-doc.markdown-body a { .readme-section-card.markdown-body a {
color: var(--accent); color: var(--accent);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 2px; text-underline-offset: 2px;
} }
.readme-doc.markdown-body a:hover { .readme-section-card.markdown-body a:hover {
filter: brightness(1.15); filter: brightness(1.15);
} }
.readme-doc.markdown-body code { .readme-section-card.markdown-body code {
font-size: 0.88em; font-size: 0.88em;
padding: 0.12em 0.35em; padding: 0.12em 0.35em;
border-radius: 4px; border-radius: 4px;
background: rgba(0, 0, 0, 0.22); background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.readme-doc.markdown-body pre { .readme-section-card.markdown-body pre {
margin: 0.65rem 0; margin: 0.65rem 0;
padding: 0.65rem 0.85rem; padding: 0.65rem 0.85rem;
overflow-x: auto; overflow-x: auto;
@@ -968,45 +994,45 @@ button.btn-danger:hover {
font-size: 0.82rem; font-size: 0.82rem;
line-height: 1.4; line-height: 1.4;
} }
.readme-doc.markdown-body pre code { .readme-section-card.markdown-body pre code {
padding: 0; padding: 0;
border: none; border: none;
background: transparent; background: transparent;
font-size: inherit; font-size: inherit;
} }
.readme-doc.markdown-body blockquote { .readme-section-card.markdown-body blockquote {
margin: 0.65rem 0; margin: 0.65rem 0;
padding: 0.35rem 0.75rem; padding: 0.35rem 0.75rem;
border-left: 4px solid var(--accent); border-left: 4px solid var(--accent);
background: rgba(59, 130, 246, 0.08); background: rgba(59, 130, 246, 0.08);
color: var(--muted); color: var(--muted);
} }
.readme-doc.markdown-body table { .readme-section-card.markdown-body table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 0.75rem 0; margin: 0.75rem 0;
font-size: 0.88rem; font-size: 0.88rem;
} }
.readme-doc.markdown-body th, .readme-section-card.markdown-body th,
.readme-doc.markdown-body td { .readme-section-card.markdown-body td {
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 0.4rem 0.55rem; padding: 0.4rem 0.55rem;
text-align: left; text-align: left;
} }
.readme-doc.markdown-body th { .readme-section-card.markdown-body th {
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
font-weight: 600; font-weight: 600;
} }
.readme-doc.markdown-body hr { .readme-section-card.markdown-body hr {
border: none; border: none;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
margin: 1.25rem 0; margin: 1.25rem 0;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
.readme-doc.markdown-body code { .readme-section-card.markdown-body code {
background: rgba(0, 0, 0, 0.06); 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); background: rgba(0, 0, 0, 0.04);
} }
} }
+4 -4
View File
@@ -1,4 +1,4 @@
{# Общий каркас страниц веб-интерфейса kind-k8s-develop. {# Общий каркас страниц веб-интерфейса Kind Clusters Dashboard.
Автор: Сергей Антропов — https://devops.org.ru #} Автор: Сергей Антропов — https://devops.org.ru #}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
@@ -23,8 +23,8 @@
<span class="nav-title">{{ app_title }}</span> <span class="nav-title">{{ app_title }}</span>
</div> </div>
<nav class="nav-links" aria-label="Разделы"> <nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link nav-pill nav-pill--home">Панель</a> <a href="/" class="nav-link nav-pill nav-pill--home{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
<a href="/documentation" class="nav-link nav-pill">Документация</a> <a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
<a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a> <a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a>
<a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a> <a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
<a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a> <a href="/api/v1/health" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_health">Health</a>
@@ -45,7 +45,7 @@
<div class="footer-inner"> <div class="footer-inner">
<p class="muted footer-line">Данные: том <code>clusters/</code> на хосте</p> <p class="muted footer-line">Данные: том <code>clusters/</code> на хосте</p>
<p class="footer-copyright"> <p class="footer-copyright">
© kind-k8s-develop · © {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a> <a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p> </p>
</div> </div>
+5 -13
View File
@@ -1,23 +1,15 @@
{# Документация: README.md через GET /api/v1/docs/readme + marked + DOMPurify из static/vendor. {# Документация: README и app/docs/*.md через API + marked + DOMPurify (vendor, без CDN).
Автор: Сергей Антропов — https://devops.org.ru #} Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %} {% extends "base.html" %}
{% block page_title %}Документация{% endblock %} {% block page_title %}Документация{% endblock %}
{% block content %} {% block content %}
<section class="card readme-doc-card" aria-labelledby="readme-doc-heading"> <div class="readme-doc-shell" id="readme-doc-shell">
<div class="readme-doc-toolbar row spread"> <p id="readme-loading" class="muted readme-doc-loading">Загрузка…</p>
<h1 class="page-title" id="readme-doc-heading">Документация</h1>
<a href="/" class="nav-link nav-pill nav-pill--home">← Панель</a>
</div>
<p class="muted readme-doc-lead">
Текст загружается из <code>README.md</code> (эндпоинт <code>/api/v1/docs/readme</code>).
Разбор Markdown выполняется в браузере библиотеками из каталога <code>app/static/js/vendor/</code> (без внешних CDN).
</p>
<p id="readme-loading" class="muted readme-doc-loading">Загрузка документации…</p>
<p id="readme-error" class="msg hidden" role="alert"></p> <p id="readme-error" class="msg hidden" role="alert"></p>
<article id="readme-doc-article" class="readme-doc markdown-body" hidden></article> <div id="readme-doc-root" class="readme-doc-page" hidden></div>
</section> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Интерактивное создание ``.env`` в корне kind-k8s-develop. """Интерактивное создание ``.env`` в корне репозитория Kind Clusters Dashboard.
Список переменных и подсказок задаётся в этом файле (не используется env.example). Список переменных и подсказок задаётся в этом файле (не используется env.example).
Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter записать предложенное значение. Дефолты совпадают с ``docker-compose.yml`` / приложением; Enter записать предложенное значение.
@@ -22,7 +22,7 @@ from pathlib import Path
logger = logging.getLogger("setup_env_interactive") logger = logging.getLogger("setup_env_interactive")
# Корень репозитория kind-k8s-develop (родитель каталога scripts/) # Корень репозитория Kind Clusters Dashboard (родитель каталога scripts/)
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
# Значения по умолчанию при нажатии Enter (как в docker-compose / Dockerfile / Settings). # Значения по умолчанию при нажатии Enter (как в docker-compose / Dockerfile / Settings).
@@ -143,7 +143,7 @@ _SETUP_PROMPTS: tuple[_EnvPrompt, ...] = (
), ),
) )
_ENV_FILE_HEADER = """# Файл .env для kind-k8s-develop _ENV_FILE_HEADER = """# Файл .env для Kind Clusters Dashboard
# Создан интерактивно: scripts/setup_env_interactive.py # Создан интерактивно: scripts/setup_env_interactive.py
# #
# В docker-compose.yml заданы литералами (не из этого файла): # В docker-compose.yml заданы литералами (не из этого файла):
@@ -259,7 +259,7 @@ def run(*, output: Path, force: bool = False) -> int:
def main() -> None: def main() -> None:
_configure_logging() _configure_logging()
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Интерактивное заполнение .env для kind-k8s-develop (переменные задаются в этом скрипте)", description="Интерактивное заполнение .env для Kind Clusters Dashboard (переменные задаются в этом скрипте)",
) )
parser.add_argument( parser.add_argument(
"--output", "--output",