UI: адаптив, журнал, спиннеры; docs: README и api_routes
- Навигация: выезжающее меню при узком экране (nav-mobile.js) - Журнал: карточки <620px, компактная пагинация, время в две строки <920px - Создание кластера: оверлей загрузки, инкрементальное обновление таблицы заданий - Документация: полноэкранный спиннер при загрузке и навигации - Главная: масштабирование CTA, статистика 2 колонки <520px, донаты перенос <710px - README: env.example, новые фичи UI, автор в конце файла - api_routes: маршрут /cluster-create, спиннеры, шаблоны; автор в конце - env.example: автор перенесён в конец файла
This commit is contained in:
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
Образ **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** — внутри контейнера.
|
||||||
|
|
||||||
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
| Ресурс | Описание |
|
| Ресурс | Описание |
|
||||||
@@ -11,7 +9,7 @@
|
|||||||
| **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) |
|
| **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) |
|
||||||
| **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) |
|
| **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) |
|
||||||
|
|
||||||
Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`; в начале — только выбор **docker** или **podman**, путь **`CONTAINER_SOCKET`** подставляется автоматически).
|
В корне репозитория — файл **`env.example`**: перечислены **только имена** переменных (без значений), для ориентира при ручной настройке **`.env`**. Полноценно создать **`.env`** можно интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`; в начале — выбор **docker** или **podman**, путь **`CONTAINER_SOCKET`** подставляется автоматически).
|
||||||
|
|
||||||
## Зачем это нужно
|
## Зачем это нужно
|
||||||
|
|
||||||
@@ -24,17 +22,21 @@
|
|||||||
|
|
||||||
- Верхняя **единая карточка**: заголовок, краткое описание и строка **состояния среды** (`kind` / `kubectl` / Docker или Podman API).
|
- Верхняя **единая карточка**: заголовок, краткое описание и строка **состояния среды** (`kind` / `kubectl` / Docker или Podman API).
|
||||||
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
|
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
|
||||||
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
|
- **Создание кластера** (`/cluster-create`): форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке); блок **«Последние задания»** (журнал с диска) обновляется без полной перерисовки таблицы — новые записи добавляются сверху, раскрытый лог не сбрасывается при автообновлении.
|
||||||
- **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание kubeconfig, модалки узлов/подов, ссылка на страницу кластера; с **панели** (`/`) — быстрый переход по ссылке в карточке «Создать кластер».
|
- **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание kubeconfig, модалки узлов/подов, ссылка на страницу кластера; с **панели** (`/`) — кнопка **«Перейти к созданию кластера»** (размер кнопки плавно уменьшается на узком экране).
|
||||||
- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx**, **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (Kiali по умолчанию без формы входа, `auth` при необходимости в YAML values); журнал операции на странице (прогресс + вывод как при создании кластера), история в **`clusters/<имя>/helm_addon_log.json`**. Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с).
|
- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx**, **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (Kiali по умолчанию без формы входа, `auth` при необходимости в YAML values); журнал операции на странице (прогресс + вывод как при создании кластера), история в **`clusters/<имя>/helm_addon_log.json`**. Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с).
|
||||||
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
|
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
|
||||||
|
- **Журнал** (`/journal`): три режима (по кластеру, развёртывание, Helm-аддоны), пагинация; на узком экране таблица записей превращается в **карточки** (как «Последние задания» на странице создания).
|
||||||
|
- **Документация** (`/documentation`): README и `app/docs/*.md` в браузере (Markdown); при загрузке и при переходе между файлами — полноэкранный **спиннер** (как на панели).
|
||||||
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
|
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
|
||||||
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
|
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
|
||||||
- Уведомления (toast) при успехе/ошибке; в подвале — копирайт и ссылка на **devops.org.ru**.
|
- Уведомления (toast) при успехе/ошибке; в подвале — копирайт и ссылка на **devops.org.ru**.
|
||||||
|
|
||||||
**Шапка:** навигация в виде **пилюль** (стили `.nav-pill`); пункты **Swagger**, **ReDoc** и **Health** открывают страницу в **отдельном именованном окне** (~1240×840), чтобы не уходить с панели (см. скрипт в `base.html`).
|
**Шапка:** навигация в виде **пилюль** (стили `.nav-pill`); при ширине окна **меньше ~920px** — кнопка **«гамбургер»** и выезжающая панель (`app/static/js/nav-mobile.js`). Пункты **Swagger**, **ReDoc** и **Health** открывают страницу в **отдельном именованном окне** (~1240×840), чтобы не уходить с панели (см. скрипт в `base.html`).
|
||||||
|
|
||||||
**Структура фронтенда:** `app/templates/base.html` (шапка и меню), `app/templates/dashboard.html`, `app/static/style.css`, `app/static/js/dashboard.js` (префикс API: `data-api-base` на `<body>`, по умолчанию `/api/v1`).
|
**Адаптивная вёрстка (кратко):** донаты «Ресурсы узлов» переносятся на новые строки при ширине окна **меньше ~710px**; блок **«Статистика»** на главной — мини-карточки в **две колонки** при **меньше ~520px**; таблицы **кластеров** и **«Последние задания»** на `/cluster-create` при ширине **меньше ~920px** оформляются **карточками**; на **/journal** карточки записей — при **меньше ~620px**; в журнале и таблицах заданий колонка времени (UTC) — дата и время **в две строки** при **меньше ~920px**. При первой загрузке полноэкранный спиннер: **главная**, **/clusters**, **страница кластера**, **/cluster-create**, **/documentation** (`app/static/style.css` — `.page-loading-overlay`).
|
||||||
|
|
||||||
|
**Структура фронтенда:** `app/templates/base.html` (шапка и меню), `app/templates/dashboard.html`, `app/templates/journal.html`, `app/templates/documentation.html`, `app/static/style.css`, `app/static/js/dashboard.js`, `app/static/js/journal.js`, `app/static/js/documentation.js`, `app/static/js/nav-mobile.js` (префикс API: `data-api-base` на `<body>`, по умолчанию `/api/v1`).
|
||||||
|
|
||||||
## Требования на хосте
|
## Требования на хосте
|
||||||
|
|
||||||
@@ -200,7 +202,7 @@ make podman up
|
|||||||
| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) |
|
| `app/api/v1/` | REST API: `router.py`, `endpoints/` (`health`, `versions`, `docs_readme`, `clusters`) |
|
||||||
| `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
|
| `app/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
|
||||||
| `app/models/schemas.py` | Pydantic-схемы запросов/ответов API |
|
| `app/models/schemas.py` | Pydantic-схемы запросов/ответов API |
|
||||||
| `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `documentation.html` |
|
| `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `clusters.html`, `cluster_create.html`, `cluster_detail.html`, `cluster_edit.html`, `cluster_addons.html`, `journal.html`, `documentation.html` |
|
||||||
| `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) |
|
| `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) |
|
||||||
| `app/docs/` | `api_routes.md` (описание REST API) |
|
| `app/docs/` | `api_routes.md` (описание REST API) |
|
||||||
| `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` |
|
| `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` |
|
||||||
@@ -226,3 +228,7 @@ make podman up
|
|||||||
- **kubectl** на хосте **не обязателен**: используйте веб-UI или **`make docker kubectl`** / **`make podman kubectl`** (см. выше).
|
- **kubectl** на хосте **не обязателен**: используйте веб-UI или **`make docker kubectl`** / **`make podman kubectl`** (см. выше).
|
||||||
- История заданий в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается. Завершённые записи можно удалить из памяти кнопкой на панели или **`DELETE /api/v1/jobs`**.
|
- История заданий в UI/API хранится в памяти (до **200** записей); после перезапуска контейнера очищается. Завершённые записи можно удалить из памяти кнопкой на панели или **`DELETE /api/v1/jobs`**.
|
||||||
- При **`exec format error`** у kind пересоберите образ: `make docker rebuild COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или **`make docker build`** без `--no-cache`, или `linux/amd64`).
|
- При **`exec format error`** у kind пересоберите образ: `make docker rebuild COMPOSE_BUILD_FLAGS=--platform linux/arm64` (или `make podman …`, или **`make docker build`** без `--no-cache`, или `linux/amd64`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Описание REST API веб-интерфейса Kind Clusters Dashboard
|
# Описание REST API веб-интерфейса Kind Clusters Dashboard
|
||||||
|
|
||||||
**Базовый префикс:** `/api/v1`
|
**Базовый префикс:** `/api/v1`
|
||||||
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
|
||||||
|
|
||||||
## Как смотреть документацию
|
## Как смотреть документацию
|
||||||
|
|
||||||
@@ -19,17 +18,18 @@
|
|||||||
|
|
||||||
| Маршрут | Описание |
|
| Маршрут | Описание |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| `GET /` | HTML **Панель**: CTA создания кластера, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. |
|
| `GET /` | HTML **Панель**: CTA **«Перейти к созданию кластера»**, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. Полноэкранный спиннер первой загрузки — как у **`/cluster-create`** и **`/documentation`**. |
|
||||||
| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. |
|
| `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}` | 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/{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 /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 /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). Каждая секция по **H2** — **одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к 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). Полноэкранный спиннер при первой загрузке и при **каждом** переходе по внутренним ссылкам / **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 /ui` | Редирект **307** на `/` (удобный ярлык). |
|
||||||
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.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/clusters.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/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**.
|
**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**.
|
||||||
|
|
||||||
@@ -1008,3 +1008,7 @@ Accept: text/markdown
|
|||||||
## GET /
|
## GET /
|
||||||
|
|
||||||
HTML-дашборд (не JSON): см. раздел «Веб-интерфейс и статика» выше.
|
HTML-дашборд (не JSON): см. раздел «Веб-интерфейс и статика» выше.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
||||||
|
|||||||
+332
-24
@@ -96,6 +96,16 @@
|
|||||||
document.body.classList.remove("dashboard-clusters-loading");
|
document.body.classList.remove("dashboard-clusters-loading");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Скрыть спиннер первой загрузки страницы «Создание кластера» (/cluster-create). */
|
||||||
|
function hideCreatePageLoadingOverlay() {
|
||||||
|
const el = document.getElementById("create-page-loading-overlay");
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add("hidden");
|
||||||
|
el.setAttribute("aria-busy", "false");
|
||||||
|
el.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.classList.remove("dashboard-create-loading");
|
||||||
|
}
|
||||||
|
|
||||||
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
|
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
|
||||||
function showClusterLifecycleJobOverlay(labelText) {
|
function showClusterLifecycleJobOverlay(labelText) {
|
||||||
const el = document.getElementById("cluster-lifecycle-job-overlay");
|
const el = document.getElementById("cluster-lifecycle-job-overlay");
|
||||||
@@ -969,8 +979,12 @@
|
|||||||
const tbody = document.createElement("tbody");
|
const tbody = document.createElement("tbody");
|
||||||
rows.forEach(function (cells) {
|
rows.forEach(function (cells) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
cells.forEach(function (cell) {
|
cells.forEach(function (cell, colIdx) {
|
||||||
const td = document.createElement("td");
|
const td = document.createElement("td");
|
||||||
|
const lab = headers[colIdx];
|
||||||
|
if (lab != null && String(lab).trim() !== "") {
|
||||||
|
td.setAttribute("data-label", String(lab));
|
||||||
|
}
|
||||||
if (cell != null && typeof cell === "object" && cell.nodeType === 1) {
|
if (cell != null && typeof cell === "object" && cell.nodeType === 1) {
|
||||||
td.appendChild(cell);
|
td.appendChild(cell);
|
||||||
} else {
|
} else {
|
||||||
@@ -2012,6 +2026,9 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка таблицы: версия и workers в ``.cluster-list-value`` — в карточках (<920px) значение по центру правой колонки.
|
||||||
|
*/
|
||||||
async function loadClusters() {
|
async function loadClusters() {
|
||||||
const tbody = document.querySelector("#tbl-clusters tbody");
|
const tbody = document.querySelector("#tbl-clusters tbody");
|
||||||
const msg = document.getElementById("list-msg");
|
const msg = document.getElementById("list-msg");
|
||||||
@@ -2049,12 +2066,12 @@
|
|||||||
"<td class=\"col-kubeconfig\">" +
|
"<td class=\"col-kubeconfig\">" +
|
||||||
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
"<td class=\"col-version\">" +
|
"<td class=\"col-version\"><span class=\"cluster-list-value\">" +
|
||||||
escapeHtml(String(ver)) +
|
escapeHtml(String(ver)) +
|
||||||
"</td>" +
|
"</span></td>" +
|
||||||
"<td class=\"col-workers\">" +
|
"<td class=\"col-workers\"><span class=\"cluster-list-value\">" +
|
||||||
escapeHtml(String(wn)) +
|
escapeHtml(String(wn)) +
|
||||||
"</td>" +
|
"</span></td>" +
|
||||||
"<td class=\"col-state\">" +
|
"<td class=\"col-state\">" +
|
||||||
stateCell +
|
stateCell +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
@@ -2094,6 +2111,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Время для ячейки таблицы: разметка дата + время (при ширине до 920px — колонкой, см. .journal-time-stack в style.css).
|
||||||
|
* @param {string} iso
|
||||||
|
* @returns {string} безопасный HTML
|
||||||
|
*/
|
||||||
|
function formatJournalUtcDisplayHtml(iso) {
|
||||||
|
if (!iso) {
|
||||||
|
return escapeHtml("—");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return escapeHtml(iso);
|
||||||
|
}
|
||||||
|
const day = d.toISOString().slice(0, 10);
|
||||||
|
const clock = d.toISOString().slice(11, 19);
|
||||||
|
return (
|
||||||
|
'<span class="journal-time-stack">' +
|
||||||
|
'<span class="journal-time-date">' +
|
||||||
|
escapeHtml(day) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="journal-time-clock">' +
|
||||||
|
escapeHtml(clock) +
|
||||||
|
"</span>" +
|
||||||
|
"</span>"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return escapeHtml(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Класс плашки статуса записи журнала (success / failed / cancelled).
|
* Класс плашки статуса записи журнала (success / failed / cancelled).
|
||||||
* @param {string} st
|
* @param {string} st
|
||||||
@@ -2136,22 +2184,83 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
|
* Стабильный ключ строки журнала на странице создания (сверка DOM ↔ API при инкрементальном обновлении).
|
||||||
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
|
* @param {Record<string, unknown>} e запись из API
|
||||||
*/
|
*/
|
||||||
async function loadJobs() {
|
function createJobJournalRowKey(e) {
|
||||||
const tbody = document.querySelector("#tbl-jobs tbody");
|
const jid = String(e.job_id || "").trim();
|
||||||
const msg = document.getElementById("jobs-msg");
|
if (jid) return jid;
|
||||||
if (!tbody) return;
|
return (
|
||||||
try {
|
"legacy:" +
|
||||||
const data = await api("/journal/recent?limit=30&offset=0");
|
String(e.finished_at_utc || e.created_at_utc || "") +
|
||||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
":" +
|
||||||
|
String(e.source_cluster || e.cluster_name || "") +
|
||||||
|
":" +
|
||||||
|
String(e.kind || "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает ключи строк с раскрытым логом — только для полной перерисовки при рассинхроне.
|
||||||
|
* @param {HTMLElement | null} tbody
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
function getExpandedCreateJobsRowKeys(tbody) {
|
||||||
|
const keys = new Set();
|
||||||
|
if (!tbody) return keys;
|
||||||
|
tbody.querySelectorAll("tr.journal-details-row:not(.hidden)").forEach(function (trDetails) {
|
||||||
|
const tr = trDetails.previousElementSibling;
|
||||||
|
if (!tr || !tr.classList.contains("journal-data-row")) return;
|
||||||
|
const k = tr.getAttribute("data-journal-row-key");
|
||||||
|
if (k) keys.add(k);
|
||||||
|
});
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстанавливает раскрытие после полной замены tbody (fallback).
|
||||||
|
* @param {HTMLElement} tbody
|
||||||
|
* @param {Set<string>} expandedKeys
|
||||||
|
*/
|
||||||
|
function restoreExpandedCreateJobsRows(tbody, expandedKeys) {
|
||||||
|
if (!expandedKeys.size) return;
|
||||||
|
let tr = tbody.firstElementChild;
|
||||||
|
while (tr) {
|
||||||
|
if (tr.classList.contains("journal-data-row")) {
|
||||||
|
const k = tr.getAttribute("data-journal-row-key");
|
||||||
|
const next = tr.nextElementSibling;
|
||||||
|
if (
|
||||||
|
k &&
|
||||||
|
expandedKeys.has(k) &&
|
||||||
|
next &&
|
||||||
|
next.classList.contains("journal-details-row")
|
||||||
|
) {
|
||||||
|
next.classList.remove("hidden");
|
||||||
|
const pre = next.querySelector("pre.journal-log-pre");
|
||||||
|
if (pre) pre.hidden = false;
|
||||||
|
const btn = tr.querySelector("button.btn-journal-toggle");
|
||||||
|
if (btn && !btn.disabled) {
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
btn.textContent = "Скрыть лог";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr = tr.nextElementSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пара строк «данные + опционально лог» для таблицы заданий на /cluster-create.
|
||||||
|
* @param {Record<string, unknown>} e
|
||||||
|
* @returns {DocumentFragment}
|
||||||
|
*/
|
||||||
|
function buildCreateJobRowFragment(e) {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
entries.forEach(function (e) {
|
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "journal-data-row";
|
tr.className = "journal-data-row";
|
||||||
|
tr.setAttribute("data-journal-row-key", createJobJournalRowKey(e));
|
||||||
const cluster = e.source_cluster || e.cluster_name || "—";
|
const cluster = e.source_cluster || e.cluster_name || "—";
|
||||||
const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc);
|
const finishedHtml = formatJournalUtcDisplayHtml(e.finished_at_utc || e.created_at_utc);
|
||||||
const kind = e.kind || "";
|
const kind = e.kind || "";
|
||||||
const message = (e.message || "").trim();
|
const message = (e.message || "").trim();
|
||||||
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
|
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
|
||||||
@@ -2159,24 +2268,24 @@
|
|||||||
const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
|
const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
|
||||||
|
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
"<td class=\"journal-cell-time\">" +
|
"<td class=\"journal-cell-time create-jobs-card-td create-jobs-card-td--time\">" +
|
||||||
escapeHtml(finished) +
|
finishedHtml +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
"<td><code class=\"journal-code\">" +
|
"<td class=\"create-jobs-card-td create-jobs-card-td--cluster\"><code class=\"journal-code\">" +
|
||||||
escapeHtml(cluster) +
|
escapeHtml(cluster) +
|
||||||
"</code></td>" +
|
"</code></td>" +
|
||||||
"<td><code class=\"journal-code journal-cell-kind\">" +
|
"<td class=\"create-jobs-card-td create-jobs-card-td--kind\"><code class=\"journal-code journal-cell-kind\">" +
|
||||||
escapeHtml(kind) +
|
escapeHtml(kind) +
|
||||||
"</code></td>" +
|
"</code></td>" +
|
||||||
"<td><span class=\"" +
|
"<td class=\"create-jobs-card-td create-jobs-card-td--status\"><span class=\"" +
|
||||||
escapeHtml(journalStatusPillClass(e.status)) +
|
escapeHtml(journalStatusPillClass(e.status)) +
|
||||||
"\">" +
|
"\">" +
|
||||||
escapeHtml(e.status || "") +
|
escapeHtml(e.status || "") +
|
||||||
"</span></td>" +
|
"</span></td>" +
|
||||||
"<td class=\"journal-cell-msg\">" +
|
"<td class=\"journal-cell-msg create-jobs-card-td create-jobs-card-td--msg\"><span class=\"create-jobs-msg-value\">" +
|
||||||
escapeHtml(message || "—") +
|
escapeHtml(message || "—") +
|
||||||
"</td>" +
|
"</span></td>" +
|
||||||
"<td class=\"journal-cell-actions\">" +
|
"<td class=\"journal-cell-actions create-jobs-card-td create-jobs-card-td--log\">" +
|
||||||
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
|
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
|
||||||
escapeHtml(detailsId) +
|
escapeHtml(detailsId) +
|
||||||
"\">Показать лог</button>" +
|
"\">Показать лог</button>" +
|
||||||
@@ -2203,8 +2312,206 @@
|
|||||||
btn.textContent = "Нет строк";
|
btn.textContent = "Нет строк";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Первая строка данных журнала в tbody или null. */
|
||||||
|
function getFirstCreateJobsDataRow(tbody) {
|
||||||
|
let tr = tbody.firstElementChild;
|
||||||
|
while (tr) {
|
||||||
|
if (tr.classList.contains("journal-data-row")) return tr;
|
||||||
|
tr = tr.nextElementSibling;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пары (строка данных, строка лога или null) сверху вниз.
|
||||||
|
* @param {HTMLElement} tbody
|
||||||
|
* @returns {{ dataRow: HTMLElement, detailsRow: HTMLElement | null }[]}
|
||||||
|
*/
|
||||||
|
function collectCreateJobsRowPairs(tbody) {
|
||||||
|
const pairs = [];
|
||||||
|
let tr = tbody.firstElementChild;
|
||||||
|
while (tr) {
|
||||||
|
if (!tr.classList.contains("journal-data-row")) {
|
||||||
|
tr = tr.nextElementSibling;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dataRow = tr;
|
||||||
|
const nx = dataRow.nextElementSibling;
|
||||||
|
let detailsRow = null;
|
||||||
|
if (nx && nx.classList.contains("journal-details-row")) {
|
||||||
|
detailsRow = nx;
|
||||||
|
tr = nx.nextElementSibling;
|
||||||
|
} else {
|
||||||
|
tr = nx;
|
||||||
|
}
|
||||||
|
pairs.push({ dataRow: dataRow, detailsRow: detailsRow });
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет последнюю пару строк (хвост при сдвиге окна limit). */
|
||||||
|
function removeLastCreateJobsPair(tbody) {
|
||||||
|
const pairs = collectCreateJobsRowPairs(tbody);
|
||||||
|
if (!pairs.length) return;
|
||||||
|
const last = pairs[pairs.length - 1];
|
||||||
|
if (last.detailsRow) last.detailsRow.remove();
|
||||||
|
last.dataRow.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вставляет сверху записи из API, пока не встретится ключ текущей верхней строки (новые — только префикс списка).
|
||||||
|
* @param {HTMLElement} tbody
|
||||||
|
* @param {Record<string, unknown>[]} entries
|
||||||
|
*/
|
||||||
|
function prependNewCreateJobEntries(tbody, entries) {
|
||||||
|
const first = getFirstCreateJobsDataRow(tbody);
|
||||||
|
const firstKey = first ? first.getAttribute("data-journal-row-key") : null;
|
||||||
|
const toPrepend = [];
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const k = createJobJournalRowKey(entries[i]);
|
||||||
|
if (firstKey !== null && k === firstKey) break;
|
||||||
|
toPrepend.push(entries[i]);
|
||||||
|
}
|
||||||
|
for (let i = toPrepend.length - 1; i >= 0; i--) {
|
||||||
|
tbody.insertBefore(buildCreateJobRowFragment(toPrepend[i]), tbody.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Оставляет не больше targetCount пар строк. */
|
||||||
|
function trimCreateJobsTail(tbody, targetCount) {
|
||||||
|
while (collectCreateJobsRowPairs(tbody).length > targetCount) {
|
||||||
|
removeLastCreateJobsPair(tbody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет ячейки и текст лога без замены узлов — раскрытие и скролл не сбрасываются.
|
||||||
|
* @param {{ dataRow: HTMLElement, detailsRow: HTMLElement | null }} pair
|
||||||
|
* @param {Record<string, unknown>} e
|
||||||
|
*/
|
||||||
|
function updateCreateJobPairFromEntry(pair, e) {
|
||||||
|
const cluster = e.source_cluster || e.cluster_name || "—";
|
||||||
|
const finishedHtml = formatJournalUtcDisplayHtml(e.finished_at_utc || e.created_at_utc);
|
||||||
|
const kind = e.kind || "";
|
||||||
|
const message = (e.message || "").trim();
|
||||||
|
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
|
||||||
|
const logText = lines.join("\n");
|
||||||
|
|
||||||
|
const timeTd = pair.dataRow.querySelector(".create-jobs-card-td--time");
|
||||||
|
if (timeTd) timeTd.innerHTML = finishedHtml;
|
||||||
|
const clusterCode = pair.dataRow.querySelector(".create-jobs-card-td--cluster .journal-code");
|
||||||
|
if (clusterCode) clusterCode.textContent = cluster;
|
||||||
|
const kindCode = pair.dataRow.querySelector(".journal-cell-kind");
|
||||||
|
if (kindCode) kindCode.textContent = kind;
|
||||||
|
const statusWrap = pair.dataRow.querySelector(".create-jobs-card-td--status span");
|
||||||
|
if (statusWrap) {
|
||||||
|
statusWrap.className = journalStatusPillClass(e.status);
|
||||||
|
statusWrap.textContent = e.status || "";
|
||||||
|
}
|
||||||
|
const msgSpan = pair.dataRow.querySelector(".create-jobs-msg-value");
|
||||||
|
if (msgSpan) msgSpan.textContent = message || "—";
|
||||||
|
|
||||||
|
const btn = pair.dataRow.querySelector(".btn-journal-toggle");
|
||||||
|
if (logText) {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
const expanded = btn.getAttribute("aria-expanded") === "true";
|
||||||
|
btn.textContent = expanded ? "Скрыть лог" : "Показать лог";
|
||||||
|
}
|
||||||
|
if (pair.detailsRow) {
|
||||||
|
const pre = pair.detailsRow.querySelector("pre.journal-log-pre");
|
||||||
|
if (pre) pre.textContent = logText;
|
||||||
|
} else {
|
||||||
|
const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
|
||||||
|
const trDetails = document.createElement("tr");
|
||||||
|
trDetails.className = "journal-details-row hidden";
|
||||||
|
trDetails.innerHTML =
|
||||||
|
"<td colspan=\"6\" class=\"journal-details-cell\">" +
|
||||||
|
"<pre id=\"" +
|
||||||
|
escapeHtml(detailsId) +
|
||||||
|
"\" class=\"mono journal-log-pre\" hidden></pre>" +
|
||||||
|
"</td>";
|
||||||
|
const pre = trDetails.querySelector("pre");
|
||||||
|
if (pre) pre.textContent = logText;
|
||||||
|
pair.dataRow.insertAdjacentElement("afterend", trDetails);
|
||||||
|
pair.detailsRow = trDetails;
|
||||||
|
if (btn) {
|
||||||
|
btn.setAttribute("aria-controls", detailsId);
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
btn.textContent = "Показать лог";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pair.detailsRow) {
|
||||||
|
pair.detailsRow.remove();
|
||||||
|
pair.detailsRow = null;
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Нет строк";
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
btn.removeAttribute("aria-controls");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полная замена tbody одним фрагментом (первый заход или несовпадение ключей с API).
|
||||||
|
* @param {HTMLElement} tbody
|
||||||
|
* @param {Record<string, unknown>[]} entries
|
||||||
|
*/
|
||||||
|
function replaceCreateJobsTableBody(tbody, entries) {
|
||||||
|
const expandedKeys = getExpandedCreateJobsRowKeys(tbody);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
entries.forEach(function (e) {
|
||||||
|
frag.appendChild(buildCreateJobRowFragment(e));
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(frag);
|
tbody.replaceChildren(frag);
|
||||||
|
restoreExpandedCreateJobsRows(tbody, expandedKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
|
||||||
|
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
|
||||||
|
* Обычное обновление: только новые строки сверху + подрезка хвоста + правка ячеек; без полной пересборки DOM.
|
||||||
|
*/
|
||||||
|
async function loadJobs() {
|
||||||
|
const tbody = document.querySelector("#tbl-jobs tbody");
|
||||||
|
const msg = document.getElementById("jobs-msg");
|
||||||
|
if (!tbody) return;
|
||||||
|
try {
|
||||||
|
const data = await api("/journal/recent?limit=30&offset=0");
|
||||||
|
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||||
|
|
||||||
|
if (!getFirstCreateJobsDataRow(tbody)) {
|
||||||
|
replaceCreateJobsTableBody(tbody, entries);
|
||||||
|
} else {
|
||||||
|
prependNewCreateJobEntries(tbody, entries);
|
||||||
|
trimCreateJobsTail(tbody, entries.length);
|
||||||
|
const pairs = collectCreateJobsRowPairs(tbody);
|
||||||
|
if (pairs.length !== entries.length) {
|
||||||
|
replaceCreateJobsTableBody(tbody, entries);
|
||||||
|
} else {
|
||||||
|
let keysOk = true;
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
if (createJobJournalRowKey(entries[i]) !== pairs[i].dataRow.getAttribute("data-journal-row-key")) {
|
||||||
|
keysOk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!keysOk) {
|
||||||
|
replaceCreateJobsTableBody(tbody, entries);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
updateCreateJobPairFromEntry(pairs[i], entries[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (msg) {
|
if (msg) {
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
msg.textContent =
|
msg.textContent =
|
||||||
@@ -2846,6 +3153,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
hideHomePageLoadingOverlay();
|
hideHomePageLoadingOverlay();
|
||||||
hideClustersPageLoadingOverlay();
|
hideClustersPageLoadingOverlay();
|
||||||
|
hideCreatePageLoadingOverlay();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
if (isCreatePage()) {
|
if (isCreatePage()) {
|
||||||
|
|||||||
@@ -24,6 +24,25 @@
|
|||||||
el.classList.add("hidden");
|
el.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Полноэкранный спиннер как на панели (page-loading-overlay в documentation.html). */
|
||||||
|
function showDocumentationPageLoadingOverlay() {
|
||||||
|
var el = document.getElementById("documentation-page-loading-overlay");
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove("hidden");
|
||||||
|
el.setAttribute("aria-busy", "true");
|
||||||
|
el.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.classList.add("documentation-page-loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDocumentationPageLoadingOverlay() {
|
||||||
|
var el = document.getElementById("documentation-page-loading-overlay");
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add("hidden");
|
||||||
|
el.setAttribute("aria-busy", "false");
|
||||||
|
el.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.classList.remove("documentation-page-loading");
|
||||||
|
}
|
||||||
|
|
||||||
function getPathFromLocation() {
|
function getPathFromLocation() {
|
||||||
var q = new URLSearchParams(window.location.search || "");
|
var q = new URLSearchParams(window.location.search || "");
|
||||||
var p = (q.get("path") || "").trim();
|
var p = (q.get("path") || "").trim();
|
||||||
@@ -193,10 +212,36 @@
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdownToRoot(text, docPath, rootEl, errEl, loadEl) {
|
/**
|
||||||
|
* Таблицы Markdown с более чем двумя колонками: класс и data-label для мини-карточек при ширине до 480px (CSS).
|
||||||
|
* @param {HTMLElement} rootEl
|
||||||
|
*/
|
||||||
|
function markupWideMarkdownTables(rootEl) {
|
||||||
|
if (!rootEl || !rootEl.querySelectorAll) return;
|
||||||
|
rootEl.querySelectorAll(".markdown-body table").forEach(function (table) {
|
||||||
|
var thList = table.querySelectorAll("thead tr th");
|
||||||
|
if (!thList.length) return;
|
||||||
|
var n = thList.length;
|
||||||
|
if (n <= 2) return;
|
||||||
|
table.classList.add("markdown-body-table--stack");
|
||||||
|
var labels = [];
|
||||||
|
for (var i = 0; i < thList.length; i++) {
|
||||||
|
labels.push((thList[i].textContent || "").trim());
|
||||||
|
}
|
||||||
|
var tbody = table.querySelector("tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.querySelectorAll("tr").forEach(function (tr) {
|
||||||
|
var tds = tr.querySelectorAll("td");
|
||||||
|
for (var j = 0; j < tds.length; j++) {
|
||||||
|
if (labels[j]) tds[j].setAttribute("data-label", labels[j]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdownToRoot(text, docPath, rootEl, errEl) {
|
||||||
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/.");
|
||||||
if (loadEl) loadEl.classList.add("hidden");
|
|
||||||
applyDocumentationTabTitle("");
|
applyDocumentationTabTitle("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -223,6 +268,7 @@
|
|||||||
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
|
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
|
||||||
var pageInner = sectionizeFromNodes(temp);
|
var pageInner = sectionizeFromNodes(temp);
|
||||||
rootEl.appendChild(pageInner);
|
rootEl.appendChild(pageInner);
|
||||||
|
markupWideMarkdownTables(rootEl);
|
||||||
rootEl.removeAttribute("hidden");
|
rootEl.removeAttribute("hidden");
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2)));
|
showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2)));
|
||||||
@@ -242,18 +288,20 @@
|
|||||||
|
|
||||||
var rootEl = document.getElementById("readme-doc-root");
|
var rootEl = document.getElementById("readme-doc-root");
|
||||||
var errEl = document.getElementById("readme-error");
|
var errEl = document.getElementById("readme-error");
|
||||||
var loadEl = document.getElementById("readme-loading");
|
if (!rootEl) {
|
||||||
if (!rootEl || !loadEl) return;
|
hideDocumentationPageLoadingOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDocumentationPageLoadingOverlay();
|
||||||
|
try {
|
||||||
hideErr(errEl);
|
hideErr(errEl);
|
||||||
rootEl.setAttribute("hidden", "hidden");
|
rootEl.setAttribute("hidden", "hidden");
|
||||||
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
|
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
|
||||||
loadEl.classList.remove("hidden");
|
|
||||||
|
|
||||||
updateBreadcrumbs(docPath);
|
updateBreadcrumbs(docPath);
|
||||||
|
|
||||||
var url = fetchUrlForPath(docPath);
|
var url = fetchUrlForPath(docPath);
|
||||||
try {
|
|
||||||
var r = await fetch(url, {
|
var r = await fetch(url, {
|
||||||
headers: { Accept: "text/markdown, text/plain, */*" },
|
headers: { Accept: "text/markdown, text/plain, */*" },
|
||||||
});
|
});
|
||||||
@@ -267,13 +315,11 @@
|
|||||||
/* raw */
|
/* raw */
|
||||||
}
|
}
|
||||||
showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText));
|
showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText));
|
||||||
loadEl.classList.add("hidden");
|
|
||||||
applyDocumentationTabTitle("");
|
applyDocumentationTabTitle("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderMarkdownToRoot(text, docPath, rootEl, errEl, loadEl);
|
renderMarkdownToRoot(text, docPath, rootEl, errEl);
|
||||||
loadEl.classList.add("hidden");
|
|
||||||
|
|
||||||
var newUrl = docUrlForPath(docPath);
|
var newUrl = docUrlForPath(docPath);
|
||||||
if (push) {
|
if (push) {
|
||||||
@@ -283,8 +329,9 @@
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showErr(errEl, "Ошибка: " + (e.message || String(e)));
|
showErr(errEl, "Ошибка: " + (e.message || String(e)));
|
||||||
loadEl.classList.add("hidden");
|
|
||||||
applyDocumentationTabTitle("");
|
applyDocumentationTabTitle("");
|
||||||
|
} finally {
|
||||||
|
hideDocumentationPageLoadingOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +356,10 @@
|
|||||||
|
|
||||||
function run() {
|
function run() {
|
||||||
var rootEl = document.getElementById("readme-doc-root");
|
var rootEl = document.getElementById("readme-doc-root");
|
||||||
if (!rootEl) return;
|
if (!rootEl) {
|
||||||
|
hideDocumentationPageLoadingOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("popstate", onPopState);
|
window.addEventListener("popstate", onPopState);
|
||||||
rootEl.addEventListener("click", onRootClick);
|
rootEl.addEventListener("click", onRootClick);
|
||||||
|
|||||||
+58
-12
@@ -80,6 +80,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ячейка «время»: дата и время отдельными span (при ширине до 920px — в две строки, см. CSS .journal-time-stack).
|
||||||
|
* @param {string} iso
|
||||||
|
* @returns {string} безопасный HTML
|
||||||
|
*/
|
||||||
|
function formatUtcCellHtml(iso) {
|
||||||
|
if (!iso) {
|
||||||
|
return esc("—");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return esc(iso);
|
||||||
|
}
|
||||||
|
const day = d.toISOString().slice(0, 10);
|
||||||
|
const clock = d.toISOString().slice(11, 19);
|
||||||
|
return (
|
||||||
|
'<span class="journal-time-stack">' +
|
||||||
|
'<span class="journal-time-date">' +
|
||||||
|
esc(day) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="journal-time-clock">' +
|
||||||
|
esc(clock) +
|
||||||
|
"</span>" +
|
||||||
|
"</span>"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return esc(iso);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function statusClass(st) {
|
function statusClass(st) {
|
||||||
const s = (st || "").toLowerCase();
|
const s = (st || "").toLowerCase();
|
||||||
if (s === "success") return "journal-status-pill journal-status-pill--ok";
|
if (s === "success") return "journal-status-pill journal-status-pill--ok";
|
||||||
@@ -189,11 +220,12 @@
|
|||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Короткий текст на кнопках — одна строка пагинации; смысл в aria-label */
|
||||||
wrap.appendChild(
|
wrap.appendChild(
|
||||||
mkBtn("« Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }),
|
mkBtn("««", page <= 1, "Первая страница", function () { onSelectPage(1); }),
|
||||||
);
|
);
|
||||||
wrap.appendChild(
|
wrap.appendChild(
|
||||||
mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
|
mkBtn("‹", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const pagesEl = document.createElement("div");
|
const pagesEl = document.createElement("div");
|
||||||
@@ -227,10 +259,10 @@
|
|||||||
wrap.appendChild(pagesEl);
|
wrap.appendChild(pagesEl);
|
||||||
|
|
||||||
wrap.appendChild(
|
wrap.appendChild(
|
||||||
mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
|
mkBtn("›", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
|
||||||
);
|
);
|
||||||
wrap.appendChild(
|
wrap.appendChild(
|
||||||
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
|
mkBtn("»»", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
|
||||||
);
|
);
|
||||||
|
|
||||||
pagNav.appendChild(wrap);
|
pagNav.appendChild(wrap);
|
||||||
@@ -252,7 +284,7 @@
|
|||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "journal-data-row";
|
tr.className = "journal-data-row";
|
||||||
const cluster = e.source_cluster || e.cluster_name || "—";
|
const cluster = e.source_cluster || e.cluster_name || "—";
|
||||||
const finished = formatUtc(
|
const finishedHtml = formatUtcCellHtml(
|
||||||
e.finished_at_utc || e.created_at_utc,
|
e.finished_at_utc || e.created_at_utc,
|
||||||
);
|
);
|
||||||
const kind = mode === "helm_addons" ? formatHelmJournalKind(e) : (e.kind || "");
|
const kind = mode === "helm_addons" ? formatHelmJournalKind(e) : (e.kind || "");
|
||||||
@@ -266,15 +298,29 @@
|
|||||||
const logText = lines.join("\n");
|
const logText = lines.join("\n");
|
||||||
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
|
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
|
||||||
|
|
||||||
|
/* Классы create-jobs-card-td* — карточки на узком экране (<620px), см. .journal-list-card в style.css */
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
|
"<td class=\"journal-cell-time create-jobs-card-td create-jobs-card-td--time\">" +
|
||||||
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
|
finishedHtml +
|
||||||
"<td><code class=\"journal-code journal-cell-kind\">" + esc(kind) + "</code></td>" +
|
"</td>" +
|
||||||
"<td><span class=\"" + esc(statusClass(e.status)) + "\">" + esc(e.status || "") + "</span></td>" +
|
"<td class=\"create-jobs-card-td create-jobs-card-td--cluster\"><code class=\"journal-code\">" +
|
||||||
"<td class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" +
|
esc(cluster) +
|
||||||
"<td class=\"journal-cell-actions\">" +
|
"</code></td>" +
|
||||||
|
"<td class=\"create-jobs-card-td create-jobs-card-td--kind\"><code class=\"journal-code journal-cell-kind\">" +
|
||||||
|
esc(kind) +
|
||||||
|
"</code></td>" +
|
||||||
|
"<td class=\"create-jobs-card-td create-jobs-card-td--status\"><span class=\"" +
|
||||||
|
esc(statusClass(e.status)) +
|
||||||
|
"\">" +
|
||||||
|
esc(e.status || "") +
|
||||||
|
"</span></td>" +
|
||||||
|
"<td class=\"journal-cell-msg create-jobs-card-td create-jobs-card-td--msg\"><span class=\"create-jobs-msg-value\">" +
|
||||||
|
esc(msg || "—") +
|
||||||
|
"</span></td>" +
|
||||||
|
"<td class=\"journal-cell-actions create-jobs-card-td create-jobs-card-td--log\">" +
|
||||||
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
|
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
|
||||||
detailsId + "\">Показать лог</button>" +
|
detailsId +
|
||||||
|
"\">Показать лог</button>" +
|
||||||
"</td>";
|
"</td>";
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Адаптивное меню шапки: выезжающая панель при ширине viewport < 920px.
|
||||||
|
* Закрытие по клику на подложку, Escape, переходу на другую страницу и при расширении окна.
|
||||||
|
*
|
||||||
|
* Автор: Сергей Антропов
|
||||||
|
* Сайт: https://devops.org.ru
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var MQ = "(max-width: 919px)";
|
||||||
|
var OPEN_CLASS = "top-nav-actions--open";
|
||||||
|
var BODY_CLASS = "nav-mobile-open";
|
||||||
|
var TOGGLE_OPEN_CLASS = "nav-menu-toggle--open";
|
||||||
|
|
||||||
|
function mqMobile() {
|
||||||
|
return window.matchMedia(MQ).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрывает выпадающее меню API (дублирует логику из base.html, чтобы не дублировать DOM-события). */
|
||||||
|
function closeApiDropdown() {
|
||||||
|
var dd = document.querySelector(".nav-dropdown");
|
||||||
|
if (!dd) return;
|
||||||
|
var btn = document.getElementById("nav-api-trigger");
|
||||||
|
var menu = document.getElementById("nav-api-menu");
|
||||||
|
dd.classList.remove("nav-dropdown--open");
|
||||||
|
if (btn) btn.setAttribute("aria-expanded", "false");
|
||||||
|
if (menu) menu.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEls() {
|
||||||
|
return {
|
||||||
|
toggle: document.getElementById("nav-menu-toggle"),
|
||||||
|
drawer: document.getElementById("top-nav-drawer"),
|
||||||
|
backdrop: document.getElementById("nav-menu-backdrop"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpen(open) {
|
||||||
|
var els = getEls();
|
||||||
|
if (!els.toggle || !els.drawer || !els.backdrop) return;
|
||||||
|
|
||||||
|
els.toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
els.toggle.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
open ? "Закрыть меню разделов" : "Открыть меню разделов",
|
||||||
|
);
|
||||||
|
els.toggle.classList.toggle(TOGGLE_OPEN_CLASS, open);
|
||||||
|
els.drawer.classList.toggle(OPEN_CLASS, open);
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
closeApiDropdown();
|
||||||
|
els.backdrop.removeAttribute("hidden");
|
||||||
|
els.backdrop.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.classList.add(BODY_CLASS);
|
||||||
|
var closeBtn = document.getElementById("nav-drawer-close");
|
||||||
|
if (closeBtn && mqMobile()) {
|
||||||
|
try {
|
||||||
|
closeBtn.focus();
|
||||||
|
} catch (e) {
|
||||||
|
/* focus не всегда доступен */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
els.backdrop.setAttribute("hidden", "");
|
||||||
|
els.backdrop.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.classList.remove(BODY_CLASS);
|
||||||
|
closeApiDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
var els = getEls();
|
||||||
|
if (!els.drawer) return;
|
||||||
|
setOpen(!els.drawer.classList.contains(OPEN_CLASS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeIfMobile() {
|
||||||
|
if (mqMobile()) setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
if (!mqMobile()) setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var els = getEls();
|
||||||
|
if (!els.toggle || !els.drawer || !els.backdrop) return;
|
||||||
|
|
||||||
|
els.toggle.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!mqMobile()) return;
|
||||||
|
toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.backdrop.addEventListener("click", function () {
|
||||||
|
closeIfMobile();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") closeIfMobile();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
/* Закрытие при уходе со страницы по ссылке внутри панели */
|
||||||
|
els.drawer.addEventListener("click", function (e) {
|
||||||
|
var a = e.target.closest("a[href]");
|
||||||
|
if (!a || !mqMobile()) return;
|
||||||
|
if (a.getAttribute("href") === "#" || a.getAttribute("href") === "") return;
|
||||||
|
if (a.getAttribute("target") === "_blank") return;
|
||||||
|
/* data-open-window открывает новое окно — страница не перезагружается */
|
||||||
|
if (a.hasAttribute("data-open-window")) return;
|
||||||
|
setOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
var closeDrawer = document.getElementById("nav-drawer-close");
|
||||||
|
if (closeDrawer) {
|
||||||
|
closeDrawer.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeIfMobile();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+1690
-61
File diff suppressed because it is too large
Load Diff
+45
-2
@@ -28,6 +28,7 @@
|
|||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
<script src="/static/js/theme.js" defer></script>
|
<script src="/static/js/theme.js" defer></script>
|
||||||
|
<script src="/static/js/nav-mobile.js" defer></script>
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
@@ -52,8 +53,50 @@
|
|||||||
/>
|
/>
|
||||||
<span class="nav-title">{{ app_title }}</span>
|
<span class="nav-title">{{ app_title }}</span>
|
||||||
</a>
|
</a>
|
||||||
{# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #}
|
{# При ширине < 920px — «гамбургер»; панель #top-nav-drawer справа (nav-mobile.js). #}
|
||||||
<nav class="nav-links top-nav-actions" aria-label="Разделы и оформление">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-menu-toggle"
|
||||||
|
id="nav-menu-toggle"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="top-nav-drawer"
|
||||||
|
aria-label="Открыть меню разделов"
|
||||||
|
>
|
||||||
|
<span class="nav-menu-toggle__bars" aria-hidden="true">
|
||||||
|
<span class="nav-menu-toggle__bar"></span>
|
||||||
|
<span class="nav-menu-toggle__bar"></span>
|
||||||
|
<span class="nav-menu-toggle__bar"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="nav-menu-backdrop"
|
||||||
|
id="nav-menu-backdrop"
|
||||||
|
hidden
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{# Один ряд flex (десктоп): пилюли, API, тема; на мобильных — содержимое в выезжающей панели #}
|
||||||
|
<nav class="nav-links top-nav-actions" id="top-nav-drawer" aria-label="Разделы и оформление">
|
||||||
|
{# Шапка панели только на узких экранах (CSS); кнопка дублирует закрытие с «гамбургера». #}
|
||||||
|
<div class="nav-drawer-header">
|
||||||
|
<span class="nav-drawer-title">Меню</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nav-drawer-close"
|
||||||
|
id="nav-drawer-close"
|
||||||
|
aria-label="Закрыть меню"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M6 6l12 12M18 6L6 18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
|
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
|
||||||
<div class="nav-links-scroll">
|
<div class="nav-links-scroll">
|
||||||
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
|
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block body_extra_class %} dashboard-create-loading{% endblock %}
|
||||||
|
|
||||||
{% block body_attrs %}data-dashboard-mode="create"{% endblock %}
|
{% block body_attrs %}data-dashboard-mode="create"{% endblock %}
|
||||||
|
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
@@ -14,10 +16,25 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div
|
||||||
|
id="create-page-loading-overlay"
|
||||||
|
class="page-loading-overlay"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="Загрузка страницы создания кластера"
|
||||||
|
>
|
||||||
|
<div class="page-loading-backdrop" aria-hidden="true"></div>
|
||||||
|
<div class="page-loading-center">
|
||||||
|
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
|
||||||
|
<span class="page-loading-label">Загрузка страницы…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="card hero-panel cluster-create-hero" aria-labelledby="create-hero-title">
|
<section class="card hero-panel cluster-create-hero" aria-labelledby="create-hero-title">
|
||||||
<h1 class="page-title" id="create-hero-title">Создание кластера</h1>
|
<h1 class="page-title" id="create-hero-title">Создание кластера</h1>
|
||||||
<p class="muted page-lead">
|
<p class="muted page-lead">
|
||||||
Укажите имя, версию образа kindest/node и число worker-нод. Операция выполняется в фоне; ход смотрите в журнале ниже.
|
Укажите имя, версию образа kindest/node и число worker-нод. Операция выполняется в фоне.<br>Ход смотрите в журнале ниже.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,10 @@
|
|||||||
<div class="cta-hero-inner">
|
<div class="cta-hero-inner">
|
||||||
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
|
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
|
||||||
<p class="muted page-lead cta-hero-lead">
|
<p class="muted page-lead cta-hero-lead">
|
||||||
Список кластеров, сводка по узлам и действия — на странице <a href="/clusters">Кластеры</a>. Данные там обновляются автоматически.
|
Список кластеров, сводка по узлам и действия — на странице <a href="/clusters">Кластеры</a>.
|
||||||
</p>
|
</p>
|
||||||
<a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a>
|
<p></p>
|
||||||
|
<a href="/cluster-create" class="btn btn-cta-large">Перейти к созданию кластера</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
{# Документация: README и app/docs/*.md через API + marked + DOMPurify (vendor, без CDN).
|
{# Документация: README и app/docs/*.md через API + marked + DOMPurify (vendor, без CDN). #}
|
||||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{# Ширина колонки как у панели: .app-main max-width 72rem (секция «Ресурсы узлов (сводка)» и др.). #}
|
{# Ширина колонки как у панели: .app-main max-width 72rem (секция «Ресурсы узлов (сводка)» и др.). #}
|
||||||
|
|
||||||
{% block page_title %}Документация{% endblock %}
|
{% block page_title %}Документация{% endblock %}
|
||||||
|
|
||||||
|
{% block body_extra_class %} documentation-page-loading{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div
|
||||||
|
id="documentation-page-loading-overlay"
|
||||||
|
class="page-loading-overlay"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label="Загрузка документации"
|
||||||
|
>
|
||||||
|
<div class="page-loading-backdrop" aria-hidden="true"></div>
|
||||||
|
<div class="page-loading-center">
|
||||||
|
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
|
||||||
|
<span class="page-loading-label">Загрузка документации…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav class="doc-breadcrumbs" id="doc-breadcrumbs" aria-label="Навигация по документации">
|
<nav class="doc-breadcrumbs" id="doc-breadcrumbs" aria-label="Навигация по документации">
|
||||||
<ol class="doc-breadcrumbs__list" id="doc-breadcrumbs-list"></ol>
|
<ol class="doc-breadcrumbs__list" id="doc-breadcrumbs-list"></ol>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="readme-doc-shell" id="readme-doc-shell">
|
<div class="readme-doc-shell" id="readme-doc-shell">
|
||||||
<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>
|
||||||
<div id="readme-doc-root" class="readme-doc-page" hidden></div>
|
<div id="readme-doc-root" class="readme-doc-page" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{# Автор: Сергей Антропов — https://devops.org.ru (в конце текста README и app/docs/api_routes.md). #}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/vendor/marked.min.js"></script>
|
<script src="/static/js/vendor/marked.min.js"></script>
|
||||||
<script src="/static/js/vendor/purify.min.js"></script>
|
<script src="/static/js/vendor/purify.min.js"></script>
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,4 @@
|
|||||||
# Переменные окружения Kind Clusters Dashboard (только имена; значения задаются в .env или в shell).
|
# Переменные окружения Kind Clusters Dashboard (только имена; значения задаются в .env или в shell).
|
||||||
# Автор: Сергей Антропов — https://devops.org.ru
|
|
||||||
|
|
||||||
KIND_VERSION
|
KIND_VERSION
|
||||||
KUBECTL_VERSION
|
KUBECTL_VERSION
|
||||||
@@ -38,3 +37,5 @@ KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES
|
|||||||
KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES
|
KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES
|
||||||
KIND_K8S_APP_TITLE
|
KIND_K8S_APP_TITLE
|
||||||
KIND_K8S_UVICORN_RELOAD
|
KIND_K8S_UVICORN_RELOAD
|
||||||
|
|
||||||
|
# Автор: Сергей Антропов — https://devops.org.ru
|
||||||
|
|||||||
Reference in New Issue
Block a user