UI/документация: крошки, метрики узлов в stats, правки навигации и подвала

- Документация: хлебные крошки; секции H2 в одной карточке; заголовок вкладки от H1
- Навигация: активна только текущая пилюля (Панель без постоянного home-стиля)
- GET /api/v1/stats: cluster_resources (docker stats CPU/RAM/I/O по узлам kind)
- Панель: блок ресурсов в карточке статистики; убраны строки подвала про api_routes/clusters
- Удалён app/docs/README.md; крошки app/docs → api_routes.md; README корня обновлён
This commit is contained in:
Sergey Antropoff
2026-04-04 06:37:36 +03:00
parent 710b360e4a
commit 6d4bc65c8a
12 changed files with 495 additions and 62 deletions
+1 -2
View File
@@ -9,7 +9,6 @@
| Ресурс | Описание | | Ресурс | Описание |
|--------|----------| |--------|----------|
| **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) | | **[app/docs/api_routes.md](app/docs/api_routes.md)** | Описание REST API `/api/v1/*` с примерами JSON (для фронтенда и интеграций) |
| **[app/docs/README.md](app/docs/README.md)** | Указатель по каталогу `app/docs/` |
| **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) | | **`/docs`** (Swagger), **`/redoc`**, **`/api/v1/health`** | На панели открываются в **отдельном окне** браузера (`window.open`); прямой URL — тот же порт, что и UI (по умолчанию **8080**) |
Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`). Шаблона **`env.example`** в репозитории нет: переменные для `.env` задаются интерактивно скриптом **`scripts/setup_env_interactive.py`** (`make setup`).
@@ -179,7 +178,7 @@ make podman up
| `app/models/schemas.py` | Pydantic-схемы запросов/ответов API | | `app/models/schemas.py` | Pydantic-схемы запросов/ответов API |
| `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `documentation.html` | | `app/templates/` | Jinja2: `base.html`, `dashboard.html`, `documentation.html` |
| `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) | | `app/static/` | `style.css`, `js/dashboard.js`, `js/documentation.js`, `js/vendor/` (marked, DOMPurify для README в UI) |
| `app/docs/` | `api_routes.md`, `README.md` | | `app/docs/` | `api_routes.md` (описание REST API) |
| `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` | | `app/create_cluster.py`, `delete_cluster.py`, `cluster_status.py` | CLI и переиспользование из API / `compose run` |
В UI и API список версий **kindest/node** по умолчанию тянется с Docker Hub (нужна сеть). В изолированной среде: **`KIND_K8S_SKIP_VERSION_LIST=1`** — версию вводят вручную. В UI и API список версий **kindest/node** по умолчанию тянется с Docker Hub (нужна сеть). В изолированной среде: **`KIND_K8S_SKIP_VERSION_LIST=1`** — версию вводят вручную.
+5
View File
@@ -26,6 +26,7 @@ from core.cluster_lifecycle import (
stop_kind_cluster_containers, stop_kind_cluster_containers,
validate_cluster_name, validate_cluster_name,
) )
from core.container_resource_stats import collect_kind_clusters_resource_stats
from core.job_store import ( from core.job_store import (
JobRecord, JobRecord,
end_job_tracking, end_job_tracking,
@@ -103,12 +104,16 @@ def _stats_sync() -> StatsResponse:
jobs = job_store.snapshot_all() jobs = job_store.snapshot_all()
failed = sum(1 for j in jobs if j.status == "failed") failed = sum(1 for j in jobs if j.status == "failed")
res_blocks, res_err = collect_kind_clusters_resource_stats()
return StatsResponse( return StatsResponse(
kind_clusters_count=len(kind_names), kind_clusters_count=len(kind_names),
local_cluster_dirs_count=len(subdirs), local_cluster_dirs_count=len(subdirs),
total_workers_from_meta=total_workers if counted else None, total_workers_from_meta=total_workers if counted else None,
jobs_total=len(jobs), jobs_total=len(jobs),
jobs_recent_failed=failed, jobs_recent_failed=failed,
cluster_resources=res_blocks,
cluster_resources_error=res_err,
) )
+146
View File
@@ -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]:
"""Вызов ``<cli> 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
-11
View File
@@ -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)
+36 -7
View File
@@ -20,7 +20,7 @@
| Маршрут | Описание | | Маршрут | Описание |
|---------|----------| |---------|----------|
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, создание кластера (прогресс, **журнал** `kind create`, отмена), таблица кластеров с **иконками** действий и **всплывающими подсказками**, модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | | `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 /ui` | Редирект **307** на `/` (удобный ярлык). |
| `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). | | `GET /static/…` | CSS (`style.css`), скрипты панели (`js/dashboard.js`) и документации (`js/documentation.js`); базовый URL API задаётся атрибутом `data-api-base` на `<body>` (по умолчанию `/api/v1`). |
@@ -160,23 +160,52 @@ Accept: text/markdown
## GET /api/v1/stats ## GET /api/v1/stats
Сводная статистика для дашборда. Сводная статистика для дашборда и **метрики запущенных узлов kind** (один вызов `docker stats` / `podman stats` на набор имён контейнеров `имя-control-plane`, `имя-worker`…).
**Пример ответа 200:** **Пример ответа 200:**
```json ```json
{ {
"kind_clusters_count": 2, "kind_clusters_count": 1,
"local_cluster_dirs_count": 2, "local_cluster_dirs_count": 1,
"total_workers_from_meta": 4, "total_workers_from_meta": 2,
"jobs_total": 5, "jobs_total": 3,
"jobs_recent_failed": 1 "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`. - `total_workers_from_meta` может быть `null`, если ни в одном `meta.json` нет `worker_nodes`.
- `jobs_total` — число заданий в текущей памяти процесса (не более 200). - `jobs_total` — число заданий в текущей памяти процесса (не более 200).
- `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку). - `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку).
- `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение.
- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым.
--- ---
+28
View File
@@ -58,6 +58,26 @@ class ClusterSummary(BaseModel):
meta: dict[str, Any] = Field(default_factory=dict) 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): class StatsResponse(BaseModel):
"""Краткая статистика для дашборда.""" """Краткая статистика для дашборда."""
@@ -66,6 +86,14 @@ class StatsResponse(BaseModel):
total_workers_from_meta: int | None total_workers_from_meta: int | None
jobs_total: int jobs_total: int
jobs_recent_failed: 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): class ClusterWorkloadsResponse(BaseModel):
+74
View File
@@ -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() { async function loadStats() {
const dl = document.getElementById("stats-dl"); const dl = document.getElementById("stats-dl");
const errEl = document.getElementById("stats-err"); const errEl = document.getElementById("stats-err");
@@ -272,9 +343,12 @@
frag.appendChild(dd); frag.appendChild(dd);
}); });
dl.replaceChildren(frag); dl.replaceChildren(frag);
renderStatsResources(s);
} catch (e) { } catch (e) {
errEl.textContent = "Статистика: " + e.message; errEl.textContent = "Статистика: " + e.message;
errEl.classList.remove("hidden"); errEl.classList.remove("hidden");
const wrap = document.getElementById("stats-resources");
if (wrap) wrap.replaceChildren();
} }
} }
+78 -8
View File
@@ -35,6 +35,50 @@
return DOC_BASE + "?path=" + encodeURIComponent(relPath); 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/: полный путь или относительно текущего файла. * Внутренняя ссылка на .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> <app_title>» (как в 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) { function sectionizeFromNodes(sourceRoot) {
var page = document.createElement("div"); var page = document.createElement("div");
@@ -110,25 +173,23 @@
appendCard("readme-section-card--intro", chunk); appendCard("readme-section-card--intro", chunk);
} }
function flushH2Pair() { function flushH2Section() {
if (i >= nodes.length) return; if (i >= nodes.length) return;
var h2 = nodes[i]; var h2 = nodes[i];
if (h2.nodeType !== 1 || h2.tagName !== "H2") return; if (h2.nodeType !== 1 || h2.tagName !== "H2") return;
i++; i++;
appendCard("readme-section-card--head", [h2]); var chunk = [h2];
var bodyChunk = [];
while (i < nodes.length) { while (i < nodes.length) {
var n = nodes[i]; var n = nodes[i];
if (n.nodeType === 1 && n.tagName === "H2") break; if (n.nodeType === 1 && n.tagName === "H2") break;
bodyChunk.push(n); chunk.push(n);
i++; i++;
} }
appendCard("readme-section-card--body", bodyChunk); appendCard("readme-section-card--block", chunk);
} }
flushIntro(); flushIntro();
while (i < nodes.length) flushH2Pair(); while (i < nodes.length) flushH2Section();
return page; return page;
} }
@@ -136,6 +197,7 @@
if (typeof marked === "undefined" || typeof DOMPurify === "undefined") { if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/."); showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
if (loadEl) loadEl.classList.add("hidden"); if (loadEl) loadEl.classList.add("hidden");
applyDocumentationTabTitle("");
return; return;
} }
@@ -155,12 +217,16 @@
temp.innerHTML = DOMPurify.sanitize(rawHtml); temp.innerHTML = DOMPurify.sanitize(rawHtml);
rewriteMdLinks(temp, docPath); rewriteMdLinks(temp, docPath);
var h1ForTitle = extractFirstH1Text(temp);
applyDocumentationTabTitle(h1ForTitle);
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild); while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
var pageInner = sectionizeFromNodes(temp); var pageInner = sectionizeFromNodes(temp);
rootEl.appendChild(pageInner); rootEl.appendChild(pageInner);
rootEl.removeAttribute("hidden"); rootEl.removeAttribute("hidden");
} catch (e2) { } catch (e2) {
showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2))); showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2)));
applyDocumentationTabTitle("");
} }
} }
@@ -184,6 +250,8 @@
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild); while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
loadEl.classList.remove("hidden"); loadEl.classList.remove("hidden");
updateBreadcrumbs(docPath);
var url = fetchUrlForPath(docPath); var url = fetchUrlForPath(docPath);
try { try {
var r = await fetch(url, { var r = await fetch(url, {
@@ -200,6 +268,7 @@
} }
showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText)); showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText));
loadEl.classList.add("hidden"); loadEl.classList.add("hidden");
applyDocumentationTabTitle("");
return; return;
} }
@@ -215,6 +284,7 @@
} catch (e) { } catch (e) {
showErr(errEl, "Ошибка: " + (e.message || String(e))); showErr(errEl, "Ошибка: " + (e.message || String(e)));
loadEl.classList.add("hidden"); loadEl.classList.add("hidden");
applyDocumentationTabTitle("");
} }
} }
+121 -30
View File
@@ -128,36 +128,17 @@ body.modal-open {
outline-offset: 3px; outline-offset: 3px;
} }
/* Текущее приложение — акцентная пилюля */ /* Активный раздел: «Панель» на дашборде или «Документация» на /documentation */
.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));
}
/* Активный раздел (панель или документация) */
.nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) { .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) {
border-color: rgba(59, 130, 246, 0.85); 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); 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; 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) { @media (prefers-color-scheme: light) {
.nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) { .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) {
color: #1e40af; color: #1e40af;
background: rgba(59, 130, 246, 0.12);
} }
} }
@@ -232,18 +213,89 @@ body.modal-open {
.stats-side-card { .stats-side-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: min(85vh, 52rem);
overflow: hidden;
} }
.stats-side-card h2 { .stats-side-card h2 {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
flex-shrink: 0;
} }
.stats-dl--compact { .stats-dl--compact {
flex: 1; flex: 0 0 auto;
margin: 0; margin: 0;
font-size: 0.88rem; 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 { .create-cluster-card {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -902,6 +954,48 @@ button.btn-danger:hover {
filter: brightness(1.12); 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 → карточки секций */ /* Страница «Документация»: Markdown → карточки секций */
.readme-doc-shell { .readme-doc-shell {
max-width: 52rem; max-width: 52rem;
@@ -925,10 +1019,10 @@ button.btn-danger:hover {
.readme-section-card { .readme-section-card {
margin: 0; margin: 0;
} }
.readme-section-card--head h2 { /* Заголовок секции (h2) и содержимое — в одной карточке: без лишнего зазора сверху у первого заголовка */
margin: 0; .readme-section-card.markdown-body > h1:first-child,
padding: 0; .readme-section-card.markdown-body > h2:first-child {
border-bottom: none; margin-top: 0;
} }
.readme-section-card.markdown-body { .readme-section-card.markdown-body {
font-size: 0.95rem; font-size: 0.95rem;
@@ -952,9 +1046,6 @@ button.btn-danger:hover {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.readme-section-card--head.markdown-body h2 {
margin: 0;
}
.readme-section-card.markdown-body h3 { .readme-section-card.markdown-body h3 {
font-size: 1.05rem; font-size: 1.05rem;
} }
+2 -1
View File
@@ -23,7 +23,8 @@
<span class="nav-title">{{ app_title }}</span> <span class="nav-title">{{ app_title }}</span>
</div> </div>
<nav class="nav-links" aria-label="Разделы"> <nav class="nav-links" aria-label="Разделы">
<a href="/" class="nav-link nav-pill nav-pill--home{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a> {# Панель подсвечивается только на дашборде; на странице документации — обычная пилюля, как Swagger/ReDoc. #}
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
<a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a> <a href="/documentation" class="nav-link nav-pill{% if nav_active|default('') == 'documentation' %} nav-pill--active{% endif %}">Документация</a>
<a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a> <a href="/docs" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_swagger">Swagger</a>
<a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a> <a href="/redoc" class="nav-link nav-pill nav-pill--ext" data-open-window="kind_redoc">ReDoc</a>
+1 -3
View File
@@ -5,9 +5,6 @@
{% block footer %} {% block footer %}
<div class="footer-inner"> <div class="footer-inner">
<p class="footer-line muted">
Документация API: <code>app/docs/api_routes.md</code> · том данных <code>clusters/</code> (не в Git)
</p>
<p class="footer-copyright"> <p class="footer-copyright">
© {{ app_title }} · © {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a> <a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
@@ -38,6 +35,7 @@
<aside class="card stats-side-card" aria-labelledby="stats-heading"> <aside class="card stats-side-card" aria-labelledby="stats-heading">
<h2 id="stats-heading">Статистика</h2> <h2 id="stats-heading">Статистика</h2>
<dl id="stats-dl" class="stats-dl stats-dl--compact" aria-label="Сводка по кластерам и заданиям"></dl> <dl id="stats-dl" class="stats-dl stats-dl--compact" aria-label="Сводка по кластерам и заданиям"></dl>
<div id="stats-resources" class="stats-resources" aria-live="polite"></div>
<p id="stats-err" class="msg hidden" role="alert"></p> <p id="stats-err" class="msg hidden" role="alert"></p>
</aside> </aside>
</div> </div>
+3
View File
@@ -5,6 +5,9 @@
{% block page_title %}Документация{% endblock %} {% block page_title %}Документация{% endblock %}
{% block content %} {% block content %}
<nav class="doc-breadcrumbs" id="doc-breadcrumbs" aria-label="Навигация по документации">
<ol class="doc-breadcrumbs__list" id="doc-breadcrumbs-list"></ol>
</nav>
<div class="readme-doc-shell" id="readme-doc-shell"> <div class="readme-doc-shell" id="readme-doc-shell">
<p id="readme-loading" class="muted readme-doc-loading">Загрузка…</p> <p id="readme-loading" class="muted readme-doc-loading">Загрузка…</p>
<p id="readme-error" class="msg hidden" role="alert"></p> <p id="readme-error" class="msg hidden" role="alert"></p>