diff --git a/README.md b/README.md index b304c31..cb59e78 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ | Ресурс | Описание | |--------|----------| | **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) | -| **[app/docs/README.md](app/docs/README.md)** | Указатель по каталогу `app/docs/` | | **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) | Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`). @@ -179,7 +178,7 @@ make podman up | `app/models/schemas.py` | Pydantic-схемы запросов/ответов API | | `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `documentation.html` | | `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) | -| `app/docs/` | `api_routes.md`, `README.md` | +| `app/docs/` | `api_routes.md` (описание REST API) | | `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` | В UI и API список версий **kindest/node** по умолчанию тянется с Docker Hub (нужна сеть). В изолированной среде: **`KIND_K8S_SKIP_VERSION_LIST=1`** — версию вводят вручную. diff --git a/app/api/v1/endpoints/clusters.py b/app/api/v1/endpoints/clusters.py index b80428b..59cfc24 100644 --- a/app/api/v1/endpoints/clusters.py +++ b/app/api/v1/endpoints/clusters.py @@ -26,6 +26,7 @@ from core.cluster_lifecycle import ( stop_kind_cluster_containers, validate_cluster_name, ) +from core.container_resource_stats import collect_kind_clusters_resource_stats from core.job_store import ( JobRecord, end_job_tracking, @@ -103,12 +104,16 @@ def _stats_sync() -> StatsResponse: jobs = job_store.snapshot_all() failed = sum(1 for j in jobs if j.status == "failed") + res_blocks, res_err = collect_kind_clusters_resource_stats() + return StatsResponse( kind_clusters_count=len(kind_names), local_cluster_dirs_count=len(subdirs), total_workers_from_meta=total_workers if counted else None, jobs_total=len(jobs), jobs_recent_failed=failed, + cluster_resources=res_blocks, + cluster_resources_error=res_err, ) diff --git a/app/core/container_resource_stats.py b/app/core/container_resource_stats.py new file mode 100644 index 0000000..86fe08f --- /dev/null +++ b/app/core/container_resource_stats.py @@ -0,0 +1,146 @@ +"""Метрики CPU, памяти и I/O узлов kind через ``docker stats`` / ``podman stats``. + +Имена контейнеров совпадают с префиксом кластера (см. ``list_kind_cluster_container_names``). + +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters +from models.schemas import KindClusterResources, KindNodeResourceStat + +logger = logging.getLogger("kind_k8s.container_resource_stats") + +# Одна строка на контейнер (совместимо с Docker и Podman). +_STAT_FORMAT = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}" + + +def _container_cli_bin() -> str: + return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker" + + +def _list_running_container_names(cli: str) -> set[str]: + """Имена **работающих** контейнеров (``ps`` без ``-a``).""" + if not shutil.which(cli): + return set() + p = subprocess.run( + [cli, "ps", "--format", "{{.Names}}"], + capture_output=True, + text=True, + timeout=30, + ) + if p.returncode != 0: + logger.debug("%s ps: код %s", cli, p.returncode) + return set() + return {n.strip() for n in (p.stdout or "").splitlines() if n.strip()} + + +def _all_container_names_ps_a(cli: str) -> tuple[list[str], str | None]: + """Все имена контейнеров (``ps -a``). При ошибке — ([], сообщение).""" + p = subprocess.run( + [cli, "ps", "-a", "--format", "{{.Names}}"], + capture_output=True, + text=True, + timeout=30, + ) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() or str(p.returncode) + return [], f"{cli} ps -a: {err}" + lines = [n.strip() for n in (p.stdout or "").splitlines() if n.strip()] + return lines, None + + +def _parse_pids(raw: str) -> int | None: + s = raw.strip() + if not s: + return None + try: + return int(re.sub(r"\D", "", s) or "0") + except ValueError: + return None + + +def _stats_for_container_names(cli: str, names: list[str]) -> list[KindNodeResourceStat]: + """Вызов `` stats --no-stream`` для списка имён (только запущенные).""" + if not names: + return [] + cmd = [cli, "stats", "--no-stream", "--format", _STAT_FORMAT, *names] + p = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if p.returncode != 0: + err = (p.stderr or p.stdout or "").strip() or str(p.returncode) + logger.warning("%s stats: %s", cli, err[:500]) + return [] + out: list[KindNodeResourceStat] = [] + for line in (p.stdout or "").splitlines(): + line = line.strip() + if not line: + continue + parts = line.split("\t") + while len(parts) < 7: + parts.append("") + name, cpu, mem_use, mem_pct, net_io, blk_io, pids_raw = parts[:7] + out.append( + KindNodeResourceStat( + container_name=name.strip(), + cpu_percent=cpu.strip() or None, + memory_usage=mem_use.strip() or None, + memory_percent=mem_pct.strip() or None, + net_io=net_io.strip() or None, + block_io=blk_io.strip() or None, + pids=_parse_pids(pids_raw), + ) + ) + return out + + +def collect_kind_clusters_resource_stats() -> tuple[list[KindClusterResources], str | None]: + """ + Для каждого кластера из ``kind get clusters`` — метрики **запущенных** узлов. + + Один вызов ``ps -a`` на все имена; префикс узлов совпадает с ``list_kind_cluster_container_names``. + + Возвращает (список блоков, сообщение об ошибке уровня движка или ``None``). + """ + cli = _container_cli_bin() + if not shutil.which(cli): + return [], f"CLI «{cli}» не найден в PATH — метрики узлов недоступны" + + all_lines, ps_err = _all_container_names_ps_a(cli) + if ps_err: + return [], ps_err + + running = _list_running_container_names(cli) + if not running: + logger.debug("Нет запущенных контейнеров по %s ps", cli) + + kind_names = list_registered_kind_clusters() + blocks: list[KindClusterResources] = [] + for cluster_name in kind_names: + prefix = f"{cluster_name}-" + node_names = _sort_kind_node_containers([n for n in all_lines if n.startswith(prefix)]) + + active = [n for n in node_names if n in running] + if not active: + blocks.append( + KindClusterResources( + cluster_name=cluster_name, + nodes=[], + note="узлы остановлены или контейнеры отсутствуют", + ) + ) + continue + + stats = _stats_for_container_names(cli, active) + # Сохраняем порядок control-plane первым, как в kind + order = {n: i for i, n in enumerate(node_names)} + stats.sort(key=lambda s: order.get(s.container_name, 99)) + blocks.append(KindClusterResources(cluster_name=cluster_name, nodes=stats, note=None)) + + return blocks, None diff --git a/app/docs/README.md b/app/docs/README.md deleted file mode 100644 index e630e6d..0000000 --- a/app/docs/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Документация приложения (app/docs) - -Каталог описаний для разработчиков и интеграции с UI. - -| Файл | Назначение | -|------|------------| -| [api_routes.md](api_routes.md) | Полное описание REST API `/api/v1/*` с примерами JSON (ориентир для фронтенда и клиентов). | - -После запуска: **Swagger** — `/docs`, **ReDoc** — `/redoc`, **Health** — `/api/v1/health` (тот же порт, что и UI). С дашборда эти ссылки открываются в **отдельном окне** браузера. **kubectl** на машине разработчика не нужен: он в образе; см. **README.md** — цель **`make docker kubectl`** / **`make podman kubectl`** и API **`/api/v1/clusters/{name}/workloads`**. - -**Автор:** Сергей Антропов — [devops.org.ru](https://devops.org.ru) diff --git a/app/docs/api_routes.md b/app/docs/api_routes.md index 6169d63..99e259f 100644 --- a/app/docs/api_routes.md +++ b/app/docs/api_routes.md @@ -20,7 +20,7 @@ | Маршрут | Описание | |---------|----------| | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** `kind create`, отмена), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | -| `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** показываются отдельными карточками (заголовок и тело). В шапке активна пилюля **Документация**. Путь к 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). Каждая секция по **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`). | @@ -160,23 +160,52 @@ Accept: text/markdown ## GET /api/v1/stats -Сводная статистика для дашборда. +Сводная статистика для дашборда и **метрики запущенных узлов kind** (один вызов `docker stats` / `podman stats` на набор имён контейнеров `имя-control-plane`, `имя-worker`…). **Пример ответа 200:** ```json { - "kind_clusters_count": 2, - "local_cluster_dirs_count": 2, - "total_workers_from_meta": 4, - "jobs_total": 5, - "jobs_recent_failed": 1 + "kind_clusters_count": 1, + "local_cluster_dirs_count": 1, + "total_workers_from_meta": 2, + "jobs_total": 3, + "jobs_recent_failed": 0, + "cluster_resources": [ + { + "cluster_name": "dev", + "nodes": [ + { + "container_name": "dev-control-plane", + "cpu_percent": "2.50%", + "memory_usage": "512MiB / 7.7GiB", + "memory_percent": "6.50%", + "net_io": "1.2MB / 800kB", + "block_io": "2GB / 150MB", + "pids": 245 + }, + { + "container_name": "dev-worker", + "cpu_percent": "1.00%", + "memory_usage": "380MiB / 7.7GiB", + "memory_percent": "4.80%", + "net_io": "512kB / 400kB", + "block_io": "500MB / 50MB", + "pids": 198 + } + ], + "note": null + } + ], + "cluster_resources_error": null } ``` - `total_workers_from_meta` может быть `null`, если ни в одном `meta.json` нет `worker_nodes`. - `jobs_total` — число заданий в текущей памяти процесса (не более 200). - `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку). +- `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение. +- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым. --- diff --git a/app/models/schemas.py b/app/models/schemas.py index 441de48..2bbd71d 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -58,6 +58,26 @@ class ClusterSummary(BaseModel): meta: dict[str, Any] = Field(default_factory=dict) +class KindNodeResourceStat(BaseModel): + """Одна нода kind (контейнер): снимок ``docker stats`` / ``podman stats``.""" + + container_name: str + cpu_percent: str | None = Field(default=None, description="Доля CPU, напр. 1.23%") + memory_usage: str | None = Field(default=None, description="Использование / лимит, напр. 120MiB / 7.7GiB") + memory_percent: str | None = Field(default=None, description="Процент памяти от лимита контейнера") + net_io: str | None = Field(default=None, description="Сетевой I/O (накопительно за жизнь контейнера)") + block_io: str | None = Field(default=None, description="Блочный I/O") + pids: int | None = Field(default=None, description="Число процессов в контейнере") + + +class KindClusterResources(BaseModel): + """Ресурсы узлов одного кластера kind (только запущенные контейнеры).""" + + cluster_name: str + nodes: list[KindNodeResourceStat] = Field(default_factory=list) + note: str | None = Field(default=None, description="Пояснение, если узлов нет или ошибка списка") + + class StatsResponse(BaseModel): """Краткая статистика для дашборда.""" @@ -66,6 +86,14 @@ class StatsResponse(BaseModel): total_workers_from_meta: int | None jobs_total: int jobs_recent_failed: int + cluster_resources: list[KindClusterResources] = Field( + default_factory=list, + description="CPU/RAM/I/O узлов kind по данным container CLI", + ) + cluster_resources_error: str | None = Field( + default=None, + description="Глобальная ошибка сбора метрик (например CLI не найден)", + ) class ClusterWorkloadsResponse(BaseModel): diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index 18acc5e..bde9807 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -248,6 +248,77 @@ } } + /** + * Блок «Ресурсы узлов» из cluster_resources (CPU/RAM/I/O из docker stats). + */ + function renderStatsResources(s) { + const wrap = document.getElementById("stats-resources"); + if (!wrap) return; + wrap.replaceChildren(); + if (s.cluster_resources_error) { + const p = document.createElement("p"); + p.className = "muted stats-resources-msg"; + p.textContent = "Ресурсы узлов: " + s.cluster_resources_error; + wrap.appendChild(p); + return; + } + const list = s.cluster_resources || []; + if (!list.length) { + const p2 = document.createElement("p"); + p2.className = "muted stats-resources-msg"; + p2.textContent = "Нет кластеров в kind — метрики узлов пусты."; + wrap.appendChild(p2); + return; + } + const h3 = document.createElement("h3"); + h3.className = "stats-resources-heading"; + h3.textContent = "Ресурсы узлов (CPU, RAM, I/O)"; + wrap.appendChild(h3); + list.forEach(function (cr) { + const sec = document.createElement("section"); + sec.className = "stats-cluster-res"; + const h4 = document.createElement("h4"); + h4.className = "stats-cluster-res__title"; + h4.textContent = cr.cluster_name; + sec.appendChild(h4); + const nodes = cr.nodes || []; + if (!nodes.length) { + const pn = document.createElement("p"); + pn.className = "muted stats-cluster-res__note"; + pn.textContent = cr.note || "Нет запущенных контейнеров узлов."; + sec.appendChild(pn); + } else { + nodes.forEach(function (n) { + const row = document.createElement("div"); + row.className = "stats-node-res"; + const code = document.createElement("code"); + code.textContent = n.container_name; + row.appendChild(code); + const ul = document.createElement("ul"); + ul.className = "stats-node-res__metrics"; + function addMetric(label, val) { + if (val == null || val === "") return; + const li = document.createElement("li"); + const strong = document.createElement("strong"); + strong.textContent = label + ": "; + li.appendChild(strong); + li.appendChild(document.createTextNode(String(val))); + ul.appendChild(li); + } + addMetric("CPU", n.cpu_percent); + addMetric("Память", n.memory_usage); + addMetric("% RAM", n.memory_percent); + addMetric("Сеть", n.net_io); + addMetric("Диск", n.block_io); + addMetric("PID", n.pids != null ? String(n.pids) : null); + row.appendChild(ul); + sec.appendChild(row); + }); + } + wrap.appendChild(sec); + }); + } + async function loadStats() { const dl = document.getElementById("stats-dl"); const errEl = document.getElementById("stats-err"); @@ -272,9 +343,12 @@ frag.appendChild(dd); }); dl.replaceChildren(frag); + renderStatsResources(s); } catch (e) { errEl.textContent = "Статистика: " + e.message; errEl.classList.remove("hidden"); + const wrap = document.getElementById("stats-resources"); + if (wrap) wrap.replaceChildren(); } } diff --git a/app/static/js/documentation.js b/app/static/js/documentation.js index 1bd9ef6..6c2db81 100644 --- a/app/static/js/documentation.js +++ b/app/static/js/documentation.js @@ -35,6 +35,50 @@ return DOC_BASE + "?path=" + encodeURIComponent(relPath); } + /** + * Хлебные крошки: Документация → файл (для app/docs/* кроме api_routes — ссылка «app/docs» на api_routes.md). + */ + function updateBreadcrumbs(docPath) { + var ol = document.getElementById("doc-breadcrumbs-list"); + if (!ol) return; + ol.replaceChildren(); + + function addLiLink(href, text) { + var li = document.createElement("li"); + li.className = "doc-breadcrumbs__item"; + var a = document.createElement("a"); + a.href = href; + a.className = "doc-breadcrumbs__link"; + a.textContent = text; + li.appendChild(a); + ol.appendChild(li); + } + + function addLiCurrent(text) { + var li = document.createElement("li"); + li.className = "doc-breadcrumbs__item doc-breadcrumbs__item--current"; + li.setAttribute("aria-current", "page"); + li.textContent = text; + ol.appendChild(li); + } + + addLiLink(DOC_BASE, "Документация"); + if (!docPath) { + addLiCurrent("README.md (корень репозитория)"); + return; + } + if (docPath.indexOf("app/docs/") === 0) { + if (docPath === "app/docs/api_routes.md") { + addLiCurrent("api_routes.md"); + } else { + addLiLink(docUrlForPath("app/docs/api_routes.md"), "app/docs"); + addLiCurrent(docPath.replace(/^.*\//, "")); + } + } else { + addLiCurrent(docPath); + } + } + /** * Внутренняя ссылка на .md под app/docs/: полный путь или относительно текущего файла. */ @@ -76,8 +120,27 @@ }); } + /** Текст первого H1 в разобранном HTML (для заголовка вкладки). */ + function extractFirstH1Text(root) { + var h = root.querySelector("h1"); + if (!h) return ""; + return (h.textContent || "").replace(/\s+/g, " ").trim(); + } + /** - * До первого H2 — одна карточка; для каждого H2 — карточка заголовка и карточка тела до следующего H2. + * Заголовок вкладки: «Документация — <первый H1> — » (как в api_routes: заголовок документа). + */ + function applyDocumentationTabTitle(h1Text) { + var appTitle = (body.dataset.appTitle || "").trim() || "kind"; + if (h1Text) { + document.title = "Документация — " + h1Text + " — " + appTitle; + } else { + document.title = "Документация — " + appTitle; + } + } + + /** + * До первого H2 — одна карточка; для каждого H2 — одна карточка: заголовок h2 и всё содержимое до следующего H2. */ function sectionizeFromNodes(sourceRoot) { var page = document.createElement("div"); @@ -110,25 +173,23 @@ appendCard("readme-section-card--intro", chunk); } - function flushH2Pair() { + function flushH2Section() { if (i >= nodes.length) return; var h2 = nodes[i]; if (h2.nodeType !== 1 || h2.tagName !== "H2") return; i++; - appendCard("readme-section-card--head", [h2]); - - var bodyChunk = []; + var chunk = [h2]; while (i < nodes.length) { var n = nodes[i]; if (n.nodeType === 1 && n.tagName === "H2") break; - bodyChunk.push(n); + chunk.push(n); i++; } - appendCard("readme-section-card--body", bodyChunk); + appendCard("readme-section-card--block", chunk); } flushIntro(); - while (i < nodes.length) flushH2Pair(); + while (i < nodes.length) flushH2Section(); return page; } @@ -136,6 +197,7 @@ if (typeof marked === "undefined" || typeof DOMPurify === "undefined") { showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/."); if (loadEl) loadEl.classList.add("hidden"); + applyDocumentationTabTitle(""); return; } @@ -155,12 +217,16 @@ temp.innerHTML = DOMPurify.sanitize(rawHtml); rewriteMdLinks(temp, docPath); + var h1ForTitle = extractFirstH1Text(temp); + applyDocumentationTabTitle(h1ForTitle); + while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild); var pageInner = sectionizeFromNodes(temp); rootEl.appendChild(pageInner); rootEl.removeAttribute("hidden"); } catch (e2) { showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2))); + applyDocumentationTabTitle(""); } } @@ -184,6 +250,8 @@ while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild); loadEl.classList.remove("hidden"); + updateBreadcrumbs(docPath); + var url = fetchUrlForPath(docPath); try { var r = await fetch(url, { @@ -200,6 +268,7 @@ } showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText)); loadEl.classList.add("hidden"); + applyDocumentationTabTitle(""); return; } @@ -215,6 +284,7 @@ } catch (e) { showErr(errEl, "Ошибка: " + (e.message || String(e))); loadEl.classList.add("hidden"); + applyDocumentationTabTitle(""); } } diff --git a/app/static/style.css b/app/static/style.css index 783fd0e..c177a02 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -128,36 +128,17 @@ body.modal-open { outline-offset: 3px; } -/* Текущее приложение — акцентная пилюля */ -.nav-link.nav-pill--home { - background: linear-gradient(145deg, rgba(59, 130, 246, 0.28), rgba(59, 130, 246, 0.1)); - border-color: rgba(59, 130, 246, 0.55); - color: #93c5fd; -} - -@media (prefers-color-scheme: light) { - .nav-link.nav-pill--home { - color: #1d4ed8; - } -} - -.nav-link.nav-pill--home:hover { - color: var(--accent); - background: linear-gradient(145deg, rgba(59, 130, 246, 0.35), rgba(59, 130, 246, 0.15)); -} - -/* Активный раздел (панель или документация) */ +/* Активный раздел: «Панель» на дашборде или «Документация» на /documentation */ .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) { border-color: rgba(59, 130, 246, 0.85); box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.35), 0 2px 14px rgba(59, 130, 246, 0.18); + background: rgba(59, 130, 246, 0.16); color: #93c5fd; } -.nav-link.nav-pill.nav-pill--home.nav-pill--active { - background: linear-gradient(145deg, rgba(59, 130, 246, 0.42), rgba(59, 130, 246, 0.18)); -} @media (prefers-color-scheme: light) { .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) { color: #1e40af; + background: rgba(59, 130, 246, 0.12); } } @@ -232,18 +213,89 @@ body.modal-open { .stats-side-card { display: flex; flex-direction: column; + max-height: min(85vh, 52rem); + overflow: hidden; } .stats-side-card h2 { margin-bottom: 0.5rem; + flex-shrink: 0; } .stats-dl--compact { - flex: 1; + flex: 0 0 auto; margin: 0; font-size: 0.88rem; } +/* Метрики узлов kind (docker stats) под сводкой */ +.stats-resources { + margin-top: 0.75rem; + padding-top: 0.65rem; + border-top: 1px solid var(--border); + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + font-size: 0.78rem; +} +.stats-resources-heading { + margin: 0 0 0.45rem; + font-size: 0.8125rem; + font-weight: 650; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.stats-resources-msg { + margin: 0; + font-size: 0.8rem; +} +.stats-cluster-res { + margin-bottom: 0.65rem; +} +.stats-cluster-res:last-child { + margin-bottom: 0; +} +.stats-cluster-res__title { + margin: 0 0 0.35rem; + font-size: 0.85rem; + font-weight: 650; +} +.stats-cluster-res__note { + margin: 0; + font-size: 0.78rem; +} +.stats-node-res { + margin-bottom: 0.45rem; + padding: 0.35rem 0.45rem; + border-radius: 6px; + background: rgba(0, 0, 0, 0.12); + border: 1px solid var(--border); +} +.stats-node-res code { + display: block; + font-size: 0.76rem; + margin-bottom: 0.25rem; + word-break: break-all; +} +.stats-node-res__metrics { + margin: 0; + padding-left: 1rem; + list-style: disc; +} +.stats-node-res__metrics li { + margin: 0.1rem 0; +} +.stats-node-res__metrics strong { + font-weight: 600; + color: var(--muted); +} +@media (prefers-color-scheme: light) { + .stats-node-res { + background: rgba(0, 0, 0, 0.04); + } +} + .create-cluster-card { margin-bottom: 1rem; } @@ -902,6 +954,48 @@ button.btn-danger:hover { filter: brightness(1.12); } +/* Хлебные крошки на странице документации */ +.doc-breadcrumbs { + max-width: 52rem; + margin: 0 auto 0.65rem; + padding: 0 0.15rem; +} +.doc-breadcrumbs__list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.15rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.8125rem; +} +.doc-breadcrumbs__item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} +.doc-breadcrumbs__item:not(:last-child)::after { + content: "›"; + opacity: 0.45; + font-weight: 500; + margin-left: 0.2rem; +} +.doc-breadcrumbs__link { + color: var(--accent); + text-decoration: none; +} +.doc-breadcrumbs__link:hover { + text-decoration: underline; + text-underline-offset: 2px; +} +.doc-breadcrumbs__item--current { + color: var(--muted); + font-weight: 600; + max-width: 100%; + word-break: break-word; +} + /* Страница «Документация»: Markdown → карточки секций */ .readme-doc-shell { max-width: 52rem; @@ -925,10 +1019,10 @@ button.btn-danger:hover { .readme-section-card { margin: 0; } -.readme-section-card--head h2 { - margin: 0; - padding: 0; - border-bottom: none; +/* Заголовок секции (h2) и содержимое — в одной карточке: без лишнего зазора сверху у первого заголовка */ +.readme-section-card.markdown-body > h1:first-child, +.readme-section-card.markdown-body > h2:first-child { + margin-top: 0; } .readme-section-card.markdown-body { font-size: 0.95rem; @@ -952,9 +1046,6 @@ button.btn-danger:hover { border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; } -.readme-section-card--head.markdown-body h2 { - margin: 0; -} .readme-section-card.markdown-body h3 { font-size: 1.05rem; } diff --git a/app/templates/base.html b/app/templates/base.html index 6d99219..7ba40a2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -23,7 +23,8 @@ {{ app_title }}