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:
@@ -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`** — версию вводят вручную.
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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` может быть пустым.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user