# Описание REST API веб-интерфейса Kind Clusters Dashboard

**Базовый префикс:** `/api/v1`

## Как смотреть документацию

| Способ | URL / путь |
|--------|------------|
| 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** (`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` в той же вкладке.

## Веб-интерфейс и статика (не JSON)

| Маршрут | Описание |
|---------|----------|
| `GET /` | HTML **Панель**: CTA **«Перейти к созданию кластера»**, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. Полноэкранный спиннер первой загрузки — как у **`/cluster-create`** и **`/documentation`**. |
| `GET /cluster-create` | HTML **Создание кластера**: форма, прогресс и журнал задания, таблица **«Последние задания»** (журнал с диска, инкрементальное обновление без сброса раскрытого лога); **`dashboard.js`**. Спиннер первой загрузки. |
| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. Спиннер первой загрузки. |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки **Ресурсы узлов**, затем отдельная карточка **Установленные аддоны** (**мини-карточки Helm**, **`GET /api/v1/clusters/{name}/addons/status`**, ссылки на **`/cluster-addons`**), **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). |
| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. В шапке активна **Кластеры**. |
| `GET /cluster-addons` | HTML **Аддоны**: **`GET /helm/chart-versions`** + **`GET …/addons/status`**; для **установленных** релизов — **`GET …/addons/installed-values`**: в селекте версия с пометкой «(текущая установленная версия)», в форме — values из кластера. По кнопке **«Загрузить values»** — шаблон из **`POST /helm/addons/compose-values`**. Установка/удаление релизов. |
| `GET /journal` | HTML **Журналы**: переключатель (как «Простой/Расширенный» в редактировании кластера) — **по кластеру** (`journal/recent?cluster=`), **развёртывание** (`/journal/provision`), **Helm-аддоны** (`/journal/helm-addons`); пагинация по **30** записей. |
| `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). Полноэкранный спиннер при первой загрузке и при **каждом** переходе по внутренним ссылкам / **popstate**. Каждая секция по **H2** — **одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. |
| `GET /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
| `GET /static/docs-images/…` | PNG из **`app/docs/images/`** для страницы **`/documentation`** (в Markdown пути вида `images/*.png` в **`documentation.js`** переписываются на этот префикс; см. **`app/docs/screenshots.md`**). |

Шаблоны: `app/templates/base.html` (шапка, навигация; **меню «гамбургер»** при узком viewport — `nav-mobile.js`), `app/templates/dashboard.html` (панель), `app/templates/clusters.html` (список кластеров и донаты узлов), `app/templates/cluster_create.html` (создание кластера и последние задания), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/cluster_addons.html` (Helm-аддоны), `app/templates/journal.html` (журнал заданий), `app/templates/documentation.html` (README и `app/docs/*.md`).

**kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** — **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**.

---

## Сводка маршрутов API

| Метод | Путь | Кратко |
|-------|------|--------|
| GET | `/api/v1/health` | Среда: kind, kubectl, движок контейнеров |
| 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` | Список кластеров (поле `has_provision_log` — есть ли `clusters/<имя>/provision_log.json`) |
| POST | `/api/v1/clusters` | Создание в фоне (**202** + `job_id`) |
| POST | `/api/v1/clusters/{name}/start` | Запуск в фоне (**202** + `job_id`, поле `mode`: `containers`, `kind_config` или `kind_config_reapply`); журнал — `GET /jobs/{job_id}` |
| POST | `/api/v1/clusters/{name}/stop` | Остановка узлов в фоне (**202** + `job_id`, `mode`: `stop`); журнал — `GET /jobs/{job_id}` |
| GET | `/api/v1/clusters/{name}` | Детали + `kubectl get nodes` при наличии kubeconfig |
| GET | `/api/v1/clusters/{name}/config` | Текущие `meta.json` и текст `kind-config.yaml`; подсказка `kind_note` про пересоздание |
| PUT | `/api/v1/clusters/{name}/config` | Сохранить YAML и/или meta (простой режим или полный YAML); ответ включает обновлённый `summary` |
| GET | `/api/v1/clusters/{name}/kubeconfig` | Скачать файл kubeconfig |
| GET | `/api/v1/clusters/{name}/kubeconfig/docker` | Скачать kubeconfig для kubectl из **любого** контейнера: `host.docker.internal:<порт>` + `tls-server-name` (запасной вариант — `*-control-plane:6443`) |
| GET | `/api/v1/clusters/{name}/provision-log` | Полный журнал последнего **create_cluster** / **start_cluster** / **start_cluster_reapply** (JSON с диска) |
| GET | `/api/v1/clusters/{name}/workloads` | Узлы и поды (`kubectl`) |
| GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) |
| POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта |
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| GET | `/api/v1/clusters/{name}/addons/status` | Статус Helm-релизов + **версии чартов** из `helm list` (`ingress_nginx_chart_version`, `kube_prometheus_stack_chart_version`, `metrics_server_chart_version`, `istiod_chart_version`, `istio_base_chart_version`, `kiali_server_chart_version`), если релиз установлен; нужен **helm** |
| GET | `/api/v1/clusters/{name}/addons/installed-values` | Для **установленных** аддонов: эффективный YAML (`helm get values --all -o yaml`) и версии; поля `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio_kiali` (или `null`); лимит размера YAML в ответе |
| GET | `/api/v1/helm/chart-versions` | Версии чартов для UI (после `helm repo update`); кэш **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`**; поля: `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio`, `kiali_server` |
| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx**; тело: опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта, см. **compose-values** и раздел ниже) |
| DELETE | `/api/v1/clusters/{name}/addons/ingress-nginx` | Удалить ingress-nginx |
| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта; пусто — автосборка) |
| DELETE | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Удалить стек |
| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server**; тело опционально: `chart_version`, **`values_yaml`** (полный YAML; пусто — автосборка с args для kind) |
| DELETE | `/api/v1/clusters/{name}/addons/metrics-server` | Удалить metrics-server |
| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: учётные данные Kiali, версии, опционально **`values_yaml`** (kiali-server), **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`** |
| DELETE | `/api/v1/clusters/{name}/addons/istio-kiali` | Удалить kiali-server, istiod, istio-base |
| POST | `/api/v1/helm/addons/compose-values` | Собрать YAML для **одного** аддона (тело: `addon`, версии, поля Grafana при необходимости) |
| POST | `/api/v1/helm/addons/compose-values-batch` | Собрать **все** шесть YAML для страницы аддонов **одним** запросом (один **`helm repo update`**); предпочтительно для UI |
| GET | `/api/v1/journal/recent` | Журнал заданий из **`journal/jobs_history.json`**: query **`limit`**, **`offset`**, опционально **`cluster`** (только выбранный каталог); ответ **`total`**, **`total_pages`**, **`page`** |
| GET | `/api/v1/journal/provision` | Сводка **`provision_log.json`** по кластерам (по одному последнему файлу на каталог), пагинация |
| GET | `/api/v1/journal/helm-addons` | Сводка **`helm_addon_log.json`**: все записи из **`entries`** по кластерам (история), тот же формат строки, что у provision |
| GET | `/api/v1/clusters/{name}/journal` | Полный файл **`jobs_history.json`** кластера (массив `entries` или пусто) |
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
| DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
| POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) |

После каждого **POST/DELETE …/addons/…** в каталоге кластера в **`helm_addon_log.json`** **добавляется** новая запись в массив **`entries`** (история сохраняется, лимит **`KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES`**). Формат записи как у **`provision_log.json`**: `lines`, `kind`, `status`, **`message`** (краткая подпись на русском при успехе), **`result`**; в **`result`** поля **`addon`** и **`action`**: **`install`** | **`upgrade`** (повторный POST при уже установленном релизе) | **`delete`**; пароли в файл не пишутся. Поле **`kind`**: `helm_addon_install_*`, `helm_addon_upgrade_*`, `helm_addon_delete_*` (суффикс — аддон в snake_case).

#### POST `/api/v1/helm/addons/compose-values-batch`

Тело **`HelmAddonComposeBatchRequest`**: версии чартов из селектов (`ingress_chart_version`, `kube_prometheus_chart_version`, `metrics_server_chart_version`, `istio_chart_version`, `kiali_chart_version`) и опционально **`grafana_admin_user`** / **`grafana_admin_password`**.

Ответ **`HelmAddonComposeBatchResponse`**: поля **`ingress_nginx_values_yaml`**, **`kube_prometheus_values_yaml`**, **`metrics_server_values_yaml`**, **`kiali_values_yaml`**, **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`**.

#### POST `/api/v1/helm/addons/compose-values`

Тело JSON (**`HelmAddonComposeValuesRequest`**):

- **`addon`**: `ingress-nginx` | `kube-prometheus-stack` | `metrics-server` | `istio-kiali`
- **`chart_version`** — для ingress / kube-prometheus-stack / metrics-server (пусто = последняя версия в `helm search`)
- **`grafana_admin_user`**, **`grafana_admin_password`** — для `kube-prometheus-stack` (если пароль пуст, в preview подставляется временная строка **`ChangeMe12345`** — замените в YAML или в форме перед установкой)
- **`istio_chart_version`**, **`kiali_chart_version`** — для `istio-kiali`

Ответ (**`HelmAddonComposeValuesResponse`**): поле **`values_yaml`** — полный текст для основного чарта; для **`istio-kiali`** дополнительно **`istio_base_values_yaml`** и **`istio_istiod_values_yaml`**. Выполняется **`helm repo update`** (внутри цепочки) и **`helm show values`**.

#### Поля YAML в POST …/addons/… (установка)

Строки **`values_yaml`** / **`istio_base_values_yaml`** / **`istio_istiod_values_yaml`**: корень YAML — **object**. Пустая строка или отсутствие поля — сервер подставляет значения как при **compose-values** (кроме Istio: пустой блок = без файла **`-f`** для соответствующего чарта). Максимум **131072** символа на поле.

- **ingress-nginx** — один файл **`-f`** с полным values; затем **`--set`** NodePort (сильнее файла).
- **kube-prometheus-stack** — один файл: либо из **`values_yaml`**, либо автосборка из версии и полей Grafana.
- **metrics-server** — аналогично.
- **istio-kiali** — до трёх опциональных файлов: **istio/base**, **istio/istiod**, **kiali-server**. Если в YAML kiali-server **нет** `auth.strategy`, сервер задаёт **`auth.strategy=anonymous`** (стратегия **login** в актуальных версиях Kiali не поддерживается). Логин/пароль Kiali в теле запроса **не** передаются.

**Пример POST compose-values (`kube-prometheus-stack`):**

```json
{
  "addon": "kube-prometheus-stack",
  "chart_version": "65.1.1",
  "grafana_admin_user": "admin",
  "grafana_admin_password": "MySecurePwd12"
}
```

**Пример ответа 200 (фрагмент):** поле **`values_yaml`** — многострочная строка с полным YAML чарта.

**Пример POST установки istio-kiali с тремя YAML** (Kiali с **token**, если задано в `values_yaml`; иначе будет **anonymous**):

```json
{
  "istio_chart_version": "1.22.0",
  "kiali_chart_version": "1.89.0",
  "istio_base_values_yaml": "defaultRevision: default\n",
  "istio_istiod_values_yaml": "pilot:\\n  resources: {}\\n",
  "values_yaml": "auth:\\n  strategy: token\\n"
}
```

### Фоновые задания (jobs)

- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**.
- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent?cluster=…`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`** (режим «По кластеру»).
- Операции **Helm-аддонов** (первая установка, **обновление** через тот же POST при уже установленном релизе, удаление) **дописывают** запись в **`clusters/<имя>/helm_addon_log.json`** (массив **`entries`**, новые сверху). Сводка по всем кластерам: **`GET /api/v1/journal/helm-addons`**; на странице **`/journal`** режим **Helm-аддоны** показывает тип операции и аддон в человекочитаемом виде.
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
- Буфер строк в памяти на задание: `KIND_K8S_JOB_LOG_MAX_LINES` (по умолчанию **2500**); при переполнении старые строки вытесняются. Для **create_cluster**, **start_cluster** и **start_cluster_reapply** полный журнал без обрезки дополнительно сохраняется в **`clusters/<имя>/provision_log.json`** (перезапись при каждом завершении такого задания); чтение — **GET /api/v1/clusters/{name}/provision-log**.
- Для **`docker pull`**: если в справке **`docker pull --help`** объявлен флаг **`--progress`**, при **`KIND_K8S_DOCKER_PULL_PLAIN=1`** вызывается **`--progress=plain`** без PTY; на старых CLI флаг не передаётся (нет строки «unknown flag» в журнале). Для **podman** и **kind** — псевдо-TTY по `KIND_K8S_STREAM_PTY`, из строк убираются ANSI-коды.
- Тип задания **`kind`**: `create_cluster`, `start_cluster` (подъём по сохранённому конфигу), `start_cluster_reapply` (после правки `kind-config.yaml`: `kind delete` без удаления каталога + `kind create`), `start_containers` (запуск уже созданных узлов), `stop_containers` (остановка узлов).
- Статус **`cancelled`** — запрошена отмена (`POST .../cancel`); дочерний процесс текущей команды получает принудительное завершение.

---

## GET /api/v1/health

Проверка: `kind`/`kubectl` в PATH и ответ движка контейнеров (`docker info` / `podman info` по `CONTAINER_CLI`).  
`status`: `ok` — всё готово к созданию кластеров; `degraded` — чего-то не хватает (см. поля ниже).

**Пример ответа 200 (JSON):**

```json
{
  "status": "ok",
  "kind_in_path": true,
  "kubectl_in_path": true,
  "container_cli": "docker",
  "container_engine_ok": true,
  "container_engine_detail": null
}
```

**Если сокет Docker недоступен:**

```json
{
  "status": "degraded",
  "kind_in_path": true,
  "kubectl_in_path": true,
  "container_cli": "docker",
  "container_engine_ok": false,
  "container_engine_detail": "Cannot connect to the Docker daemon..."
}
```

---

## GET /api/v1/docs/readme

Сырое содержимое **README.md** проекта в кодировке UTF-8, заголовок **`Content-Type: text/markdown; charset=utf-8`**.

Используется страницей **`GET /documentation`**: скрипт `documentation.js` загружает текст и превращает его в HTML через **marked** и **DOMPurify** (файлы лежат в репозитории: `app/static/js/vendor/`, без внешних CDN).

**Ошибка 404:** файл не найден. В Compose смонтируйте `./README.md:/opt/kind-k8s/README.md:ro`, задайте `KIND_K8S_README_PATH` или пересоберите образ (`COPY README.md`). См. `app/core/readme_doc.py`.

---

## 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.

- Первым элементом всегда идёт **`latest`** (плавающий тег «самый новый образ»).
- Далее стабильные семверы **`vX.Y.Z`** без суффиксов (`-rc` и т.п.), **от новых к старым**, минимум **1.19.0**.
- Полный список семверов зависит от числа обходимых страниц API Hub: переменная **`KIND_K8S_HUB_TAGS_MAX_PAGES`** (по умолчанию в коде — 120 страниц, максимум при явной настройке — 500). Раньше UI обрезал список до 100 пунктов — сейчас отображаются все пришедшие теги.

При `KIND_K8S_SKIP_VERSION_LIST=1` список пустой.

**Пример ответа 200:**

```json
{
  "tags": ["latest", "v1.32.0", "v1.31.4"],
  "skipped": false
}
```

**Пример при пропуске загрузки:**

```json
{
  "tags": [],
  "skipped": true,
  "reason": "KIND_K8S_SKIP_VERSION_LIST"
}
```

---

## GET /api/v1/stats

Сводная статистика для дашборда и **метрики запущенных узлов kind** (один вызов `docker stats` / `podman stats` на набор имён контейнеров `имя-control-plane`, `имя-worker`…). Поле **`container_cli`** совпадает с **`CONTAINER_CLI`** в среде приложения (`docker` или `podman`) — по нему UI показывает, что **все** узлы kind и сбор метрик идут через выбранный CLI.

**Пример ответа 200:**

```json
{
  "container_cli": "docker",
  "kind_clusters_count": 1,
  "local_cluster_dirs_count": 1,
  "total_workers_from_meta": 2,
  "jobs_total": 3,
  "jobs_recent_failed": 0,
  "helm_addons_installable_count": 4,
  "cluster_resources": [
    {
      "cluster_name": "dev",
      "nodes": [
        {
          "container_name": "dev-control-plane",
          "cpu_percent": "2.50%",
          "memory_usage": "512MiB / 7.7GiB",
          "memory_percent": "6.50%",
          "net_io": "1.2MB / 800kB",
          "block_io": "2GB / 150MB",
          "pids": 245
        },
        {
          "container_name": "dev-worker",
          "cpu_percent": "1.00%",
          "memory_usage": "380MiB / 7.7GiB",
          "memory_percent": "4.80%",
          "net_io": "512kB / 400kB",
          "block_io": "500MB / 50MB",
          "pids": 198
        }
      ],
      "note": null
    }
  ],
  "cluster_resources_error": null,
  "aggregate_cluster_resources": {
    "nodes_count": 2,
    "cpu_ring": 1.8,
    "cpu_label": "ср. 1.8%",
    "memory_percent_ring": 5.7,
    "memory_percent_label": "ср. 5.7%",
    "memory_used_ratio_ring": 5.8,
    "memory_used_ratio_label": "ср. 5.8%",
    "network_ring": 12.5,
    "network_label": "всего 1.71 MiB",
    "disk_ring": 45.0,
    "disk_label": "всего 2.5 GiB"
  }
}
```

- `container_cli` — активный движок для kind и `docker stats` / `podman stats` (как в GET /health).
- `total_workers_from_meta` — целое **≥ 0**; **0**, если ни в одном `meta.json` нет поля `worker_nodes` или оно не число.
- `jobs_total` — число заданий в текущей памяти процесса (не более 200).
- `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку).
- `helm_addons_installable_count` — число типовых Helm-аддонов в каталоге UI (**Аддоны**, `HELM_INSTALLABLE_ADDON_IDS` в `core/helm_addons.py`); совпадает с кнопками установки на `/cluster-addons`.
- `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение.
- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым, а `aggregate_cluster_resources` — нулевая сводка.
- `aggregate_cluster_resources` — агрегаты по **запущенным** узлам для донат-диаграмм на главной: средние проценты CPU/RAM, средняя доля RAM из строки `memory_usage`, кольца сети/диска по суммарному I/O (шкала 0–100 относительно порога 8 GiB на полное кольцо).

---

## GET /api/v1/clusters

Список: объединение `kind get clusters` и подкаталогов `clusters/*` (без дубликатов).

**Пример ответа 200 (массив):**

```json
[
  {
    "name": "dev",
    "registered_in_kind": true,
    "kind_nodes_running": true,
    "has_local_kubeconfig": true,
    "has_provision_log": true,
    "meta": {
      "cluster_name": "dev",
      "kubernetes_version_tag": "v1.29.4",
      "node_image": "kindest/node:v1.29.4",
      "worker_nodes": 2,
      "kubeconfig_patched_for_host": true
    }
  }
]
```

- `kind_nodes_running` — в списке процессов контейнерного движка есть узлы kind с префиксом имени (`имя-control-plane`, `имя-worker`…); для UI: при `true` показывается действие «Стоп», иначе при необходимости подъёма — «Старт».
- `has_provision_log` — в каталоге кластера есть файл **`provision_log.json`** (последнее завершённое задание создания или старта по сохранённому конфигу).

---

## GET /api/v1/helm/chart-versions

Список версий чартов для выпадающих списков на **`/cluster-addons`**. Выполняется **`helm repo update`** и для каждого чарта — **`helm search repo <chart> --versions -o json`**. Результаты кэшируются на **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`** (по умолчанию **600** с) отдельно для каждого рефа чарта. Длина списка ограничена **`KIND_K8S_HELM_VERSIONS_MAX`** (по умолчанию **80**).

**Пример ответа 200:**

```json
{
  "ingress_nginx": ["4.14.0", "4.13.3", "4.12.1"],
  "kube_prometheus_stack": ["69.0.0", "68.4.3"],
  "metrics_server": ["3.12.2", "3.12.1"],
  "istio": ["1.24.3", "1.24.2"],
  "kiali_server": ["2.1.0", "2.0.0"]
}
```

При недоступном **helm** — **503** с текстом ошибки.

---

## GET /api/v1/journal/recent

Записи из **`journal/jobs_history.json`**. Без параметра **`cluster`** — объединение по всем кластерам, сортировка по времени (новые первыми). С **`cluster=<имя>`** — только файл выбранного каталога (порядок как в файле, новые сверху). В каждой записи есть **`source_cluster`**.

**Query:**

| Параметр | Описание |
|----------|----------|
| `limit` | Записей на страницу, **1–100**, по умолчанию **30** |
| `offset` | Смещение, **≥ 0**, по умолчанию **0** |
| `cluster` | Необязательно: DNS-имя кластера — только его **`jobs_history.json`** |

**Поля ответа:** **`limit`**, **`offset`**, **`total`**, **`page`**, **`total_pages`**, **`entries`** (элементы **`JournalEntryModel`** — в т.ч. **`log_lines`**).

**Пример ответа 200 (первая страница, до 30 записей):**

```json
{
  "limit": 30,
  "offset": 0,
  "total": 75,
  "page": 1,
  "total_pages": 3,
  "entries": [
    {
      "job_id": "a1b2c3",
      "kind": "create_cluster",
      "cluster_name": "dev",
      "source_cluster": "dev",
      "status": "success",
      "message": null,
      "created_at_utc": "2026-04-04T11:58:00+00:00",
      "finished_at_utc": "2026-04-04T12:05:00+00:00",
      "log_lines": ["[1] …", "[2] …"],
      "result": { "cluster_name": "dev" }
    }
  ]
}
```

---

## GET /api/v1/journal/provision

Для каждого каталога **`clusters/<имя>/`**, где есть **`provision_log.json`**, читается один объект; список сортируется по **`finished_at_utc`** (новые первыми). Пагинация: **`limit`**, **`offset`** (те же ограничения, что у **`/journal/recent`**).

**Ответ:** **`JournalPagedDirLogsResponse`** — в каждом элементе **`entries`** поля как у файла provision: **`kind`**, **`lines`**, **`status`**, **`message`**, **`result`**, плюс **`source_cluster`**.

---

## GET /api/v1/journal/helm-addons

Аналогично **`/journal/provision`**, но источник — **`helm_addon_log.json`**: для каждого кластера разворачиваются **все** элементы массива **`entries`** (история операций Helm), строки сортируются по **`finished_at_utc`** (новые первыми). Формат одной записи совпадает с **`provision_log.json`**.

---

## GET /api/v1/clusters/{name}/journal

Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**.

**Пример ответа 200:**

```json
{
  "cluster_name": "dev",
  "file_version": 1,
  "entries": [
    {
      "version": 1,
      "job_id": "a1b2c3",
      "kind": "stop_containers",
      "cluster_name": "dev",
      "status": "success",
      "message": null,
      "created_at_utc": "2026-04-04T10:00:00+00:00",
      "finished_at_utc": "2026-04-04T10:00:15+00:00",
      "log_lines": [],
      "result": null
    }
  ]
}
```

---

## GET /api/v1/jobs

Список последних фоновых заданий, от новых к старым. Поле **`progress_log`** в каждом элементе **пустое** — используйте **GET /api/v1/jobs/{job_id}** для журнала.

**Query:** `limit` (1–200, по умолчанию **30**).

**Пример ответа 200 (массив `JobView`):**

```json
[
  {
    "job_id": "abc123",
    "kind": "create_cluster",
    "status": "success",
    "cluster_name": "dev",
    "created_at_utc": "2026-04-04T12:00:00+00:00",
    "message": "Кластер создан",
    "result": { "cluster_name": "dev", "kubernetes_version_tag": "v1.29.4" },
    "progress_stage": null,
    "progress_percent": null,
    "progress_log": []
  }
]
```

---

## DELETE /api/v1/jobs

Удаляет из памяти записи заданий со статусом **success**, **failed** или **cancelled**. Задания **queued** и **running** не удаляются.

**Пример ответа 200:**

```json
{
  "removed": 4
}
```

---

## POST /api/v1/jobs/{job_id}/cancel

Прервать фоновое задание (`create_cluster`, `start_cluster`, `start_cluster_reapply`, `start_containers`, `stop_containers`). Для длительной команды завершается связанный дочерний процесс; между шагами запуска/остановки отдельных узлов также проверяется флаг отмены.

**Пример ответа 200:**

```json
{
  "job_id": "a1b2…",
  "cancel_requested": true,
  "message": "Запрошено прерывание; текущая команда будет остановлена, задание перейдёт в отменено"
}
```

**Ошибка 400:** задание уже завершено.  
**Ошибка 404:** неизвестный `job_id`.

---

## GET /api/v1/clusters/{name}/config

Чтение **`clusters/<имя>/meta.json`** и текста **`kind-config.yaml`**. Поле **`kind_note`** напоминает: kind **не** меняет топологию и образ уже созданного кластера — новый YAML на диске применится при следующем **`kind create`** (после `kind delete` и «Старт» / пересоздания).

**Ошибка 404:** каталога кластера нет.  
**Ошибка 400:** некорректное имя.

**Пример ответа 200 (JSON):**

```json
{
  "cluster_name": "dev",
  "meta": {
    "cluster_name": "dev",
    "kubernetes_version_tag": "v1.29.4",
    "worker_nodes": 2,
    "node_image": "kindest/node:v1.29.4",
    "description": "Тестовый стенд"
  },
  "kind_config_yaml": "kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n- role: control-plane\n  image: kindest/node:v1.29.4\n- role: worker\n  image: kindest/node:v1.29.4\n",
  "has_kind_config": true,
  "registered_in_kind": true,
  "kind_note": "Файлы сохранены на диске. Кластер уже зарегистрирован в kind: действующие узлы и образ не меняются до удаления кластера из kind и повторного создания по обновлённому kind-config.yaml (или «Старт», если запись в kind удалена, а каталог данных остался)."
}
```

---

## PUT /api/v1/clusters/{name}/config

Сохранение на диск. Нужно **хотя бы одно** поле тела:

| Поле | Назначение |
|------|------------|
| `kubernetes_version` | Тег `kindest/node` для **пересборки** простого YAML (control-plane + N worker) |
| `workers` | Число worker-нод 0–20 (вместе с версией из запроса или из meta) |
| `kind_config_yaml` | **Полная замена** `kind-config.yaml` (проверка: `kind: Cluster`, `apiVersion` с `kind.x-k8s.io`, `nodes`, есть `control-plane`) |
| `description` | Строка в meta (пустая строка **сбрасывает** поле) |

Если передан непустой **`kind_config_yaml`**, поля **`kubernetes_version`** и **`workers`** для генерации YAML **не используются** (можно не отправлять).

**Пример тела (только описание):**

```json
{
  "description": "Обновлённая заметка"
}
```

**Пример тела (простой режим):**

```json
{
  "kubernetes_version": "1.30.0",
  "workers": 3,
  "description": "Три воркера"
}
```

**Пример тела (расширенный YAML):**

```json
{
  "kind_config_yaml": "kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n- role: control-plane\n  image: kindest/node:v1.29.4\n",
  "description": ""
}
```

**Пример ответа 200 (JSON):**

```json
{
  "cluster_name": "dev",
  "updated_kind_config_yaml": true,
  "message": "Сохранено.",
  "registered_in_kind": true,
  "kind_note": "Файлы сохранены на диске. Кластер уже зарегистрирован в kind: действующие узлы и образ не меняются до удаления кластера из kind и повторного создания по обновлённому kind-config.yaml (или «Старт», если запись в kind удалена, а каталог данных остался).",
  "summary": {
    "name": "dev",
    "registered_in_kind": true,
    "kind_nodes_running": true,
    "has_local_kubeconfig": true,
    "has_provision_log": true,
    "meta": {
      "kubernetes_version_tag": "v1.30.0",
      "worker_nodes": 3
    }
  }
}
```

**Ошибка 404:** каталога кластера нет.  
**Ошибка 400:** некорректное имя, пустое тело, ошибка разбора или структуры YAML.

---

## GET /api/v1/clusters/{name}/kubeconfig

Скачать kubeconfig для **kubectl на машине пользователя** (ответ — тело файла, `Content-Disposition`: `kubeconfig-{name}.yaml`).

При каждом запросе копируется **`clusters/{name}/kubeconfig`** и патчится `server=https://<KIND_K8S_KUBECONFIG_CLIENT_HOST или localhost>:<порт>` (порт из `docker port … 6443/tcp`). Если патч не удалился — отдаётся сохранённый **`kubeconfig.host`** или сырой **`kubeconfig`**.

**Ошибка 404:** нет ни `kubeconfig.host`, ни `kubeconfig` в `clusters/{name}/`.

**Ошибка 400:** некорректное имя кластера.

---

## GET /api/v1/clusters/{name}/kubeconfig/docker

Скачать kubeconfig для **kubectl из произвольного контейнера** (`Content-Disposition`: `kubeconfig-docker-{name}.yaml`).

Логика как у **kubectl внутри веб-контейнера** (`kubeconfig_patch.apply_apiserver_endpoint_to_kubeconfig_file`): при успешном **`docker port <имя>-control-plane 6443/tcp`** — **`server=https://<KIND_K8S_APISERVER_GATEWAY_HOST>:<порт>`** (по умолчанию **host.docker.internal**) и **`tls-server-name`** из **`KIND_K8S_KUBECONFIG_TLS_SERVER_NAME`** (по умолчанию **localhost**). Иначе запасной **`server=https://<имя>-control-plane:6443`** без SNI. На Linux у клиентского контейнера обычно нужен **`extra_hosts: host.docker.internal:host-gateway`**.

**Ошибка 404:** нет файла **`kubeconfig`** в `clusters/{name}/`.

**Ошибка 500:** не удалось выполнить `kubectl config set-cluster` / задать SNI.

**Ошибка 400:** некорректное имя кластера.

---

## GET /api/v1/clusters/{name}/provision-log

Содержимое **`clusters/<имя>/provision_log.json`**: полный журнал строк последнего завершённого задания **create_cluster**, **start_cluster** или **start_cluster_reapply** (включая успех, ошибку и отмену), плюс метаданные (`job_id`, `kind`, `status`, `message`, `result`, `finished_at_utc`).

**Ошибка 404:** файла нет (кластер ещё не создавали/не стартовали с сохранением журнала, или каталог без записи).

**Ошибка 400:** некорректное имя кластера.

**Пример ответа 200 (JSON):**

```json
{
  "version": 1,
  "job_id": "a1b2c3d4",
  "kind": "create_cluster",
  "cluster_name": "dev",
  "finished_at_utc": "2026-04-04T14:30:00+00:00",
  "status": "success",
  "message": "Кластер создан",
  "lines": [
    "Creating cluster \"dev\" ...",
    " • Ensuring node image (kindest/node:v1.29.4) 🖼"
  ],
  "result": {
    "cluster_name": "dev",
    "kubernetes_version_tag": "v1.29.4",
    "kubeconfig_path": "/work/clusters/dev/kubeconfig"
  }
}
```

---

## GET /api/v1/clusters/{name}/workloads

`kubectl get nodes -o wide` и `kubectl get pods -A` по сохранённому kubeconfig.

**Пример ответа 200:**

```json
{
  "cluster_name": "dev",
  "nodes_rc": 0,
  "nodes_output": "NAME                 STATUS   ROLES           ...",
  "pods_rc": 0,
  "pods_output": "NAMESPACE     NAME                           READY   STATUS    ...",
  "error": null
}
```

Если kubeconfig нет: `error` — строка вида **`Нет сохранённого kubeconfig в clusters/<имя>/`**, поля вывода kubectl могут быть `null`.

Если кластер **есть в kind**, но **узлы остановлены** (`docker stop` / «Стоп» в UI): **`error`** — краткое сообщение без вызова kubectl (вместо сырого stderr про DNS `*-control-plane`).

**Ошибка 400:** некорректное имя кластера.

---

## GET /api/v1/clusters/{name}/overview

Единый ответ для **страницы кластера** (`GET /cluster/{name}`): флаги и `meta` как в списке кластеров, **метрики узлов** (docker/podman), **`aggregate_resources`** (как в `GET /stats` для донатов), данные Kubernetes в виде **`kubectl get … -o json`** (разбор `items` на фронтенде в таблицы).

- При отсутствии kubeconfig: **`kubeconfig_error`** — строка-пояснение; блоки **`k8s_*`** недоступны (см. ниже).
- Если кластер **зарегистрирован в kind**, но **узлы не запущены** (`kind_nodes_running: false`): **`kubectl` не вызывается**; в **`kubeconfig_error`** и в **`message`** каждого блока **`k8s_*`** — одно и то же дружелюбное пояснение (без stderr про недоступный API).
- Поля **`nodes_rc` / `nodes_output`**, **`pods_rc` / `pods_output`**, … — устаревший текстовый вывод; в текущей версии API могут быть **`null`** (UI использует только JSON-блоки).
- Блоки **`k8s_nodes`**, **`k8s_namespaces`**, **`k8s_pods`**, **`k8s_deployments`**, **`k8s_statefulsets`**, **`k8s_daemonsets`**, **`k8s_replicasets`**, **`k8s_jobs`**, **`k8s_cronjobs`**, **`k8s_services`**, **`k8s_ingresses`** (ресурс **`ingresses.networking.k8s.io -A`**), **`k8s_pvcs`** — объекты вида **`K8sListJsonBlock`**; для ресурсов в namespace везде **`kubectl get … -A`** (все пространства имён).
  - **`ok`**: `true` при успешном `kubectl`;
  - **`rc`**: код возврата;
  - **`items`**: массив объектов из `kubectl` (как в API Kubernetes);
  - **`message`**: текст ошибки при `ok: false`.
- **`resources_error`** — ошибка сбора метрик контейнерного CLI (как `cluster_resources_error` в `/stats`).
- **`cluster_resources`** — один блок `KindClusterResources` (узлы с полями `cpu_percent`, `memory_usage`, …).

**Пример ответа 200 (фрагмент):**

```json
{
  "cluster_name": "dev",
  "registered_in_kind": true,
  "kind_nodes_running": true,
  "has_local_kubeconfig": true,
  "has_provision_log": true,
  "meta": { "worker_nodes": 2, "kubernetes_version_tag": "v1.29.4" },
  "resources_error": null,
  "cluster_resources": {
    "cluster_name": "dev",
    "nodes": [
      {
        "container_name": "dev-control-plane",
        "cpu_percent": "2.10%",
        "memory_usage": "800MiB / 7.7GiB",
        "memory_percent": "10.14%",
        "net_io": "1.2MB / 800kB",
        "block_io": "0B / 0B",
        "pids": 120
      }
    ],
    "note": null
  },
  "aggregate_resources": {
    "nodes_count": 3,
    "cpu_ring": 5.2,
    "cpu_label": "5.2%",
    "memory_percent_ring": 12.0,
    "memory_percent_label": "12%",
    "memory_used_ratio_ring": 45.0,
    "memory_used_ratio_label": "2.1 / 4.6 GiB",
    "network_ring": 8.0,
    "network_label": "↓ 10 MB",
    "disk_ring": 3.0,
    "disk_label": "R/W 2 MB"
  },
  "kubeconfig_error": null,
  "k8s_nodes": {
    "ok": true,
    "rc": 0,
    "items": [{ "metadata": { "name": "dev-control-plane" }, "status": { "conditions": [] } }],
    "message": null
  },
  "k8s_pods": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_deployments": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_statefulsets": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_daemonsets": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_services": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_ingresses": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_namespaces": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_replicasets": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_jobs": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_cronjobs": { "ok": true, "rc": 0, "items": [], "message": null },
  "k8s_pvcs": { "ok": true, "rc": 0, "items": [], "message": null }
}
```

**Ошибка 400:** некорректное имя кластера.

---

## POST /api/v1/clusters/{name}/pods/restart

Мягкий **рестарт пода**: выполняется **`kubectl delete pod`** в указанном namespace (под пересоздаётся, если им управляет Deployment/ReplicaSet и т.д.).

**Тело запроса (JSON):**

```json
{
  "namespace": "default",
  "pod": "my-app-7d4f8b9-xk2cp"
}
```

**Пример ответа 200:**

```json
{
  "ok": true,
  "cluster_name": "dev",
  "namespace": "default",
  "pod": "my-app-7d4f8b9-xk2cp",
  "message": null
}
```

**Ошибка 400:** неверное имя кластера, namespace или pod (валидация как в Kubernetes).

**Ошибка 502:** `kubectl delete` завершился с ненулевым кодом (текст stderr в теле ответа от сервера).

---

## GET /api/v1/clusters/{name}

Детали и попытка `kubectl get nodes -o wide` с **сохранённого** `clusters/{name}/kubeconfig` (если файл есть).

**Пример ответа 200:**

```json
{
  "name": "dev",
  "registered_in_kind": true,
  "has_local_kubeconfig": true,
  "kubeconfig_path": "/work/clusters/dev/kubeconfig",
  "meta": { "worker_nodes": 2 },
  "kubectl_get_nodes_rc": 0,
  "kubectl_get_nodes": "NAME                 STATUS   ROLES           AGE   VERSION   INTERNAL-IP   EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION   CONTAINER-RUNTIME\n..."
}
```

---

## POST /api/v1/clusters

Создание кластера **в фоне** (ответ **202**).

**Тело запроса (JSON):**

```json
{
  "name": "dev",
  "kubernetes_version": "1.29.4",
  "workers": 2
}
```

**Пример ответа 202:**

```json
{
  "job_id": "a1b2c3d4e5f6...",
  "status": "queued",
  "message": "Создание кластера выполняется в фоне; опросите GET /api/v1/jobs/{job_id}"
}
```

**Ошибка 400:** невалидное имя кластера или тело не проходит валидацию Pydantic.

**Ошибка 409 (кластер уже есть в kind):**

```json
{
  "detail": "Кластер с таким именем уже есть в kind"
}
```

---

## POST /api/v1/clusters/{name}/start

Запуск кластера (все варианты — **202** и `job_id`, журнал в `GET /jobs/{job_id}`):

1. Кластер **зарегистрирован** в kind и в **`meta.json`** **нет** флага **`apply_kind_config_on_next_start`** — задание **`start_containers`** (поочерёдный `docker start` узлов, `mode`: **`containers`**).
2. Кластер **зарегистрирован** в kind и после **PUT …/config** с изменением **`kind-config.yaml`** в **`meta.json`** выставлен **`apply_kind_config_on_next_start`: true** — задание **`start_cluster_reapply`** (`mode`: **`kind_config_reapply`**): `kind delete cluster` **без** удаления `clusters/<имя>/`, затем **`kind create`** по сохранённому YAML (как при создании; флаг в новом `meta.json` не сохраняется).
3. В kind кластера **нет**, но есть **`clusters/<имя>/kind-config.yaml`** — задание **`start_cluster`** (`mode`: **`kind_config`**).

Остановка (**POST …/stop**) флаг пересоздания **не сбрасывает**; достаточно после правки конфига нажать **«Старт»** (после стопа или сразу — выполнится сценарий 2, если кластер всё ещё в kind).

**Пример ответа 202 (запуск узлов):**

```json
{
  "job_id": "cafebabe...",
  "status": "queued",
  "mode": "containers",
  "message": "Запуск узлов; опросите GET /api/v1/jobs/{job_id}"
}
```

**Пример ответа 202 (подъём по конфигу):**

```json
{
  "job_id": "deadbeef...",
  "status": "queued",
  "mode": "kind_config",
  "message": "Подъём кластера по сохранённому конфигу; опросите GET /api/v1/jobs/{job_id}"
}
```

**Пример ответа 202 (пересоздание после правки YAML):**

```json
{
  "job_id": "c0ffee...",
  "status": "queued",
  "mode": "kind_config_reapply",
  "message": "Удаление записи в kind и создание кластера по обновлённому kind-config.yaml; GET /api/v1/jobs/{job_id}"
}
```

**Ошибка 400:** некорректное имя; или нет ни кластера в kind, ни `kind-config.yaml`; или для **`kind_config_reapply`** отсутствует `kind-config.yaml`.

---

## POST /api/v1/clusters/{name}/stop

Остановка узлов кластера **в фоне** (задание **`stop_containers`**). Запись кластера в kind **не удаляется**; позже снова **POST …/start**.

**Пример ответа 202:**

```json
{
  "job_id": "baba...",
  "status": "queued",
  "mode": "stop",
  "message": "Остановка узлов; опросите GET /api/v1/jobs/{job_id}"
}
```

**Ошибка 400:** некорректное имя кластера.

---

## GET /api/v1/jobs/{job_id}

Статус фонового задания создания.

**В процессе (пример 200):**

```json
{
  "job_id": "a1b2...",
  "kind": "create_cluster",
  "status": "running",
  "cluster_name": "dev",
  "created_at_utc": "2026-04-04T12:00:00+00:00",
  "message": null,
  "result": null,
  "progress_stage": "Создание узлов кластера",
  "progress_percent": 45,
  "progress_log": [
    "Подготовка конфигурации",
    "Using default tag: latest",
    "Status: Downloaded newer image for kindest/node:v1.29.4",
    "Creating cluster \"dev\" ..."
  ]
}
```

**Успех (пример 200):**

```json
{
  "job_id": "a1b2...",
  "kind": "create_cluster",
  "status": "success",
  "cluster_name": "dev",
  "created_at_utc": "2026-04-04T12:00:00+00:00",
  "message": "Кластер создан",
  "progress_log": ["Завершение", "Узлы готовы: condition met"],
  "result": {
    "cluster_name": "dev",
    "kubernetes_version_tag": "v1.29.4",
    "node_image": "kindest/node:v1.29.4",
    "workers": 2,
    "kubeconfig_path": "/work/clusters/dev/kubeconfig",
    "kubeconfig_patched_for_host": true,
    "nodes_ready": true,
    "nodes_ready_message": "..."
  }
}
```

**Ошибка 404:**

```json
{
  "detail": "Задание не найдено"
}
```

---

## DELETE /api/v1/clusters/{name}

`kind delete cluster` и удаление каталога `clusters/{name}/`.

**Пример ответа 200:**

```json
{
  "name": "dev",
  "kind_delete_ok": true,
  "summary": "kind delete: OK; удалена папка /work/clusters/dev"
}
```

**Ошибка 400:** некорректное имя кластера.  
**Ошибка 500:** логическая ошибка удаления (тело с `detail`).

---

## GET /

HTML-дашборд (не JSON): см. раздел «Веб-интерфейс и статика» выше.

---

**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
