diff --git a/README.md b/README.md index 9181f62..1712787 100644 --- a/README.md +++ b/README.md @@ -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` на ``, по умолчанию `/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` на ``, по умолчанию `/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) diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 241a48f..c1c9fe1 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -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` на `` (по умолчанию `/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) diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 49b2bdf..cdf598a 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -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 @@ "" + (c.has_local_kubeconfig ? "да" : "нет") + "" + - "" + + "" + escapeHtml(String(ver)) + - "" + - "" + + "" + + "" + escapeHtml(String(wn)) + - "" + + "" + "" + stateCell + "" + @@ -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 ( + '' + + '' + + escapeHtml(day) + + "" + + '' + + escapeHtml(clock) + + "" + + "" + ); + } catch (e) { + return escapeHtml(iso); + } + } + /** * Класс плашки статуса записи журнала (success / failed / cancelled). * @param {string} st @@ -2135,9 +2183,300 @@ ); } + /** + * Стабильный ключ строки журнала на странице создания (сверка DOM ↔ API при инкрементальном обновлении). + * @param {Record} 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} + */ + 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} 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} 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 = + "" + + finishedHtml + + "" + + "" + + escapeHtml(cluster) + + "" + + "" + + escapeHtml(kind) + + "" + + "" + + escapeHtml(e.status || "") + + "" + + "" + + escapeHtml(message || "—") + + "" + + "" + + "" + + ""; + + frag.appendChild(tr); + + if (logText) { + const trDetails = document.createElement("tr"); + trDetails.className = "journal-details-row hidden"; + trDetails.innerHTML = + "" + + "" + + ""; + 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[]} 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} 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 = + "" + + "" + + ""; + 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[]} 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 = - "" + - escapeHtml(finished) + - "" + - "" + - escapeHtml(cluster) + - "" + - "" + - escapeHtml(kind) + - "" + - "" + - escapeHtml(e.status || "") + - "" + - "" + - escapeHtml(message || "—") + - "" + - "" + - "" + - ""; - - frag.appendChild(tr); - - if (logText) { - const trDetails = document.createElement("tr"); - trDetails.className = "journal-details-row hidden"; - trDetails.innerHTML = - "" + - "" + - ""; - 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()) { diff --git a/app/static/js/documentation.js b/app/static/js/documentation.js index 6c2db81..f40a435 100644 --- a/app/static/js/documentation.js +++ b/app/static/js/documentation.js @@ -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); diff --git a/app/static/js/journal.js b/app/static/js/journal.js index e9b8038..ac069cf 100644 --- a/app/static/js/journal.js +++ b/app/static/js/journal.js @@ -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 ( + '' + + '' + + esc(day) + + "" + + '' + + esc(clock) + + "" + + "" + ); + } 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 = - "" + esc(finished) + "" + - "" + esc(cluster) + "" + - "" + esc(kind) + "" + - "" + esc(e.status || "") + "" + - "" + esc(msg || "—") + "" + - "" + + "" + + finishedHtml + + "" + + "" + + esc(cluster) + + "" + + "" + + esc(kind) + + "" + + "" + + esc(e.status || "") + + "" + + "" + + esc(msg || "—") + + "" + + "" + "" + + detailsId + + "\">Показать лог" + ""; tbody.appendChild(tr); diff --git a/app/static/js/nav-mobile.js b/app/static/js/nav-mobile.js new file mode 100644 index 0000000..1f821ce --- /dev/null +++ b/app/static/js/nav-mobile.js @@ -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(); + } +})(); diff --git a/app/static/style.css b/app/static/style.css index 9833996..e5ba018 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -13,6 +13,11 @@ html:not([data-theme="light"]) { --card: #1a2332; --accent: #3b82f6; --border: #2f3d52; + /* Текст на кнопках/ссылках с фоном --accent (тёмная и светлая тема) */ + --on-accent: #ffffff; + /* Текстовые ссылки: та же синяя гамма, что и акценты UI (без отдельного цвета :visited) */ + --link: #93c5fd; + --link-hover: #e0f2fe; } /* Светлая тема: основной текст и акценты в синей гамме (как тёмная), без «чистого» чёрного */ html[data-theme="light"] { @@ -23,6 +28,9 @@ html[data-theme="light"] { --card: #ffffff; --border: #b8c9e8; --accent: #3b82f6; + --on-accent: #ffffff; + --link: #2563eb; + --link-hover: #1e3a8a; } body { @@ -37,6 +45,39 @@ body.modal-open { overflow: hidden; } +/* Обычные ссылки : единый вид до и после перехода (перебиваем системный :visited). :where — низкая специфичность. */ +a:where([href]) { + color: var(--link); + text-decoration: underline; + /* Fallback без color-mix; поверх — смешивание с --accent */ + text-decoration-color: rgba(59, 130, 246, 0.4); + text-decoration-color: color-mix(in srgb, var(--accent) 42%, transparent); + text-underline-offset: 0.22em; + text-decoration-thickness: max(1px, 0.065em); + text-decoration-skip-ink: auto; + transition: color 0.15s ease, text-decoration-color 0.15s ease; +} +a:where([href]):visited { + color: var(--link); + text-decoration-color: rgba(59, 130, 246, 0.4); + text-decoration-color: color-mix(in srgb, var(--accent) 42%, transparent); +} +a:where([href]):hover { + color: var(--link-hover); + text-decoration-color: rgba(59, 130, 246, 0.65); + text-decoration-color: color-mix(in srgb, var(--accent) 72%, transparent); +} +a:where([href]):visited:hover { + color: var(--link-hover); + text-decoration-color: rgba(59, 130, 246, 0.65); + text-decoration-color: color-mix(in srgb, var(--accent) 72%, transparent); +} +a:where([href]):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; +} + /* Пропуск к основному содержимому (a11y) */ .skip-link { position: absolute; @@ -45,7 +86,7 @@ body.modal-open { z-index: 200; padding: 0.5rem 1rem; background: var(--accent); - color: #fff; + color: var(--on-accent); border-radius: 6px; font-weight: 600; } @@ -98,6 +139,13 @@ body.modal-open { font-weight: 700; font-size: 1.05rem; } +/* Тёмная тема: название в шапке — чисто белый (остальной текст остаётся --fg) */ +html:not([data-theme="light"]) .nav-brand-link .nav-title { + color: #ffffff; +} +html:not([data-theme="light"]) .nav-brand-link:hover .nav-title { + color: var(--accent); +} .nav-tag { font-size: 0.8rem; } @@ -352,6 +400,318 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) text-decoration: underline; } +/* Кнопка «гамбургер» (видна при ширине < 920px; синхронно с nav-mobile.js) */ +.nav-menu-toggle { + display: none; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 2.85rem; + height: 2.85rem; + margin: 0; + padding: 0; + border: 1px solid rgba(59, 130, 246, 0.35); + border-radius: 14px; + background: linear-gradient(145deg, rgba(59, 130, 246, 0.22), rgba(15, 23, 42, 0.35)); + color: var(--fg); + cursor: pointer; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); + transition: + background 0.22s ease, + border-color 0.22s ease, + color 0.22s ease, + box-shadow 0.22s ease, + transform 0.18s ease; +} +html[data-theme="light"] .nav-menu-toggle { + background: linear-gradient(145deg, rgba(59, 130, 246, 0.2), rgba(255, 255, 255, 0.95)); + border-color: rgba(59, 130, 246, 0.4); + color: var(--accent); + box-shadow: 0 2px 14px rgba(59, 130, 246, 0.18); +} +.nav-menu-toggle:hover { + border-color: var(--accent); + background: linear-gradient(145deg, rgba(59, 130, 246, 0.32), rgba(59, 130, 246, 0.12)); + color: var(--accent); + box-shadow: 0 4px 18px rgba(59, 130, 246, 0.28); +} +html[data-theme="light"] .nav-menu-toggle:hover { + color: #1d4ed8; +} +.nav-menu-toggle:active { + transform: scale(0.96); +} +.nav-menu-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} +.nav-menu-toggle__bars { + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 1.25rem; +} +.nav-menu-toggle__bar { + display: block; + height: 2.5px; + border-radius: 2px; + background: currentColor; + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease; +} +.nav-menu-toggle--open .nav-menu-toggle__bar:nth-child(1) { + transform: translateY(7.5px) rotate(45deg); +} +.nav-menu-toggle--open .nav-menu-toggle__bar:nth-child(2) { + opacity: 0; + transform: scaleX(0); +} +.nav-menu-toggle--open .nav-menu-toggle__bar:nth-child(3) { + transform: translateY(-7.5px) rotate(-45deg); +} +@media (prefers-reduced-motion: reduce) { + .nav-menu-toggle__bar { + transition: none; + } + .nav-menu-toggle:active { + transform: none; + } +} + +/* Подложка под выезжающее меню */ +.nav-menu-backdrop { + position: fixed; + inset: 0; + z-index: 270; + margin: 0; + padding: 0; + border: none; + background: rgba(8, 12, 22, 0.58); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; + transition: opacity 0.28s ease, backdrop-filter 0.28s ease; +} +html[data-theme="light"] .nav-menu-backdrop { + background: rgba(15, 23, 42, 0.32); +} + +/* Шапка боковой панели: только на узких экранах (см. медиазапрос) */ +.nav-drawer-header { + display: none; +} +.nav-drawer-title { + font-size: 1.08rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--fg); +} +.nav-drawer-close { + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + margin: 0; + padding: 0; + border: none; + border-radius: 12px; + background: rgba(59, 130, 246, 0.12); + color: var(--muted); + cursor: pointer; + transition: background 0.18s ease, color 0.18s ease, transform 0.15s ease; +} +.nav-drawer-close:hover { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} +html[data-theme="light"] .nav-drawer-close:hover { + color: #b91c1c; +} +.nav-drawer-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.nav-drawer-close:active { + transform: scale(0.94); +} + +@media (max-width: 919px) { + .top-nav-inner { + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 0.75rem; + padding-left: 0.85rem; + padding-right: 0.85rem; + } + .nav-brand-link { + min-width: 0; + } + .nav-title { + font-size: clamp(0.95rem, 4vw, 1.05rem); + } + .nav-menu-toggle { + display: inline-flex; + } + .nav-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 0.75rem; + margin: 0 0 0.35rem; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(59, 130, 246, 0.18); + } + html[data-theme="light"] .nav-drawer-header { + border-bottom-color: rgba(59, 130, 246, 0.22); + } + .nav-drawer-close { + display: inline-flex; + flex-shrink: 0; + } + /* Панель: выезд справа, скругление, градиент; API-меню без обрезки */ + .top-nav-actions { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 280; + width: min(21.5rem, min(92vw, 100% - 1.25rem)); + max-width: 100%; + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + justify-content: flex-start; + gap: 0.65rem; + margin: 0; + padding: max(1rem, env(safe-area-inset-top, 0px)) 1.15rem max(1.25rem, env(safe-area-inset-bottom, 0px)); + padding-left: max(1.15rem, env(safe-area-inset-left, 0px)); + padding-right: max(1.15rem, env(safe-area-inset-right, 0px)); + overflow-x: visible; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + background: var(--card); + background: linear-gradient( + 165deg, + var(--card) 0%, + color-mix(in srgb, var(--card) 88%, var(--accent) 6%) 55%, + color-mix(in srgb, var(--card) 94%, var(--bg)) 100% + ); + border-left: 1px solid var(--border); + border-left: 1px solid color-mix(in srgb, var(--border) 75%, var(--accent) 25%); + border-radius: 1.35rem 0 0 1.35rem; + box-shadow: + -12px 0 40px rgba(0, 0, 0, 0.38), + -2px 0 0 rgba(59, 130, 246, 0.12) inset; + transform: translateX(108%); + visibility: hidden; + pointer-events: none; + transition: transform 0.34s cubic-bezier(0.22, 1, 0.36, 1); + } + html[data-theme="light"] .top-nav-actions { + background: #ffffff; + background: linear-gradient(165deg, #ffffff 0%, #f0f6ff 48%, #eef2fb 100%); + border-left-color: rgba(59, 130, 246, 0.28); + box-shadow: + -10px 0 36px rgba(15, 23, 42, 0.1), + -1px 0 0 rgba(59, 130, 246, 0.15) inset; + } + .top-nav-actions--open { + transform: translateX(0); + visibility: visible; + pointer-events: auto; + } + @media (prefers-reduced-motion: reduce) { + .top-nav-actions { + transition: transform 0.18s ease; + } + } + .top-nav-actions .nav-links-scroll { + flex-direction: column; + align-items: stretch; + flex-wrap: nowrap; + overflow-x: visible; + min-width: 0; + width: 100%; + padding: 0.15rem 0 0.35rem; + gap: 0.45rem; + } + .top-nav-actions .nav-link.nav-pill { + width: 100%; + justify-content: flex-start; + box-sizing: border-box; + padding: 0.72rem 1.05rem; + font-size: 0.94rem; + border-radius: 14px; + border: 1px solid rgba(59, 130, 246, 0.2); + background: rgba(0, 0, 0, 0.2); + } + html[data-theme="light"] .top-nav-actions .nav-link.nav-pill { + background: rgba(255, 255, 255, 0.85); + border-color: rgba(59, 130, 246, 0.22); + } + .top-nav-actions .nav-link.nav-pill:hover { + transform: none; + border-color: var(--accent); + background: rgba(59, 130, 246, 0.14); + } + html[data-theme="light"] .top-nav-actions .nav-link.nav-pill:hover { + background: rgba(59, 130, 246, 0.1); + } + .top-nav-actions .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) { + border-color: rgba(59, 130, 246, 0.65); + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.25); + } + .top-nav-actions .nav-dropdown { + width: 100%; + flex-direction: column; + align-items: stretch; + margin-top: 0.25rem; + padding-top: 0.65rem; + border-top: 1px dashed rgba(59, 130, 246, 0.22); + } + .top-nav-actions .nav-dropdown__trigger { + width: 100%; + justify-content: space-between; + padding: 0.72rem 1.05rem; + font-size: 0.94rem; + border-radius: 14px; + } + .top-nav-actions .nav-dropdown__menu { + position: static; + right: auto; + left: auto; + width: 100%; + min-width: 0; + margin-top: 0.45rem; + padding: 0.25rem 0; + border-radius: 12px; + border: 1px solid rgba(59, 130, 246, 0.15); + background: color-mix(in srgb, var(--card) 92%, transparent); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22); + } + html[data-theme="light"] .top-nav-actions .nav-dropdown__menu { + background: rgba(255, 255, 255, 0.97); + box-shadow: 0 8px 28px rgba(15, 23, 42, 0.08); + } + .top-nav-actions .theme-toggle--nav-end { + transform: none; + width: 100%; + min-height: 2.85rem; + margin-top: auto; + padding-top: 0.85rem; + padding-bottom: 0.85rem; + justify-content: center; + border-radius: 14px; + font-size: 0.88rem; + } +} + +body.nav-mobile-open { + overflow: hidden; +} + .app-main { max-width: 72rem; margin: 0 auto; @@ -359,6 +719,13 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) outline: none; } +/* Узкие экраны: меньше боковые поля, комфортный тап */ +@media (max-width: 767px) { + .app-main { + padding: 1rem 0.75rem; + } +} + .app-footer { max-width: 72rem; margin: 0 auto; @@ -367,6 +734,23 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) font-size: 0.85rem; } +@media (max-width: 767px) { + .app-footer { + padding-left: 0.75rem; + padding-right: 0.75rem; + padding-bottom: max(1.5rem, env(safe-area-inset-bottom, 0px)); + } +} + +/* Широкие мониторы: чуть шире контентная колонка */ +@media (min-width: 1320px) { + .app-main, + .top-nav-inner, + .app-footer { + max-width: min(84rem, calc(100% - 2.5rem)); + } +} + .footer-inner { text-align: center; } @@ -397,7 +781,7 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) } } -/* Левая карточка — переход к форме создания */ +/* Левая карточка — переход к созданию кластера */ .cta-hero-card { margin-bottom: 0; display: flex; @@ -523,7 +907,7 @@ html[data-theme="light"] .stats-env-card--metric { background: rgba(59, 130, 246, 0.07); } -/* Метрики панели: по 3 карточки в ряд */ +/* Метрики панели: по 3 карточки в ряд (карточка «Статистика» на главной с CTA «Создать кластер») */ .stats-metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); @@ -532,9 +916,21 @@ html[data-theme="light"] .stats-env-card--metric { align-content: start; } -@media (max-width: 520px) { +/* Уже чем 520px — две колонки мини-карточек, а не одна длинная колонка */ +@media (max-width: 519px) { .stats-metrics-grid { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem 0.45rem; + } + .stats-tile { + min-height: 3.85rem; + padding: 0.58rem 0.35rem; + } + .stats-tile__value { + font-size: 1.12rem; + } + .stats-tile__label { + font-size: 0.62rem; } } @@ -599,26 +995,45 @@ html[data-theme="light"] .stats-node-res { background: rgba(59, 130, 246, 0.07); } +/* Главная: «Перейти к созданию кластера» — размер шрифта, отступы и скругление плавно от ширины viewport */ .btn-cta-large { display: inline-flex; align-items: center; justify-content: center; - min-width: min(22rem, 100%); + box-sizing: border-box; + min-width: 0; + max-width: min(22rem, 100%); + width: fit-content; margin-top: 0; - padding: 0.95rem 2.25rem; - font-size: 1.15rem; + padding: clamp(0.48rem, 0.34rem + 0.75vw, 0.95rem) clamp(0.72rem, 0.38rem + 2.35vw, 2.25rem); + font-size: clamp(0.78rem, 0.58rem + 0.95vw, 1.15rem); font-weight: 650; - border-radius: 12px; + line-height: 1.2; + border-radius: clamp(8px, 0.45rem + 0.55vw, 12px); text-decoration: none; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + box-shadow: 0 clamp(2px, 0.2vw + 1px, 4px) clamp(7px, 0.35rem + 0.9vw, 14px) rgba(0, 0, 0, 0.18); border: 1px solid transparent; background: var(--accent); - color: #fff; + color: var(--on-accent); cursor: pointer; } a.btn-cta-large:hover { filter: brightness(1.08); } +/* Ссылка-кнопка: глобальные стили a[href] не перекрашивают текст и :visited */ +a.btn-cta-large, +a.clusters-page-create-btn { + color: var(--on-accent); + text-decoration: none; +} +a.btn-cta-large:visited, +a.btn-cta-large:hover, +a.btn-cta-large:visited:hover, +a.clusters-page-create-btn:visited, +a.clusters-page-create-btn:hover, +a.clusters-page-create-btn:visited:hover { + color: var(--on-accent); +} /* Сводка-денаты по узлам */ .aggregate-donuts-card { @@ -638,8 +1053,18 @@ a.btn-cta-large:hover { overflow-x: auto; padding-bottom: 0.35rem; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; scrollbar-width: thin; } +/* Раньше переносились только от 480px; на главной/кластерах сводка «Ресурсы узлов» — с <710px сразу в несколько строк */ +@media (max-width: 709px) { + .donut-grid { + flex-wrap: wrap; + justify-content: center; + overflow-x: visible; + row-gap: 1.1rem; + } +} .donut-item { flex: 0 0 auto; display: flex; @@ -919,11 +1344,11 @@ html[data-theme="light"] .cluster-node-res-card__bar-track { background: rgba(239, 68, 68, 0.12); } -/* Страница «Кластеры»: заголовок слева, кнопка «Создать кластер» справа */ +/* Страница «Кластеры»: заголовок слева, кнопка «Создать кластер» справа; по вертикали — по центру блока */ .clusters-page-hero-row { display: flex; flex-wrap: wrap; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 1rem 1.5rem; } @@ -938,7 +1363,42 @@ html[data-theme="light"] .cluster-node-res-card__bar-track { flex: 0 0 auto; display: flex; align-items: center; - padding-top: 0.15rem; + align-self: center; + padding-top: 0; +} +@media (max-width: 520px) { + /* Остаёмся в ряд при возможности: кнопка по центру по высоте относительно заголовка, не «внизу» колонки */ + .clusters-page-hero-row { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + } + .clusters-page-hero-text { + flex: 1 1 min(100%, 16rem); + } + .clusters-page-hero-actions { + flex: 0 0 auto; + margin-left: auto; + } + .clusters-page-create-btn { + width: auto; + max-width: 100%; + justify-content: center; + box-sizing: border-box; + } +} +@media (max-width: 380px) { + .clusters-page-hero-row { + flex-direction: column; + align-items: stretch; + } + .clusters-page-hero-actions { + margin-left: 0; + } + .clusters-page-create-btn { + width: 100%; + } } .clusters-page-create-btn { display: inline-flex; @@ -951,7 +1411,7 @@ html[data-theme="light"] .cluster-node-res-card__bar-track { text-decoration: none; border: 1px solid transparent; background: var(--accent); - color: #fff; + color: var(--on-accent); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.14); white-space: nowrap; } @@ -1239,6 +1699,12 @@ summary:focus-visible { border-radius: 10px; padding: 1rem 1.1rem; } +@media (max-width: 767px) { + .card { + padding: 0.9rem 0.75rem; + border-radius: 9px; + } +} .card h2 { margin-top: 0; @@ -1280,7 +1746,7 @@ button { border-radius: 6px; border: 1px solid var(--border); background: var(--accent); - color: #fff; + color: var(--on-accent); cursor: pointer; } @@ -1288,6 +1754,11 @@ button:hover { filter: brightness(1.08); } +/* Повышенная специфичность: на синем фоне всегда белый текст (наследование color от карточки в светлой теме и т.п.) */ +button:not(.btn-secondary):not(.btn-danger):not(.icon-btn):not(.theme-toggle):not(.nav-menu-toggle):not(.nav-dropdown__trigger):not(.journal-pag-btn):not(.journal-pag-num) { + color: var(--on-accent); +} + .row { display: flex; gap: 0.5rem; @@ -1315,8 +1786,21 @@ html[data-theme="light"] .mono { background: rgba(59, 130, 246, 0.1); } +/* Горизонтальная прокрутка широких таблиц; на touch — плавный скролл */ .table-wrap { overflow-x: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + margin: 0 -0.15rem; + padding: 0 0.15rem 0.25rem; + border-radius: 10px; + scrollbar-width: thin; +} +@media (max-width: 767px) { + .table-wrap { + margin: 0 -0.35rem; + padding: 0 0.35rem 0.35rem; + } } table { @@ -1324,6 +1808,24 @@ table { border-collapse: collapse; font-size: 0.9rem; } +@media (max-width: 767px) { + table { + font-size: 0.82rem; + } + th, + td { + padding: 0.4rem 0.28rem; + } +} + +/* Липкий заголовок внутри прокручиваемой области */ +.table-wrap thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--card); + box-shadow: 0 1px 0 var(--border); +} th .th-hint { font-weight: 500; @@ -1411,23 +1913,45 @@ button.btn-small { font-size: 0.8rem; } -/* Таблица кластеров: колонка «Имя» 40 % ширины таблицы (без width:1%, из‑за него в UI казалось «1 %») */ +/* + * Таблица кластеров: width 100% карточки; узкие колонки — по содержимому (width: 1% + nowrap), + * свободная ширина уходит в «Имя». Текст имени до 36rem с ellipsis. «Версия» ≤ 30rem с переносом. + * Прокрутка при переполнении — в .table-wrap. + * Автор: Сергей Антропов — https://devops.org.ru + */ #tbl-clusters { - table-layout: fixed; - width: 90%; + table-layout: auto; + width: 100%; + box-sizing: border-box; } #tbl-clusters th.col-cluster-name, #tbl-clusters td.col-cluster-name { - width: 40%; + /* Остаток ширины таблицы после «1%»-колонок */ + width: 100%; min-width: 0; + max-width: none; text-align: left; vertical-align: middle; - overflow-wrap: break-word; - word-break: break-word; + overflow: hidden; + box-sizing: border-box; } -/* Остальные колонки — компактно; содержимое по центру */ +/* Имя в одну строку; обрезка длинных имён — до 36rem, в узкой колонке — по ширине ячейки */ +#tbl-clusters td.col-cluster-name .cluster-name-link { + display: inline-block; + max-width: min(36rem, 100%); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + box-sizing: border-box; +} +#tbl-clusters td.col-cluster-name .cluster-name { + white-space: nowrap; +} + +/* Короткие колонки: 1% — не забирают лишнюю ширину у «Имя», фактически по min-content + nowrap */ #tbl-clusters th.col-kind, #tbl-clusters td.col-kind, #tbl-clusters th.col-kubeconfig, @@ -1436,6 +1960,8 @@ button.btn-small { #tbl-clusters td.col-workers, #tbl-clusters th.col-state, #tbl-clusters td.col-state { + width: 1%; + min-width: 0; padding-left: 0.5rem; padding-right: 0.5rem; text-align: center; @@ -1443,12 +1969,20 @@ button.btn-small { white-space: nowrap; } +/* + * Версия: по содержимому, но max-width меньше, чем у «Имя» (36rem), иначе один docker-тег + * сделал бы эту колонку шире имени кластера. + */ #tbl-clusters th.col-version, #tbl-clusters td.col-version { + width: 1%; + min-width: 0; + max-width: 30rem; padding-left: 0.5rem; padding-right: 0.5rem; text-align: center; vertical-align: middle; + box-sizing: border-box; } #tbl-clusters th.col-version { white-space: nowrap; @@ -1456,18 +1990,582 @@ button.btn-small { #tbl-clusters td.col-version { white-space: normal; word-break: break-word; + overflow-wrap: anywhere; } -/* Колонка «Действия»: доля от оставшихся 50 %, иконки в ряд */ +/* Действия: как узкая колонка по сетке 4×2 */ #tbl-clusters th.col-actions, #tbl-clusters td.actions { - padding: 0.3rem 0.25rem; + width: 1%; + min-width: 0; + max-width: none; + padding: 0.5rem 0.3rem; text-align: center; vertical-align: middle; + box-sizing: border-box; } #tbl-clusters td.actions { - white-space: nowrap; + white-space: normal; + overflow: visible; } +/* Ширина > 920px: все кнопки в одну строку (6 или 7 шт., appendClusterActionButtons) */ +#tbl-clusters .actions-toolbar { + display: inline-flex; + flex-wrap: nowrap; + flex-direction: row; + gap: 0.38rem; + justify-content: center; + align-items: center; + width: max-content; + max-width: 100%; + margin: 0 auto; + box-sizing: border-box; +} +#tbl-clusters .actions-toolbar .icon-tooltip-host { + display: flex; + justify-content: center; + align-items: center; +} + +/* ≤920px: компактная сетка 4×2 в колонке «Действия» */ +@media (max-width: 920px) { + #tbl-clusters .actions-toolbar { + display: grid; + grid-template-columns: repeat(4, 2rem); + grid-auto-rows: minmax(1.95rem, auto); + } + /* Неполная вторая строка — по центру относительно четырёх столбцов */ + #tbl-clusters .actions-toolbar:has(.icon-tooltip-host:nth-child(7):last-child) .icon-tooltip-host:nth-child(5) { + grid-column: 2; + } + #tbl-clusters .actions-toolbar:has(.icon-tooltip-host:nth-child(6):last-child) .icon-tooltip-host:nth-child(5) { + grid-column: 2; + } + #tbl-clusters .actions-toolbar:has(.icon-tooltip-host:nth-child(5):last-child) .icon-tooltip-host:nth-child(5) { + grid-column: 2 / span 2; + justify-self: center; + } +} + +/* + * Ширина < 920px: список кластеров — те же карточки (шапка с именем, поля, действия в одну строку). + * При ≥920px — таблица с горизонтальным скроллом в .table-wrap. + * Автор: Сергей Антропов — https://devops.org.ru + */ +@media (max-width: 919px) { + .clusters-card { + padding-left: 0.5rem; + padding-right: 0.5rem; + box-sizing: border-box; + } + .clusters-card > h2 { + font-size: 1.08rem; + font-weight: 650; + margin: 0 0 0.85rem; + padding: 0 0.15rem; + letter-spacing: -0.02em; + } + .clusters-card .table-wrap { + overflow-x: visible; + margin: 0; + padding: 0; + border-radius: 0; + } + #tbl-clusters { + min-width: 0; + width: 100%; + display: block; + border-collapse: separate; + border-spacing: 0; + } + #tbl-clusters thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + #tbl-clusters tbody { + display: block; + } + /* Карточка кластера: градиент, тень, скругление; внутренние зоны — через отступы у ячеек */ + #tbl-clusters tbody tr { + display: block; + margin-bottom: 1.05rem; + padding: 0; + border: 1px solid var(--border); + border-radius: 16px; + box-sizing: border-box; + overflow: hidden; + background: linear-gradient( + 160deg, + rgba(59, 130, 246, 0.11) 0%, + rgba(15, 23, 42, 0.55) 42%, + rgba(10, 15, 22, 0.72) 100% + ); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.05) inset, + 0 8px 28px rgba(0, 0, 0, 0.28); + } + html[data-theme="light"] #tbl-clusters tbody tr { + background: linear-gradient( + 165deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(241, 245, 249, 0.92) 48%, + rgba(226, 232, 240, 0.88) 100% + ); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.9) inset, + 0 6px 22px rgba(15, 23, 42, 0.09); + } + /* Шапка карточки — имя кластера (подпись и ссылка по центру) */ + #tbl-clusters tbody td.col-cluster-name { + display: block; + width: 100% !important; + max-width: none !important; + margin: 0; + padding: 1rem 1rem 0.9rem; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.14); + box-sizing: border-box; + text-align: center; + } + html[data-theme="light"] #tbl-clusters tbody td.col-cluster-name { + border-bottom-color: rgba(15, 23, 42, 0.08); + background: rgba(59, 130, 246, 0.07); + } + #tbl-clusters tbody td.col-cluster-name::before { + content: "Кластер"; + display: block; + font-size: 0.65rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 0.45rem; + padding-top: 0; + opacity: 0.95; + } + #tbl-clusters tbody td.col-cluster-name .cluster-name-link { + display: inline; + max-width: none; + white-space: normal; + overflow: visible; + text-overflow: clip; + font-size: 1.12rem; + font-weight: 700; + line-height: 1.35; + word-break: break-word; + vertical-align: baseline; + } + #tbl-clusters tbody td.col-cluster-name .cluster-name { + white-space: normal; + word-break: break-word; + font-size: inherit; + } + /* Две колонки: слева подпись к левому краю, справа значение по центру */ + #tbl-clusters tbody td:not(.col-cluster-name):not(.actions) { + display: grid; + grid-template-columns: minmax(5.75rem, 40%) minmax(0, 1fr); + gap: 0.45rem 0.65rem; + align-items: center; + justify-items: start; + width: 100%; + max-width: none; + box-sizing: border-box; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.55rem 1rem; + text-align: center; + vertical-align: middle; + white-space: normal; + overflow: visible; + } + #tbl-clusters tbody td.col-version { + align-items: start; + font-size: 0.78rem; + line-height: 1.45; + color: var(--fg); + word-break: break-word; + overflow-wrap: anywhere; + } + html[data-theme="light"] #tbl-clusters tbody td:not(.col-cluster-name):not(.actions) { + border-bottom-color: rgba(15, 23, 42, 0.07); + } + #tbl-clusters tbody td:not(.col-cluster-name):not(.actions)::before { + font-size: 0.72rem; + font-weight: 600; + text-transform: none; + letter-spacing: 0.02em; + color: var(--muted); + line-height: 1.3; + padding-top: 0; + justify-self: start; + text-align: left; + width: auto; + } + #tbl-clusters tbody td.col-kind::before { + content: "kind"; + } + #tbl-clusters tbody td.col-kubeconfig::before { + content: "kubeconfig"; + } + #tbl-clusters tbody td.col-version::before { + content: "Версия"; + padding-top: 0.2rem; + } + #tbl-clusters tbody td.col-workers::before { + content: "Workers"; + } + #tbl-clusters tbody td.col-state::before { + content: "Состояние"; + } + #tbl-clusters tbody td:not(.col-cluster-name):not(.actions) > .badge, + #tbl-clusters tbody td:not(.col-cluster-name):not(.actions) > .muted { + justify-self: center; + width: fit-content; + max-width: 100%; + box-sizing: border-box; + text-align: center; + } + /* Версия и workers: обёртка в разметке (dashboard.js) — явно по центру правой колонки */ + #tbl-clusters tbody td.col-version .cluster-list-value, + #tbl-clusters tbody td.col-workers .cluster-list-value { + display: block; + justify-self: center; + width: 100%; + max-width: 100%; + box-sizing: border-box; + text-align: center; + } + #tbl-clusters tbody td:last-child:not(.actions) { + border-bottom: none; + } + /* Страница кластера (<920): две колонки — подпись слева, значение по центру */ + .cluster-meta-tile { + display: grid; + grid-template-columns: minmax(5.5rem, 42%) minmax(0, 1fr); + gap: 0.35rem 0.55rem; + align-items: center; + text-align: center; + } + .cluster-meta-tile__label { + justify-self: start; + text-align: left; + width: auto; + } + .cluster-meta-tile__value { + justify-self: stretch; + justify-content: center; + text-align: center; + width: 100%; + } + .cluster-meta-tile__value .badge { + width: fit-content; + max-width: 100%; + margin-left: auto; + margin-right: auto; + box-sizing: border-box; + } + .cluster-meta-tile__value .cluster-meta-version-tag, + .cluster-meta-tile__value .cluster-meta-workers { + display: block; + margin-left: auto; + margin-right: auto; + width: fit-content; + max-width: 100%; + text-align: center; + } + /* Панель действий — отдельная «подошва» карточки */ + #tbl-clusters tbody td.actions { + display: block; + grid-template-columns: none; + max-width: none !important; + width: 100%; + margin: 0; + padding: 0.75rem 0.85rem 0.95rem; + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(0, 0, 0, 0.2); + text-align: center; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: thin; + box-sizing: border-box; + } + html[data-theme="light"] #tbl-clusters tbody td.actions { + border-top-color: rgba(15, 23, 42, 0.08); + background: rgba(15, 23, 42, 0.04); + } + #tbl-clusters tbody td.actions::before { + content: "Действия"; + display: block; + font-size: 0.65rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 0.55rem; + opacity: 0.95; + } + /* Все иконки действий в одну строку (6–7 шт.); узкий экран — горизонтальный скролл у ячейки */ + #tbl-clusters tbody td.actions .actions-toolbar { + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 0.42rem; + justify-content: center; + align-items: center; + width: max-content; + max-width: none; + margin: 0 auto; + padding: 0.4rem 0.5rem; + border-radius: 12px; + background: rgba(0, 0, 0, 0.12); + border: 1px solid rgba(255, 255, 255, 0.06); + box-sizing: border-box; + } + html[data-theme="light"] #tbl-clusters tbody td.actions .actions-toolbar { + background: rgba(255, 255, 255, 0.55); + border-color: rgba(59, 130, 246, 0.15); + } + #tbl-clusters tbody td.actions .actions-toolbar .icon-tooltip-host { + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + } + + /* + * Страница создания кластера: «Последние задания» (#tbl-jobs) — карточки как у списка кластеров. + * Автор: Сергей Антропов — https://devops.org.ru + */ + .jobs-card { + padding-left: 0.5rem; + padding-right: 0.5rem; + box-sizing: border-box; + } + .jobs-card .table-wrap { + overflow-x: visible; + margin: 0; + padding: 0; + border-radius: 0; + } + .jobs-card .jobs-card-toolbar { + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: center; + } + .jobs-card #tbl-jobs { + min-width: 0 !important; + width: 100%; + display: block; + border-collapse: separate; + border-spacing: 0; + } + .jobs-card #tbl-jobs thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .jobs-card #tbl-jobs tbody { + display: block; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row { + display: block; + padding: 0; + border: 1px solid var(--border); + border-radius: 16px; + box-sizing: border-box; + overflow: hidden; + margin-bottom: 1.05rem; + background: linear-gradient( + 160deg, + rgba(59, 130, 246, 0.11) 0%, + rgba(15, 23, 42, 0.55) 42%, + rgba(10, 15, 22, 0.72) 100% + ); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.05) inset, + 0 8px 28px rgba(0, 0, 0, 0.28); + } + html[data-theme="light"] .jobs-card #tbl-jobs tbody tr.journal-data-row { + background: linear-gradient( + 165deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(241, 245, 249, 0.92) 48%, + rgba(226, 232, 240, 0.88) 100% + ); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.9) inset, + 0 6px 22px rgba(15, 23, 42, 0.09); + } + .jobs-card #tbl-jobs tbody tr.journal-data-row:has(+ tr.journal-details-row:not(.hidden)) { + border-bottom: none; + border-radius: 16px 16px 0 0; + margin-bottom: 0; + } + .jobs-card #tbl-jobs tbody tr.journal-details-row:not(.hidden) { + display: block; + margin-bottom: 1.05rem; + padding: 0; + border: 1px solid var(--border); + border-top: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 0 0 16px 16px; + box-sizing: border-box; + overflow: hidden; + background: rgba(0, 0, 0, 0.2); + } + html[data-theme="light"] .jobs-card #tbl-jobs tbody tr.journal-details-row:not(.hidden) { + border-top-color: rgba(15, 23, 42, 0.08); + background: rgba(15, 23, 42, 0.04); + } + .jobs-card #tbl-jobs tbody tr.journal-details-row:not(.hidden) td { + display: block; + width: 100% !important; + border: none; + padding: 0.65rem 0.85rem 0.85rem; + box-sizing: border-box; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td:not(.create-jobs-card-td--log) { + display: grid; + grid-template-columns: minmax(6.5rem, 40%) minmax(0, 1fr); + gap: 0.45rem 0.65rem; + align-items: center; + justify-items: start; + width: 100%; + max-width: none; + box-sizing: border-box; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.55rem 1rem; + text-align: center; + white-space: normal; + overflow: visible; + } + html[data-theme="light"] + .jobs-card + #tbl-jobs + tbody + tr.journal-data-row + td.create-jobs-card-td:not(.create-jobs-card-td--log) { + border-bottom-color: rgba(15, 23, 42, 0.07); + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--time { + align-items: start; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td:not(.create-jobs-card-td--log)::before { + font-size: 0.72rem; + font-weight: 600; + text-transform: none; + letter-spacing: 0.02em; + color: var(--muted); + line-height: 1.3; + justify-self: start; + text-align: left; + width: auto; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--time::before { + content: "Завершено (UTC)"; + padding-top: 0.15rem; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--cluster::before { + content: "Кластер"; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--kind::before { + content: "Тип задания"; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--status::before { + content: "Статус"; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--msg::before { + content: "Сообщение"; + align-self: start; + padding-top: 0.2rem; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--msg { + align-items: start; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--cluster .journal-code, + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--kind .journal-code { + display: inline-block; + justify-self: center; + width: fit-content; + max-width: 100%; + text-align: center; + word-break: break-word; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--status > span { + display: inline-block; + justify-self: center; + width: fit-content; + max-width: 100%; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--time > .journal-time-stack { + display: flex; + flex-direction: column; + align-items: center; + justify-self: center; + width: 100%; + text-align: center; + gap: 0.12rem; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--msg .create-jobs-msg-value { + display: block; + justify-self: center; + width: 100%; + max-width: 100%; + text-align: center; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.4; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--log { + display: block; + width: 100% !important; + max-width: none !important; + margin: 0; + padding: 0.75rem 0.85rem 0.95rem; + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(0, 0, 0, 0.2); + text-align: center; + white-space: normal; + box-sizing: border-box; + } + html[data-theme="light"] .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--log { + border-top-color: rgba(15, 23, 42, 0.08); + background: rgba(15, 23, 42, 0.04); + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--log::before { + content: "Лог"; + display: block; + font-size: 0.65rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 0.55rem; + opacity: 0.95; + } + .jobs-card #tbl-jobs tbody tr.journal-data-row td.create-jobs-card-td--log .btn-journal-toggle { + margin-left: auto; + margin-right: auto; + } +} + .actions-toolbar { display: inline-flex; flex-wrap: nowrap; @@ -1782,10 +2880,6 @@ button.btn-danger:hover { margin: 0; box-sizing: border-box; } -.readme-doc-loading { - margin: 0 0 0.75rem; - font-size: 0.9rem; -} .readme-doc-page { width: 100%; } @@ -1818,7 +2912,7 @@ button.btn-danger:hover { font-weight: 650; } .readme-section-card.markdown-body h1 { - font-size: 1.45rem; + font-size: clamp(1.15rem, 4.5vw, 1.45rem); border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; } @@ -1957,11 +3051,11 @@ body.cluster-detail-page .app-main > .card:last-child { margin-bottom: 0; } -/* Шапка страницы кластера: слева заголовок и кнопки, справа мета столбиком */ +/* Шапка страницы кластера: слева заголовок и кнопки, справа мета; по вертикали блоки выровнены по центру */ .cluster-detail-hero-layout { display: flex; flex-wrap: wrap; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 1rem 1.75rem; } @@ -1971,7 +3065,7 @@ body.cluster-detail-page .app-main > .card:last-child { } .cluster-detail-hero-meta { flex: 0 0 auto; - align-self: flex-start; + align-self: center; } .cluster-detail-hero-card .page-title { margin-bottom: 0.5rem; @@ -2296,7 +3390,10 @@ html[data-theme="light"] .cluster-detail-addon-mini__ns { align-items: stretch; } .cluster-detail-hero-meta { - align-self: stretch; + align-self: center; + width: 100%; + max-width: 28rem; + margin-inline: auto; } .cluster-detail-meta { max-width: none; @@ -2428,19 +3525,27 @@ html[data-theme="light"] .cluster-kube-hint.cluster-kube-hint--info { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; border-radius: 8px; border: 1px solid var(--border); background: rgba(0, 0, 0, 0.12); + scrollbar-width: thin; } html[data-theme="light"] .cluster-k8s-table-scroll { background: rgba(15, 23, 42, 0.04); } .cluster-k8s-table { width: 100%; - min-width: 36rem; + min-width: 32rem; border-collapse: collapse; font-size: 0.8rem; } +@media (max-width: 767px) { + .cluster-k8s-table { + min-width: 28rem; + font-size: 0.76rem; + } +} .cluster-k8s-table th, .cluster-k8s-table td { padding: 0.4rem 0.55rem; @@ -2448,11 +3553,21 @@ html[data-theme="light"] .cluster-k8s-table-scroll { border-bottom: 1px solid var(--border); vertical-align: middle; } +@media (max-width: 767px) { + .cluster-k8s-table th, + .cluster-k8s-table td { + padding: 0.32rem 0.4rem; + } +} .cluster-k8s-table th { font-weight: 650; color: var(--muted); white-space: nowrap; background: rgba(0, 0, 0, 0.18); + position: sticky; + top: 0; + z-index: 2; + box-shadow: 0 1px 0 var(--border); } html[data-theme="light"] .cluster-k8s-table th { background: rgba(59, 130, 246, 0.1); @@ -2510,8 +3625,10 @@ html[data-theme="light"] .cluster-kube-pre { background: rgba(15, 23, 42, 0.04); } -/* Полноэкранный спиннер первой загрузки главной панели */ -body.dashboard-home-loading { +/* Полноэкранный спиннер первой загрузки главной панели, /cluster-create, /documentation */ +body.dashboard-home-loading, +body.dashboard-create-loading, +body.documentation-page-loading { overflow: hidden; } @@ -2706,7 +3823,7 @@ html[data-theme="light"] .cluster-edit-segmented { .cluster-edit-segment-native:checked + .cluster-edit-segment-label { background: var(--accent); - color: #f8fafc; + color: var(--on-accent); border-color: var(--accent); box-shadow: 0 2px 14px rgba(59, 130, 246, 0.35); } @@ -2972,7 +4089,7 @@ html[data-theme="light"] .cluster-edit-yaml-preview-head { .cluster-addons-toolbar-card .cluster-addons-cluster-row { display: flex; flex-wrap: wrap; - align-items: flex-end; + align-items: center; gap: 0.65rem 1rem; margin-bottom: 0.35rem; } @@ -3255,12 +4372,23 @@ html[data-theme="light"] .cluster-addon-card__values-textarea { .journal-table-wrap { overflow-x: auto; margin-top: 0.5rem; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + border-radius: 10px; + scrollbar-width: thin; } .journal-table { width: 100%; + min-width: 28rem; border-collapse: collapse; font-size: 0.85rem; } +@media (max-width: 767px) { + .journal-table { + font-size: 0.8rem; + min-width: 22rem; + } +} .journal-table th, .journal-table td { padding: 0.45rem 0.55rem; @@ -3268,9 +4396,20 @@ html[data-theme="light"] .cluster-addon-card__values-textarea { border-bottom: 1px solid var(--border); vertical-align: top; } +@media (max-width: 767px) { + .journal-table th, + .journal-table td { + padding: 0.38rem 0.4rem; + } +} .journal-table th { font-weight: 600; color: var(--muted); + position: sticky; + top: 0; + z-index: 2; + background: var(--card); + box-shadow: 0 1px 0 var(--border); } .journal-code { font-size: 0.8rem; @@ -3278,6 +4417,30 @@ html[data-theme="light"] .cluster-addon-card__values-textarea { .journal-cell-time { white-space: nowrap; } +/* Дата и время в одну строку на широком экране; до 920px — в две строки (дата / время), как в журнале и таблицах */ +.journal-time-stack { + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: baseline; + gap: 0.35em; +} +.journal-time-date, +.journal-time-clock { + font-variant-numeric: tabular-nums; +} + +@media (max-width: 919px) { + .journal-time-stack { + flex-direction: column; + align-items: flex-start; + gap: 0.12rem; + } + .journal-cell-time { + white-space: normal; + vertical-align: top; + } +} .journal-cell-msg { max-width: 14rem; word-break: break-word; @@ -3322,6 +4485,230 @@ html[data-theme="light"] .journal-details-cell { border: 1px solid var(--border); } +/* + * Журнал: при ширине < 620px список записей — карточки (та же сетка, что «Последние задания» на /cluster-create). + * Разметка строк задаётся в journal.js (классы create-jobs-card-td*). + * Автор: Сергей Антропов — https://devops.org.ru + */ +@media (max-width: 619px) { + .journal-list-card { + padding-left: 0.5rem; + padding-right: 0.5rem; + box-sizing: border-box; + } + .journal-list-card .journal-table-wrap { + overflow-x: visible; + margin-top: 0.5rem; + padding: 0; + border-radius: 0; + } + .journal-list-card #journal-table { + min-width: 0 !important; + width: 100%; + display: block; + border-collapse: separate; + border-spacing: 0; + } + .journal-list-card #journal-table thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .journal-list-card #journal-table tbody { + display: block; + } + .journal-list-card #journal-table tbody tr.journal-data-row { + display: block; + padding: 0; + border: 1px solid var(--border); + border-radius: 16px; + box-sizing: border-box; + overflow: hidden; + margin-bottom: 1.05rem; + background: linear-gradient( + 160deg, + rgba(59, 130, 246, 0.11) 0%, + rgba(15, 23, 42, 0.55) 42%, + rgba(10, 15, 22, 0.72) 100% + ); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.05) inset, + 0 8px 28px rgba(0, 0, 0, 0.28); + } + html[data-theme="light"] .journal-list-card #journal-table tbody tr.journal-data-row { + background: linear-gradient( + 165deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(241, 245, 249, 0.92) 48%, + rgba(226, 232, 240, 0.88) 100% + ); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.9) inset, + 0 6px 22px rgba(15, 23, 42, 0.09); + } + .journal-list-card #journal-table tbody tr.journal-data-row:has(+ tr.journal-details-row:not(.hidden)) { + border-bottom: none; + border-radius: 16px 16px 0 0; + margin-bottom: 0; + } + .journal-list-card #journal-table tbody tr.journal-details-row:not(.hidden) { + display: block; + margin-bottom: 1.05rem; + padding: 0; + border: 1px solid var(--border); + border-top: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 0 0 16px 16px; + box-sizing: border-box; + overflow: hidden; + background: rgba(0, 0, 0, 0.2); + } + html[data-theme="light"] .journal-list-card #journal-table tbody tr.journal-details-row:not(.hidden) { + border-top-color: rgba(15, 23, 42, 0.08); + background: rgba(15, 23, 42, 0.04); + } + .journal-list-card #journal-table tbody tr.journal-details-row:not(.hidden) td { + display: block; + width: 100% !important; + border: none; + padding: 0.65rem 0.85rem 0.85rem; + box-sizing: border-box; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td:not(.create-jobs-card-td--log) { + display: grid; + grid-template-columns: minmax(6.5rem, 40%) minmax(0, 1fr); + gap: 0.45rem 0.65rem; + align-items: center; + justify-items: start; + width: 100%; + max-width: none; + box-sizing: border-box; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.55rem 1rem; + text-align: center; + white-space: normal; + overflow: visible; + } + html[data-theme="light"] + .journal-list-card + #journal-table + tbody + tr.journal-data-row + td.create-jobs-card-td:not(.create-jobs-card-td--log) { + border-bottom-color: rgba(15, 23, 42, 0.07); + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--time { + align-items: start; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td:not(.create-jobs-card-td--log)::before { + font-size: 0.72rem; + font-weight: 600; + text-transform: none; + letter-spacing: 0.02em; + color: var(--muted); + line-height: 1.3; + justify-self: start; + text-align: left; + width: auto; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--time::before { + content: "Завершено (UTC)"; + padding-top: 0.15rem; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--cluster::before { + content: "Кластер"; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--kind::before { + content: "Тип / операция"; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--status::before { + content: "Статус"; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--msg::before { + content: "Сообщение"; + align-self: start; + padding-top: 0.2rem; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--msg { + align-items: start; + } + .journal-list-card #journal-table tbody tr.journal-data-row .journal-cell-kind { + max-width: none; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--cluster .journal-code, + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--kind .journal-code { + display: inline-block; + justify-self: center; + width: fit-content; + max-width: 100%; + text-align: center; + word-break: break-word; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--status > span { + display: inline-block; + justify-self: center; + width: fit-content; + max-width: 100%; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--time > .journal-time-stack { + display: flex; + flex-direction: column; + align-items: center; + justify-self: center; + width: 100%; + text-align: center; + gap: 0.12rem; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--msg .create-jobs-msg-value { + display: block; + justify-self: center; + width: 100%; + max-width: 100%; + text-align: center; + word-break: break-word; + overflow-wrap: anywhere; + line-height: 1.4; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--log { + display: block; + width: 100% !important; + max-width: none !important; + margin: 0; + padding: 0.75rem 0.85rem 0.95rem; + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(0, 0, 0, 0.2); + text-align: center; + white-space: normal; + box-sizing: border-box; + } + html[data-theme="light"] .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--log { + border-top-color: rgba(15, 23, 42, 0.08); + background: rgba(15, 23, 42, 0.04); + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--log::before { + content: "Лог"; + display: block; + font-size: 0.65rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 0.55rem; + opacity: 0.95; + } + .journal-list-card #journal-table tbody tr.journal-data-row td.create-jobs-card-td--log .btn-journal-toggle { + margin-left: auto; + margin-right: auto; + } +} + /* Три сегмента на странице журнала — шире блок переключателя */ .journal-toolbar-card .cluster-edit-segmented { max-width: 100%; @@ -3376,7 +4763,7 @@ html[data-theme="light"] .journal-details-cell { max-width: 12rem; } -/* Пагинация журнала */ +/* Пагинация журнала: компактно, в одну строку (при нехватке места — горизонтальный скролл) */ .journal-pagination { margin-top: 1.25rem; padding-top: 1rem; @@ -3384,19 +4771,38 @@ html[data-theme="light"] .journal-details-cell { } .journal-pagination-inner { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; justify-content: center; - gap: 0.35rem 0.5rem; + gap: 0.18rem 0.32rem; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: thin; + padding-bottom: 0.15rem; + box-sizing: border-box; +} +.journal-pagination-inner::-webkit-scrollbar { + height: 4px; +} +.journal-pagination-inner::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; } .journal-pag-btn { - padding: 0.4rem 0.65rem; - font-size: 0.82rem; - border-radius: 8px; + padding: 0.26rem 0.42rem; + font-size: 0.72rem; + font-weight: 600; + line-height: 1.2; + border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; + flex-shrink: 0; + white-space: nowrap; transition: background 0.15s ease, border-color 0.15s ease; } .journal-pag-btn:hover:not(:disabled) { @@ -3409,21 +4815,25 @@ html[data-theme="light"] .journal-details-cell { } .journal-pag-pages { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; - gap: 0.25rem; - margin: 0 0.15rem; + gap: 0.12rem; + margin: 0 0.06rem; + flex-shrink: 0; } .journal-pag-num { - min-width: 2.35rem; - padding: 0.4rem 0.5rem; - font-size: 0.85rem; - font-weight: 500; - border-radius: 8px; + min-width: 1.6rem; + padding: 0.26rem 0.34rem; + font-size: 0.74rem; + font-weight: 600; + line-height: 1.2; + border-radius: 6px; border: 1px solid transparent; background: transparent; color: var(--text); cursor: pointer; + flex-shrink: 0; + white-space: nowrap; transition: background 0.15s ease, color 0.15s ease; } .journal-pag-num:hover:not(:disabled) { @@ -3434,20 +4844,239 @@ html[data-theme="light"] .journal-details-cell { border-color: rgba(99, 102, 241, 0.5); color: var(--text); cursor: default; - box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2); + box-shadow: 0 1px 4px rgba(99, 102, 241, 0.18); } .journal-pag-ellipsis { - padding: 0 0.2rem; + padding: 0 0.08rem; color: var(--muted); - font-size: 0.9rem; + font-size: 0.78rem; user-select: none; + flex-shrink: 0; } -@media (max-width: 640px) { - .journal-pagination-inner { + +/* + * Вьюпорт до 480px: компактная главная, кластеры, страница кластера, журнал, документация. + * Автор: Сергей Антропов — https://devops.org.ru + */ +@media (max-width: 479px) { + .clusters-page-hero-row { flex-direction: column; + align-items: center; + text-align: center; + } + .clusters-page-hero-text { + flex: none; + width: 100%; + text-align: center; + } + .clusters-page-hero-actions { + flex: none; + align-self: center; + width: 100%; + margin-left: 0 !important; + justify-content: center; + padding-top: 0.25rem; + } + .clusters-page-create-btn { + margin-left: auto; + margin-right: auto; + } + + .cluster-detail-hero-layout { + align-items: flex-start; + } + .cluster-detail-hero-meta { + align-self: stretch; + width: 100%; + max-width: none; + } + + .journal-pagination { + margin-top: 0.85rem; + padding-top: 0.65rem; + } + .journal-pagination-inner { + gap: 0.12rem 0.22rem; + } + .journal-pag-btn { + padding: 0.22rem 0.36rem; + font-size: 0.68rem; } .journal-pag-pages { - order: -1; - margin: 0.25rem 0; + gap: 0.08rem; + } + .journal-pag-num { + min-width: 1.45rem; + padding: 0.22rem 0.28rem; + font-size: 0.7rem; + } + .journal-pag-ellipsis { + font-size: 0.72rem; + padding: 0 0.06rem; + } + + /* Таблицы kubectl: строка ресурса = мини-карточка (data-label задаётся в clusterK8sBuildTable) */ + .cluster-k8s-table-scroll { + overflow-x: visible; + border: none; + background: transparent; + } + .cluster-k8s-table { + display: block; + width: 100%; + min-width: 0; + border-collapse: separate; + border-spacing: 0; + } + .cluster-k8s-table thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .cluster-k8s-table tbody { + display: block; + } + .cluster-k8s-table tbody tr { + display: block; + margin-bottom: 0.65rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.1); + box-sizing: border-box; + } + html[data-theme="light"] .cluster-k8s-table tbody tr { + background: rgba(59, 130, 246, 0.05); + } + .cluster-k8s-table tbody td { + display: grid; + grid-template-columns: minmax(5.25rem, 38%) minmax(0, 1fr); + gap: 0.28rem 0.45rem; + align-items: center; + justify-items: start; + width: 100% !important; + max-width: none; + border: none; + border-bottom: 1px solid var(--border); + padding: 0.4rem 0; + white-space: normal; + vertical-align: top; + word-break: break-word; + overflow-wrap: anywhere; + text-align: center; + } + .cluster-k8s-table tbody td > * { + justify-self: center; + text-align: center; + } + .cluster-k8s-table tbody td .badge { + width: fit-content; + max-width: 100%; + box-sizing: border-box; + } + .cluster-k8s-table tbody td:last-child { + border-bottom: none; + white-space: normal; + } + .cluster-k8s-table tbody td::before { + content: attr(data-label); + justify-self: start; + text-align: left; + width: auto; + font-size: 0.62rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + line-height: 1.2; + padding-top: 0.06rem; + } + .cluster-k8s-table tbody td:not([data-label])::before { + content: none; + } + + /* Документация: широкие таблицы Markdown → мини-карточки (класс и data-label в documentation.js) */ + .readme-section-card.markdown-body table.markdown-body-table--stack { + display: block; + width: 100%; + min-width: 0; + border-collapse: separate; + border-spacing: 0; + margin: 0.5rem 0; + } + .readme-section-card.markdown-body table.markdown-body-table--stack thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody { + display: block; + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody tr { + display: block; + margin-bottom: 0.6rem; + padding: 0.5rem 0.6rem; + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.08); + box-sizing: border-box; + } + html[data-theme="light"] + .readme-section-card.markdown-body + table.markdown-body-table--stack + tbody + tr { + background: rgba(59, 130, 246, 0.04); + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody td { + display: grid; + grid-template-columns: minmax(5rem, 38%) minmax(0, 1fr); + gap: 0.28rem 0.4rem; + align-items: center; + justify-items: start; + border: none; + border-bottom: 1px solid var(--border); + padding: 0.38rem 0; + word-break: break-word; + overflow-wrap: anywhere; + text-align: center; + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody td > * { + justify-self: center; + text-align: center; + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody td .badge { + width: fit-content; + max-width: 100%; + box-sizing: border-box; + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody tr td:last-child { + border-bottom: none; + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody td::before { + content: attr(data-label); + justify-self: start; + text-align: left; + width: auto; + font-size: 0.62rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + } + .readme-section-card.markdown-body table.markdown-body-table--stack tbody td:not([data-label])::before { + content: none; } } diff --git a/app/templates/base.html b/app/templates/base.html index fa8d675..346ae4e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -28,6 +28,7 @@ + {% block head_extra %}{% endblock %} {{ app_title }} - {# Один ряд flex: Панель, Документация, API, тема — общий align-items: center #} -