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:
Sergey Antropoff
2026-04-05 00:18:19 +03:00
parent 4b703801e1
commit f0b24c8901
12 changed files with 2426 additions and 173 deletions
+14 -8
View File
@@ -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)
+10 -6
View File
@@ -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)
+368 -60
View File
@@ -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
@@ -2135,9 +2183,300 @@
); );
} }
/**
* Стабильный ключ строки журнала на странице создания (сверка DOM ↔ API при инкрементальном обновлении).
* @param {Record<string, unknown>} e запись из API
*/
function createJobJournalRowKey(e) {
const jid = String(e.job_id || "").trim();
if (jid) return jid;
return (
"legacy:" +
String(e.finished_at_utc || e.created_at_utc || "") +
":" +
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 tr = document.createElement("tr");
tr.className = "journal-data-row";
tr.setAttribute("data-journal-row-key", createJobJournalRowKey(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 detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML =
"<td class=\"journal-cell-time create-jobs-card-td create-jobs-card-td--time\">" +
finishedHtml +
"</td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--cluster\"><code class=\"journal-code\">" +
escapeHtml(cluster) +
"</code></td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--kind\"><code class=\"journal-code journal-cell-kind\">" +
escapeHtml(kind) +
"</code></td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--status\"><span class=\"" +
escapeHtml(journalStatusPillClass(e.status)) +
"\">" +
escapeHtml(e.status || "") +
"</span></td>" +
"<td class=\"journal-cell-msg create-jobs-card-td create-jobs-card-td--msg\"><span class=\"create-jobs-msg-value\">" +
escapeHtml(message || "—") +
"</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=\"" +
escapeHtml(detailsId) +
"\">Показать лог</button>" +
"</td>";
frag.appendChild(tr);
if (logText) {
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;
frag.appendChild(trDetails);
} else {
const btn = tr.querySelector(".btn-journal-toggle");
if (btn) {
btn.disabled = true;
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);
restoreExpandedCreateJobsRows(tbody, expandedKeys);
}
/** /**
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания * Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе). * (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
* Обычное обновление: только новые строки сверху + подрезка хвоста + правка ячеек; без полной пересборки DOM.
*/ */
async function loadJobs() { async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody"); const tbody = document.querySelector("#tbl-jobs tbody");
@@ -2146,65 +2485,33 @@
try { try {
const data = await api("/journal/recent?limit=30&offset=0"); const data = await api("/journal/recent?limit=30&offset=0");
const entries = Array.isArray(data.entries) ? data.entries : []; const entries = Array.isArray(data.entries) ? data.entries : [];
const frag = document.createDocumentFragment();
entries.forEach(function (e) {
const tr = document.createElement("tr");
tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatJournalUtcDisplay(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 detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML = if (!getFirstCreateJobsDataRow(tbody)) {
"<td class=\"journal-cell-time\">" + replaceCreateJobsTableBody(tbody, entries);
escapeHtml(finished) + } else {
"</td>" + prependNewCreateJobEntries(tbody, entries);
"<td><code class=\"journal-code\">" + trimCreateJobsTail(tbody, entries.length);
escapeHtml(cluster) + const pairs = collectCreateJobsRowPairs(tbody);
"</code></td>" + if (pairs.length !== entries.length) {
"<td><code class=\"journal-code journal-cell-kind\">" + replaceCreateJobsTableBody(tbody, entries);
escapeHtml(kind) +
"</code></td>" +
"<td><span class=\"" +
escapeHtml(journalStatusPillClass(e.status)) +
"\">" +
escapeHtml(e.status || "") +
"</span></td>" +
"<td class=\"journal-cell-msg\">" +
escapeHtml(message || "—") +
"</td>" +
"<td class=\"journal-cell-actions\">" +
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
escapeHtml(detailsId) +
"\">Показать лог</button>" +
"</td>";
frag.appendChild(tr);
if (logText) {
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;
frag.appendChild(trDetails);
} else { } else {
const btn = tr.querySelector(".btn-journal-toggle"); let keysOk = true;
if (btn) { for (let i = 0; i < entries.length; i++) {
btn.disabled = true; if (createJobJournalRowKey(entries[i]) !== pairs[i].dataRow.getAttribute("data-journal-row-key")) {
btn.textContent = "Нет строк"; keysOk = false;
break;
}
}
if (!keysOk) {
replaceCreateJobsTableBody(tbody, entries);
} else {
for (let i = 0; i < entries.length; i++) {
updateCreateJobPairFromEntry(pairs[i], entries[i]);
}
} }
} }
}); }
tbody.replaceChildren(frag);
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()) {
+67 -17
View File
@@ -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;
}
hideErr(errEl); showDocumentationPageLoadingOverlay();
rootEl.setAttribute("hidden", "hidden");
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
loadEl.classList.remove("hidden");
updateBreadcrumbs(docPath);
var url = fetchUrlForPath(docPath);
try { try {
hideErr(errEl);
rootEl.setAttribute("hidden", "hidden");
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
updateBreadcrumbs(docPath);
var url = fetchUrlForPath(docPath);
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
View File
@@ -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);
+131
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+45 -2
View File
@@ -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>
+18 -1
View File
@@ -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>
+3 -2
View File
@@ -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>
+20 -3
View File
@@ -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
View File
@@ -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