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** — внутри контейнера.
|
||||
|
||||
**Автор:** Сергей Антропов — [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 (для фронтенда и интеграций) |
|
||||
| **`/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, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
|
||||
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
|
||||
- **Кластеры** (`/clusters`): сводка ресурсов узлов (донаты), таблица кластеров — **старт**/**стоп**, скачивание kubeconfig, модалки узлов/подов, ссылка на страницу кластера; с **панели** (`/`) — быстрый переход по ссылке в карточке «Создать кластер».
|
||||
- **Создание кластера** (`/cluster-create`): форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке); блок **«Последние задания»** (журнал с диска) обновляется без полной перерисовки таблицы — новые записи добавляются сверху, раскрытый лог не сбрасывается при автообновлении.
|
||||
- **Кластеры** (`/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 с).
|
||||
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
|
||||
- **Журнал** (`/journal`): три режима (по кластеру, развёртывание, Helm-аддоны), пагинация; на узком экране таблица записей превращается в **карточки** (как «Последние задания» на странице создания).
|
||||
- **Документация** (`/documentation`): README и `app/docs/*.md` в браузере (Markdown); при загрузке и при переходе между файлами — полноэкранный **спиннер** (как на панели).
|
||||
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
|
||||
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
|
||||
- Уведомления (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/core/` | Жизненный цикл кластеров, задания, настройки, блокировки (`kind_guard`), пути |
|
||||
| `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/docs/` | `api_routes.md` (описание REST API) |
|
||||
| `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`** (см. выше).
|
||||
- История заданий в 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`).
|
||||
|
||||
---
|
||||
|
||||
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
||||
|
||||
+10
-6
@@ -1,7 +1,6 @@
|
||||
# Описание REST API веб-интерфейса Kind Clusters Dashboard
|
||||
|
||||
**Базовый префикс:** `/api/v1`
|
||||
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
||||
**Базовый префикс:** `/api/v1`
|
||||
|
||||
## Как смотреть документацию
|
||||
|
||||
@@ -19,17 +18,18 @@
|
||||
|
||||
| Маршрут | Описание |
|
||||
|---------|----------|
|
||||
| `GET /` | HTML **Панель**: CTA создания кластера, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. |
|
||||
| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. |
|
||||
| `GET /` | HTML **Панель**: CTA **«Перейти к созданию кластера»**, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. Полноэкранный спиннер первой загрузки — как у **`/cluster-create`** и **`/documentation`**. |
|
||||
| `GET /cluster-create` | HTML **Создание кластера**: форма, прогресс и журнал задания, таблица **«Последние задания»** (журнал с диска, инкрементальное обновление без сброса раскрытого лога); **`dashboard.js`**. Спиннер первой загрузки. |
|
||||
| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. Спиннер первой загрузки. |
|
||||
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки **Ресурсы узлов**, затем отдельная карточка **Установленные аддоны** (**мини-карточки Helm**, **`GET /api/v1/clusters/{name}/addons/status`**, ссылки на **`/cluster-addons`**), **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). |
|
||||
| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. В шапке активна **Кластеры**. |
|
||||
| `GET /cluster-addons` | HTML **Аддоны**: **`GET /helm/chart-versions`** + **`GET …/addons/status`**; для **установленных** релизов — **`GET …/addons/installed-values`**: в селекте версия с пометкой «(текущая установленная версия)», в форме — values из кластера. По кнопке **«Загрузить values»** — шаблон из **`POST /helm/addons/compose-values`**. Установка/удаление релизов. |
|
||||
| `GET /journal` | HTML **Журналы**: переключатель (как «Простой/Расширенный» в редактировании кластера) — **по кластеру** (`journal/recent?cluster=`), **развёртывание** (`/journal/provision`), **Helm-аддоны** (`/journal/helm-addons`); пагинация по **30** записей. |
|
||||
| `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path` — **`GET /api/v1/docs/readme`**, с `?path=app/docs/…` — **`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **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 /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**.
|
||||
|
||||
@@ -1008,3 +1008,7 @@ Accept: text/markdown
|
||||
## GET /
|
||||
|
||||
HTML-дашборд (не JSON): см. раздел «Веб-интерфейс и статика» выше.
|
||||
|
||||
---
|
||||
|
||||
**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru)
|
||||
|
||||
+368
-60
@@ -96,6 +96,16 @@
|
||||
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). */
|
||||
function showClusterLifecycleJobOverlay(labelText) {
|
||||
const el = document.getElementById("cluster-lifecycle-job-overlay");
|
||||
@@ -969,8 +979,12 @@
|
||||
const tbody = document.createElement("tbody");
|
||||
rows.forEach(function (cells) {
|
||||
const tr = document.createElement("tr");
|
||||
cells.forEach(function (cell) {
|
||||
cells.forEach(function (cell, colIdx) {
|
||||
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) {
|
||||
td.appendChild(cell);
|
||||
} else {
|
||||
@@ -2012,6 +2026,9 @@
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка таблицы: версия и workers в ``.cluster-list-value`` — в карточках (<920px) значение по центру правой колонки.
|
||||
*/
|
||||
async function loadClusters() {
|
||||
const tbody = document.querySelector("#tbl-clusters tbody");
|
||||
const msg = document.getElementById("list-msg");
|
||||
@@ -2049,12 +2066,12 @@
|
||||
"<td class=\"col-kubeconfig\">" +
|
||||
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
||||
"</td>" +
|
||||
"<td class=\"col-version\">" +
|
||||
"<td class=\"col-version\"><span class=\"cluster-list-value\">" +
|
||||
escapeHtml(String(ver)) +
|
||||
"</td>" +
|
||||
"<td class=\"col-workers\">" +
|
||||
"</span></td>" +
|
||||
"<td class=\"col-workers\"><span class=\"cluster-list-value\">" +
|
||||
escapeHtml(String(wn)) +
|
||||
"</td>" +
|
||||
"</span></td>" +
|
||||
"<td class=\"col-state\">" +
|
||||
stateCell +
|
||||
"</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).
|
||||
* @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: те же данные, что «Журнал» → задания
|
||||
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
|
||||
* Обычное обновление: только новые строки сверху + подрезка хвоста + правка ячеек; без полной пересборки DOM.
|
||||
*/
|
||||
async function loadJobs() {
|
||||
const tbody = document.querySelector("#tbl-jobs tbody");
|
||||
@@ -2146,65 +2485,33 @@
|
||||
try {
|
||||
const data = await api("/journal/recent?limit=30&offset=0");
|
||||
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 =
|
||||
"<td class=\"journal-cell-time\">" +
|
||||
escapeHtml(finished) +
|
||||
"</td>" +
|
||||
"<td><code class=\"journal-code\">" +
|
||||
escapeHtml(cluster) +
|
||||
"</code></td>" +
|
||||
"<td><code class=\"journal-code journal-cell-kind\">" +
|
||||
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);
|
||||
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 {
|
||||
const btn = tr.querySelector(".btn-journal-toggle");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Нет строк";
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
tbody.replaceChildren(frag);
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
if (!entries.length) {
|
||||
msg.textContent =
|
||||
@@ -2846,6 +3153,7 @@
|
||||
} finally {
|
||||
hideHomePageLoadingOverlay();
|
||||
hideClustersPageLoadingOverlay();
|
||||
hideCreatePageLoadingOverlay();
|
||||
}
|
||||
})();
|
||||
if (isCreatePage()) {
|
||||
|
||||
@@ -24,6 +24,25 @@
|
||||
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() {
|
||||
var q = new URLSearchParams(window.location.search || "");
|
||||
var p = (q.get("path") || "").trim();
|
||||
@@ -193,10 +212,36 @@
|
||||
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") {
|
||||
showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
|
||||
if (loadEl) loadEl.classList.add("hidden");
|
||||
applyDocumentationTabTitle("");
|
||||
return;
|
||||
}
|
||||
@@ -223,6 +268,7 @@
|
||||
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
|
||||
var pageInner = sectionizeFromNodes(temp);
|
||||
rootEl.appendChild(pageInner);
|
||||
markupWideMarkdownTables(rootEl);
|
||||
rootEl.removeAttribute("hidden");
|
||||
} catch (e2) {
|
||||
showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2)));
|
||||
@@ -242,18 +288,20 @@
|
||||
|
||||
var rootEl = document.getElementById("readme-doc-root");
|
||||
var errEl = document.getElementById("readme-error");
|
||||
var loadEl = document.getElementById("readme-loading");
|
||||
if (!rootEl || !loadEl) return;
|
||||
if (!rootEl) {
|
||||
hideDocumentationPageLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
hideErr(errEl);
|
||||
rootEl.setAttribute("hidden", "hidden");
|
||||
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
|
||||
loadEl.classList.remove("hidden");
|
||||
|
||||
updateBreadcrumbs(docPath);
|
||||
|
||||
var url = fetchUrlForPath(docPath);
|
||||
showDocumentationPageLoadingOverlay();
|
||||
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, {
|
||||
headers: { Accept: "text/markdown, text/plain, */*" },
|
||||
});
|
||||
@@ -267,13 +315,11 @@
|
||||
/* raw */
|
||||
}
|
||||
showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText));
|
||||
loadEl.classList.add("hidden");
|
||||
applyDocumentationTabTitle("");
|
||||
return;
|
||||
}
|
||||
|
||||
renderMarkdownToRoot(text, docPath, rootEl, errEl, loadEl);
|
||||
loadEl.classList.add("hidden");
|
||||
renderMarkdownToRoot(text, docPath, rootEl, errEl);
|
||||
|
||||
var newUrl = docUrlForPath(docPath);
|
||||
if (push) {
|
||||
@@ -283,8 +329,9 @@
|
||||
}
|
||||
} catch (e) {
|
||||
showErr(errEl, "Ошибка: " + (e.message || String(e)));
|
||||
loadEl.classList.add("hidden");
|
||||
applyDocumentationTabTitle("");
|
||||
} finally {
|
||||
hideDocumentationPageLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +356,10 @@
|
||||
|
||||
function run() {
|
||||
var rootEl = document.getElementById("readme-doc-root");
|
||||
if (!rootEl) return;
|
||||
if (!rootEl) {
|
||||
hideDocumentationPageLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", onPopState);
|
||||
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) {
|
||||
const s = (st || "").toLowerCase();
|
||||
if (s === "success") return "journal-status-pill journal-status-pill--ok";
|
||||
@@ -189,11 +220,12 @@
|
||||
return b;
|
||||
}
|
||||
|
||||
/* Короткий текст на кнопках — одна строка пагинации; смысл в aria-label */
|
||||
wrap.appendChild(
|
||||
mkBtn("« Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }),
|
||||
mkBtn("««", page <= 1, "Первая страница", function () { onSelectPage(1); }),
|
||||
);
|
||||
wrap.appendChild(
|
||||
mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
|
||||
mkBtn("‹", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
|
||||
);
|
||||
|
||||
const pagesEl = document.createElement("div");
|
||||
@@ -227,10 +259,10 @@
|
||||
wrap.appendChild(pagesEl);
|
||||
|
||||
wrap.appendChild(
|
||||
mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
|
||||
mkBtn("›", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
|
||||
);
|
||||
wrap.appendChild(
|
||||
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
|
||||
mkBtn("»»", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
|
||||
);
|
||||
|
||||
pagNav.appendChild(wrap);
|
||||
@@ -252,7 +284,7 @@
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "journal-data-row";
|
||||
const cluster = e.source_cluster || e.cluster_name || "—";
|
||||
const finished = formatUtc(
|
||||
const finishedHtml = formatUtcCellHtml(
|
||||
e.finished_at_utc || e.created_at_utc,
|
||||
);
|
||||
const kind = mode === "helm_addons" ? formatHelmJournalKind(e) : (e.kind || "");
|
||||
@@ -266,15 +298,29 @@
|
||||
const logText = lines.join("\n");
|
||||
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
|
||||
|
||||
/* Классы create-jobs-card-td* — карточки на узком экране (<620px), см. .journal-list-card в style.css */
|
||||
tr.innerHTML =
|
||||
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
|
||||
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
|
||||
"<td><code class=\"journal-code journal-cell-kind\">" + esc(kind) + "</code></td>" +
|
||||
"<td><span class=\"" + esc(statusClass(e.status)) + "\">" + esc(e.status || "") + "</span></td>" +
|
||||
"<td class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" +
|
||||
"<td class=\"journal-cell-actions\">" +
|
||||
"<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\">" +
|
||||
esc(cluster) +
|
||||
"</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=\"" +
|
||||
detailsId + "\">Показать лог</button>" +
|
||||
detailsId +
|
||||
"\">Показать лог</button>" +
|
||||
"</td>";
|
||||
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>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<script src="/static/js/theme.js" defer></script>
|
||||
<script src="/static/js/nav-mobile.js" defer></script>
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body
|
||||
@@ -52,8 +53,50 @@
|
||||
/>
|
||||
<span class="nav-title">{{ app_title }}</span>
|
||||
</a>
|
||||
{# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #}
|
||||
<nav class="nav-links top-nav-actions" aria-label="Разделы и оформление">
|
||||
{# При ширине < 920px — «гамбургер»; панель #top-nav-drawer справа (nav-mobile.js). #}
|
||||
<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). #}
|
||||
<div class="nav-links-scroll">
|
||||
<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 #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block body_extra_class %} dashboard-create-loading{% endblock %}
|
||||
|
||||
{% block body_attrs %}data-dashboard-mode="create"{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
@@ -14,10 +16,25 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<h1 class="page-title" id="create-hero-title">Создание кластера</h1>
|
||||
<p class="muted page-lead">
|
||||
Укажите имя, версию образа kindest/node и число worker-нод. Операция выполняется в фоне; ход смотрите в журнале ниже.
|
||||
Укажите имя, версию образа kindest/node и число worker-нод. Операция выполняется в фоне.<br>Ход смотрите в журнале ниже.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -36,9 +36,10 @@
|
||||
<div class="cta-hero-inner">
|
||||
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
|
||||
<p class="muted page-lead cta-hero-lead">
|
||||
Список кластеров, сводка по узлам и действия — на странице <a href="/clusters">Кластеры</a>. Данные там обновляются автоматически.
|
||||
Список кластеров, сводка по узлам и действия — на странице <a href="/clusters">Кластеры</a>.
|
||||
</p>
|
||||
<a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a>
|
||||
<p></p>
|
||||
<a href="/cluster-create" class="btn btn-cta-large">Перейти к созданию кластера</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
{# Документация: README и app/docs/*.md через API + marked + DOMPurify (vendor, без CDN).
|
||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||
{# Документация: README и app/docs/*.md через API + marked + DOMPurify (vendor, без CDN). #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{# Ширина колонки как у панели: .app-main max-width 72rem (секция «Ресурсы узлов (сводка)» и др.). #}
|
||||
|
||||
{% block page_title %}Документация{% endblock %}
|
||||
|
||||
{% block body_extra_class %} documentation-page-loading{% endblock %}
|
||||
|
||||
{% 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="Навигация по документации">
|
||||
<ol class="doc-breadcrumbs__list" id="doc-breadcrumbs-list"></ol>
|
||||
</nav>
|
||||
<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>
|
||||
<div id="readme-doc-root" class="readme-doc-page" hidden></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{# Автор: Сергей Антропов — https://devops.org.ru (в конце текста README и app/docs/api_routes.md). #}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/vendor/marked.min.js"></script>
|
||||
<script src="/static/js/vendor/purify.min.js"></script>
|
||||
|
||||
+2
-1
@@ -1,5 +1,4 @@
|
||||
# Переменные окружения Kind Clusters Dashboard (только имена; значения задаются в .env или в shell).
|
||||
# Автор: Сергей Антропов — https://devops.org.ru
|
||||
|
||||
KIND_VERSION
|
||||
KUBECTL_VERSION
|
||||
@@ -38,3 +37,5 @@ KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES
|
||||
KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES
|
||||
KIND_K8S_APP_TITLE
|
||||
KIND_K8S_UVICORN_RELOAD
|
||||
|
||||
# Автор: Сергей Антропов — https://devops.org.ru
|
||||
|
||||
Reference in New Issue
Block a user