Журнал: пагинация 30 записей на страницу, API offset/total_pages

- job_journal: collect_recent_journal_entries_page_sync(limit, offset)
- GET /api/v1/journal/recent: limit по умолчанию 30, offset, total, page, total_pages
- journal.html/js: навигация Первая/Назад/номера/Вперёд/Последняя, стили
- app/docs/api_routes.md: описание query и пример ответа
- Прочие изменения UI/API (аддоны, helm, job_journal в кластерах) в том же коммите
This commit is contained in:
Sergey Antropoff
2026-04-04 11:16:43 +03:00
parent 52538d9816
commit 17f6233fd7
22 changed files with 2811 additions and 4 deletions
+6
View File
@@ -8,6 +8,8 @@
FROM alpine:3.20 FROM alpine:3.20
ARG KIND_VERSION=0.24.0 ARG KIND_VERSION=0.24.0
# Helm для страницы «Аддоны» (установка ingress-nginx, prometheus-stack и т.д.)
ARG HELM_VERSION=v3.16.3
# Пусто — взять актуальный stable.txt; иначе явная версия, например v1.32.0 # Пусто — взять актуальный stable.txt; иначе явная версия, например v1.32.0
ARG KUBECTL_VERSION= ARG KUBECTL_VERSION=
# Платформа целевого образа (BuildKit подставляет amd64/arm64; иначе — uname внутри слоя) # Платформа целевого образа (BuildKit подставляет amd64/arm64; иначе — uname внутри слоя)
@@ -28,6 +30,10 @@ RUN apk add --no-cache python3 py3-pip docker-cli curl bash ca-certificates \
&& if [ -n "${KUBECTL_VERSION}" ]; then KVER="${KUBECTL_VERSION}"; else KVER=$(curl -Ls https://dl.k8s.io/release/stable.txt); fi \ && if [ -n "${KUBECTL_VERSION}" ]; then KVER="${KUBECTL_VERSION}"; else KVER=$(curl -Ls https://dl.k8s.io/release/stable.txt); fi \
&& curl -sSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KVER}/bin/linux/${KARCH}/kubectl" \ && curl -sSLo /usr/local/bin/kubectl "https://dl.k8s.io/release/${KVER}/bin/linux/${KARCH}/kubectl" \
&& chmod +x /usr/local/bin/kubectl \ && chmod +x /usr/local/bin/kubectl \
&& curl -sSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${KARCH}.tar.gz" | tar xz -C /tmp \
&& mv "/tmp/linux-${KARCH}/helm" /usr/local/bin/helm \
&& chmod +x /usr/local/bin/helm \
&& rm -rf "/tmp/linux-${KARCH}" \
&& pip3 install --no-cache-dir --break-system-packages -r /opt/kind-k8s/requirements.txt && pip3 install --no-cache-dir --break-system-packages -r /opt/kind-k8s/requirements.txt
COPY app/ /opt/kind-k8s/app/ COPY app/ /opt/kind-k8s/app/
+1
View File
@@ -26,6 +26,7 @@
- **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий. - **Статистика**: число кластеров в kind, локальных каталогов, сумма workers из `meta.json`, счётчики фоновых заданий.
- **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке). - **Создание кластера**: форма с подсказкой тегов `kindest/node` (`GET /api/v1/versions`), фоновое задание и опрос статуса (JSON в сворачиваемом блоке).
- **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление. - **Таблица кластеров**: признаки регистрации в kind и наличия kubeconfig, **старт** и **стоп** узлов (фоновые задания с тем же журналом, что и при создании), скачивание kubeconfig, просмотр узлов/подов в модальном окне, удаление.
- **Аддоны** (`/cluster-addons`): выбор кластера и установка/удаление через **Helm** в контейнере — **ingress-nginx** (версия чарта, NodePort 30080 для kind), **kube-prometheus-stack** (логин/пароль Grafana), **metrics-server**, **Istio + Kiali** (логин/пароль Kiali). Нужна **пересборка образа** после обновления Dockerfile (бинарник `helm`). Таймаут операций: **`KIND_K8S_HELM_TIMEOUT_SEC`** (по умолчанию 900 с).
- **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания). - **Последние задания**: история в памяти процесса (до **200** записей; после перезапуска контейнера сбрасывается); кнопка **«Очистить завершённые»** вызывает **`DELETE /api/v1/jobs`** (из памяти удаляются только завершённые задания).
- **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы). - **Автообновление** таблиц и плашки среды каждые ~3,5 с (fetch к API без перезагрузки страницы).
- При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды. - При активном задании — **прогресс-бар**, **журнал** (в т.ч. скачивание образа; для **docker** при поддержке CLI — **`pull --progress=plain`**, см. **`KIND_K8S_DOCKER_PULL_PLAIN`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
+267
View File
@@ -0,0 +1,267 @@
"""Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali.
Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Body, HTTPException
from core.cluster_lifecycle import validate_cluster_name
from core.helm_addons import (
HelmAddonError,
addon_status,
require_helm_binary,
install_ingress_nginx,
install_istio_and_kiali,
install_kube_prometheus_stack,
install_metrics_server,
uninstall_ingress_nginx,
uninstall_istio_and_kiali,
uninstall_kube_prometheus_stack,
uninstall_metrics_server,
)
from core.kind_guard import kind_cluster_lock
from models.schemas import (
ClusterAddonsStatusResponse,
HelmAddonApplyResponse,
IngressNginxInstallRequest,
IstioKialiInstallRequest,
KubePrometheusStackInstallRequest,
MetricsServerInstallRequest,
)
logger = logging.getLogger("kind_k8s.api.cluster_addons")
router = APIRouter(tags=["cluster-addons"])
_LOG_MAX = 24000
def _trim_log(s: str) -> str:
if len(s) <= _LOG_MAX:
return s
return s[: _LOG_MAX - 20] + "\n… (обрезано)"
def _map_helm_error(e: HelmAddonError) -> HTTPException:
msg = str(e)
if e.exit_code == 127:
return HTTPException(status_code=503, detail=msg)
return HTTPException(status_code=500, detail=msg)
@router.get(
"/clusters/{name}/addons/status",
response_model=ClusterAddonsStatusResponse,
summary="Статус Helm-аддонов в кластере",
)
async def get_cluster_addons_status(name: str) -> ClusterAddonsStatusResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
try:
require_helm_binary()
except HelmAddonError as e:
raise _map_helm_error(e) from e
try:
st = await asyncio.to_thread(addon_status, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
mesh = bool(st.get("istio_base") and st.get("istiod") and st.get("kiali"))
return ClusterAddonsStatusResponse(
cluster_name=n,
ingress_nginx=bool(st.get("ingress_nginx")),
kube_prometheus_stack=bool(st.get("kube_prometheus_stack")),
metrics_server=bool(st.get("metrics_server")),
istio_base=bool(st.get("istio_base")),
istiod=bool(st.get("istiod")),
kiali_server=bool(st.get("kiali")),
istio_mesh_ready=mesh,
)
@router.post(
"/clusters/{name}/addons/ingress-nginx",
response_model=HelmAddonApplyResponse,
summary="Установить ingress-nginx (Helm)",
)
async def post_install_ingress_nginx(
name: str,
body: IngressNginxInstallRequest | None = Body(default=None),
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_ingress_nginx, n, ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен ingress-nginx для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="ingress-nginx установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/ingress-nginx",
response_model=HelmAddonApplyResponse,
summary="Удалить ingress-nginx",
)
async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_ingress_nginx, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/kube-prometheus-stack",
response_model=HelmAddonApplyResponse,
summary="Установить kube-prometheus-stack (Prometheus Operator + Grafana)",
)
async def post_install_kube_prometheus_stack(
name: str,
body: KubePrometheusStackInstallRequest,
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
prom_ver: str | None = None
if body.chart_version and body.chart_version.strip():
prom_ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_kube_prometheus_stack,
n,
grafana_admin_user=body.grafana_admin_user.strip(),
grafana_admin_password=body.grafana_admin_password,
chart_version=prom_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен kube-prometheus-stack для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/kube-prometheus-stack",
response_model=HelmAddonApplyResponse,
summary="Удалить kube-prometheus-stack",
)
async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_kube_prometheus_stack, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/metrics-server",
response_model=HelmAddonApplyResponse,
summary="Установить metrics-server",
)
async def post_install_metrics_server(
name: str,
body: MetricsServerInstallRequest | None = Body(default=None),
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
ms_ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ms_ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_metrics_server, n, ms_ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен metrics-server для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="metrics-server установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/metrics-server",
response_model=HelmAddonApplyResponse,
summary="Удалить metrics-server",
)
async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_metrics_server, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/istio-kiali",
response_model=HelmAddonApplyResponse,
summary="Установить Istio (base+istiod) и Kiali с логином",
)
async def post_install_istio_kiali(
name: str,
body: IstioKialiInstallRequest,
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
istio_ver: str | None = None
if body.istio_chart_version and body.istio_chart_version.strip():
istio_ver = body.istio_chart_version.strip()
kiali_ver: str | None = None
if body.kiali_chart_version and body.kiali_chart_version.strip():
kiali_ver = body.kiali_chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_istio_and_kiali,
n,
kiali_username=body.kiali_username.strip(),
kiali_password=body.kiali_password,
istio_chart_version=istio_ver,
kiali_chart_version=kiali_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлены Istio+Kiali для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="Istio и Kiali установлены.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/istio-kiali",
response_model=HelmAddonApplyResponse,
summary="Удалить Kiali, istiod и istio-base",
)
async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_istio_and_kiali, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log))
+44
View File
@@ -0,0 +1,44 @@
"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException
from core.helm_addons import HelmAddonError, helm_addon_chart_versions_all, require_helm_binary
from models.schemas import HelmAddonChartVersionsResponse
logger = logging.getLogger("kind_k8s.api.helm_charts")
router = APIRouter(tags=["helm-charts"])
@router.get(
"/helm/chart-versions",
response_model=HelmAddonChartVersionsResponse,
summary="Версии чартов аддонов (для выпадающих списков)",
)
async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse:
"""
Выполняет ``helm repo update`` и для каждого аддона — ``helm search repo <chart> --versions``.
Результат кэшируется на ``KIND_K8S_HELM_VERSIONS_CACHE_SEC`` (по чарту).
Пустой список — если helm недоступен или репозиторий не ответил.
"""
try:
require_helm_binary()
except HelmAddonError as e:
logger.warning("helm недоступен для списка версий: %s", e)
raise HTTPException(status_code=503, detail=str(e)) from e
try:
data = await asyncio.to_thread(helm_addon_chart_versions_all)
except HelmAddonError as e:
logger.exception("Ошибка получения версий чартов")
raise HTTPException(status_code=500, detail=str(e)) from e
return HelmAddonChartVersionsResponse(**data)
+84
View File
@@ -0,0 +1,84 @@
"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Query
from core.cluster_lifecycle import validate_cluster_name
from core.job_journal import collect_recent_journal_entries_page_sync, read_cluster_journal_sync
from models.schemas import ClusterJournalResponse, JournalEntryModel, JournalRecentResponse
logger = logging.getLogger("kind_k8s.api.journal")
router = APIRouter(tags=["journal"])
def _journal_total_pages(total: int, limit: int) -> int:
if total <= 0 or limit <= 0:
return 0
return (total + limit - 1) // limit
@router.get(
"/journal/recent",
response_model=JournalRecentResponse,
summary="Страница журнала по всем кластерам (пагинация)",
)
async def get_journal_recent(
limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"),
offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"),
) -> JournalRecentResponse:
"""Сборка из ``clusters/*/journal/jobs_history.json``, новые первыми; ``total`` и ``total_pages`` для UI."""
rows, total = await asyncio.to_thread(
collect_recent_journal_entries_page_sync,
limit=limit,
offset=offset,
)
entries = [JournalEntryModel.model_validate(r) for r in rows]
total_pages = _journal_total_pages(total, limit)
if total <= 0:
page = 1
else:
page = min((offset // limit) + 1, total_pages)
page = max(1, page)
return JournalRecentResponse(
limit=limit,
offset=offset,
total=total,
page=page,
total_pages=total_pages,
entries=entries,
)
@router.get(
"/clusters/{name}/journal",
response_model=ClusterJournalResponse,
summary="Журнал заданий кластера (JSON с диска)",
)
async def get_cluster_journal(name: str) -> ClusterJournalResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
raw = await asyncio.to_thread(read_cluster_journal_sync, n)
if raw is None:
return ClusterJournalResponse(cluster_name=n, file_version=None, entries=[])
fv = raw.get("file_version") if isinstance(raw.get("file_version"), int) else None
ent = raw.get("entries")
if not isinstance(ent, list):
return ClusterJournalResponse(cluster_name=n, file_version=fv, entries=[])
entries: list[JournalEntryModel] = []
for item in ent:
if isinstance(item, dict):
try:
entries.append(JournalEntryModel.model_validate(item))
except Exception:
logger.debug("Пропуск некорректной записи журнала кластера «%s»", n)
return ClusterJournalResponse(cluster_name=n, file_version=fv, entries=entries)
+4 -1
View File
@@ -8,10 +8,13 @@ from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter
from api.v1.endpoints import clusters, docs_readme, health, versions from api.v1.endpoints import cluster_addons, clusters, docs_readme, health, helm_charts, journal, versions
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(health.router, prefix="") api_router.include_router(health.router, prefix="")
api_router.include_router(versions.router, prefix="") api_router.include_router(versions.router, prefix="")
api_router.include_router(docs_readme.router, prefix="") api_router.include_router(docs_readme.router, prefix="")
api_router.include_router(clusters.router, prefix="") api_router.include_router(clusters.router, prefix="")
api_router.include_router(cluster_addons.router, prefix="")
api_router.include_router(helm_charts.router, prefix="")
api_router.include_router(journal.router, prefix="")
+512
View File
@@ -0,0 +1,512 @@
"""Установка и удаление типовых Helm-чартов в выбранном кластере (kubeconfig с диска).
Используются бинарники ``helm`` и ``kubectl`` из PATH; ``KUBECONFIG`` — путь из
:func:`kubeconfig_patch.kubeconfig_path_for_container_kubectl` (как для kubectl в приложении).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
import yaml
from core.cluster_lifecycle import validate_cluster_name
from kind_k8s_paths import clusters_dir
from kubeconfig_patch import kubeconfig_path_for_container_kubectl
logger = logging.getLogger("kind_k8s.helm_addons")
# Официальные репозитории чартов (имя → URL).
HELM_REPOS: dict[str, str] = {
"ingress-nginx": "https://kubernetes.github.io/ingress-nginx",
"prometheus-community": "https://prometheus-community.github.io/helm-charts",
"metrics-server": "https://kubernetes-sigs.github.io/metrics-server/",
"istio": "https://istio-release.storage.googleapis.com/charts",
"kiali": "https://kiali.org/helm-charts",
}
# Ссылки для ``helm search repo <ref> --versions`` (ключи совпадают с полями API версий чартов).
HELM_ADDON_CHART_REFS: dict[str, str] = {
"ingress_nginx": "ingress-nginx/ingress-nginx",
"kube_prometheus_stack": "prometheus-community/kube-prometheus-stack",
"metrics_server": "metrics-server/metrics-server",
# Для base и istiod обычно берут одну версию чарта Istio.
"istio": "istio/base",
"kiali_server": "kiali/kiali-server",
}
_versions_cache: dict[str, tuple[float, list[str]]] = {}
_versions_lock = threading.Lock()
class HelmAddonError(Exception):
"""Ошибка helm/kubectl (сообщение для API)."""
def __init__(self, message: str, *, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code
def _helm_timeout_sec() -> int:
raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip()
try:
return max(60, min(int(raw), 7200))
except ValueError:
return 900
def _helm_versions_cache_sec() -> int:
"""TTL кэша списков версий (``helm search repo`` после ``repo update``)."""
raw = (os.environ.get("KIND_K8S_HELM_VERSIONS_CACHE_SEC") or "600").strip()
try:
return max(30, min(int(raw), 86400))
except ValueError:
return 600
def _helm_versions_max_list() -> int:
raw = (os.environ.get("KIND_K8S_HELM_VERSIONS_MAX") or "80").strip()
try:
return max(10, min(int(raw), 200))
except ValueError:
return 80
def search_repo_chart_versions(chart_ref: str) -> list[str]:
"""
Уникальные версии чарта (новые первыми), через ``helm search repo … --versions -o json``.
Перед вызовом рекомендуется :func:`helm_repo_ensure`; здесь кэш по ``chart_ref``.
"""
ref = chart_ref.strip()
if not ref:
return []
now = time.monotonic()
ttl = _helm_versions_cache_sec()
with _versions_lock:
hit = _versions_cache.get(ref)
if hit is not None:
ts, vers = hit
if now - ts < ttl:
logger.debug("Версии чарта %s из кэша (%s шт.)", ref, len(vers))
return list(vers)
require_helm_binary()
rc, out, err = _run_cmd(
["helm", "search", "repo", ref, "--versions", "-o", "json"],
timeout=180,
)
if rc != 0:
logger.warning("helm search repo %s: %s", ref, err or out or rc)
with _versions_lock:
_versions_cache[ref] = (now, [])
return []
versions: list[str] = []
seen: set[str] = set()
try:
rows = json.loads(out or "[]")
except json.JSONDecodeError:
rows = []
cap = _helm_versions_max_list()
if isinstance(rows, list):
for row in rows:
if not isinstance(row, dict):
continue
v = str(row.get("version", "")).strip()
if not v or v in seen:
continue
seen.add(v)
versions.append(v)
if len(versions) >= cap:
break
with _versions_lock:
_versions_cache[ref] = (now, versions)
logger.info("Получены версии для %s: %s записей", ref, len(versions))
return versions
def helm_addon_chart_versions_all() -> dict[str, list[str]]:
"""
Версии для всех аддонов UI (после обновления репозиториев — один раз).
Ключи: ``ingress_nginx``, ``kube_prometheus_stack``, ``metrics_server``, ``istio``, ``kiali_server``.
"""
helm_repo_ensure()
out: dict[str, list[str]] = {}
for key, cref in HELM_ADDON_CHART_REFS.items():
out[key] = search_repo_chart_versions(cref)
return out
def require_helm_binary() -> None:
if not shutil.which("helm"):
raise HelmAddonError("В образе не найден helm в PATH. Пересоберите образ с установленным Helm.", exit_code=127)
def require_kubectl_binary() -> None:
if not shutil.which("kubectl"):
raise HelmAddonError("Не найден kubectl в PATH.", exit_code=127)
def _kubeconfig_runtime_path(cluster_name: str) -> Path:
if not validate_cluster_name(cluster_name):
raise HelmAddonError("Некорректное имя кластера")
src = clusters_dir() / cluster_name / "kubeconfig"
if not src.is_file():
raise HelmAddonError(f"Нет kubeconfig: {src}")
return kubeconfig_path_for_container_kubectl(cluster_name=cluster_name, kube_source_path=src)
def _run_cmd(
cmd: list[str],
*,
timeout: int | None = None,
env: dict[str, str] | None = None,
) -> tuple[int, str, str]:
"""Выполнить команду; возвращает (код, stdout, stderr)."""
t = timeout if timeout is not None else _helm_timeout_sec()
logger.info("Выполнение: %s (timeout=%s)", " ".join(cmd[:12]) + ("" if len(cmd) > 12 else ""), t)
try:
p = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=t,
env={**os.environ, **(env or {})},
)
except subprocess.TimeoutExpired:
logger.warning("Таймаут команды: %s", cmd[0])
raise HelmAddonError(f"Превышено время ожидания ({t} с) для команды «{cmd[0]}»", exit_code=124) from None
out = (p.stdout or "").strip()
err = (p.stderr or "").strip()
return p.returncode, out, err
def helm_repo_ensure() -> tuple[int, str]:
"""Добавить недостающие репозитории и выполнить ``helm repo update``."""
require_helm_binary()
lines: list[str] = []
for name, url in HELM_REPOS.items():
rc, o, e = _run_cmd(["helm", "repo", "add", name, url], timeout=120)
if rc != 0 and "already exists" not in (e + o).lower():
raise HelmAddonError(f"helm repo add {name}: {e or o or rc}", exit_code=rc)
if o:
lines.append(o)
if e and "already exists" not in e.lower():
lines.append(e)
rc2, o2, e2 = _run_cmd(["helm", "repo", "update"], timeout=300)
if rc2 != 0:
raise HelmAddonError(f"helm repo update: {e2 or o2 or rc2}", exit_code=rc2)
lines.append(o2 or e2)
return 0, "\n".join(x for x in lines if x).strip()
def helm_list_all(cluster_name: str) -> list[dict[str, Any]]:
"""Список релизов ``helm list -A -o json``."""
kcfg = str(_kubeconfig_runtime_path(cluster_name))
rc, out, err = _run_cmd(
["helm", "list", "-A", "-o", "json", "--kubeconfig", kcfg],
timeout=120,
)
if rc != 0:
logger.warning("helm list: %s", err or out)
return []
if not out.strip():
return []
try:
data = json.loads(out)
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def addon_status(cluster_name: str) -> dict[str, bool]:
"""Какие аддоны установлены (по имени релиза и namespace)."""
lst = helm_list_all(cluster_name)
names_ns = {(str(x.get("name", "")), str(x.get("namespace", ""))) for x in lst if isinstance(x, dict)}
return {
"ingress_nginx": ("ingress-nginx", "ingress-nginx") in names_ns,
"kube_prometheus_stack": ("kube-prometheus-stack", "monitoring") in names_ns,
"metrics_server": ("metrics-server", "kube-system") in names_ns,
"istio_base": ("istio-base", "istio-system") in names_ns,
"istiod": ("istiod", "istio-system") in names_ns,
"kiali": ("kiali-server", "istio-system") in names_ns,
}
def _kubectl_apply_manifest(cluster_name: str, manifest: dict[str, Any]) -> tuple[int, str]:
require_kubectl_binary()
kcfg = str(_kubeconfig_runtime_path(cluster_name))
yml = yaml.safe_dump(manifest, allow_unicode=True, default_flow_style=False)
p = subprocess.run(
["kubectl", "apply", "-f", "-", "--kubeconfig", kcfg, "--request-timeout=120s"],
input=yml,
capture_output=True,
text=True,
timeout=180,
)
msg = ((p.stdout or "").strip() + "\n" + (p.stderr or "").strip()).strip()
return p.returncode, msg
def _ensure_namespace(cluster_name: str, namespace: str) -> None:
manifest = {
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": namespace},
}
rc, msg = _kubectl_apply_manifest(cluster_name, manifest)
if rc != 0:
raise HelmAddonError(f"Создание namespace {namespace}: {msg}", exit_code=rc)
def _helm_upgrade_install(
cluster_name: str,
release: str,
chart: str,
namespace: str,
*,
chart_version: str | None = None,
extra_args: list[str] | None = None,
values_file: Path | None = None,
) -> tuple[int, str, str]:
kcfg = str(_kubeconfig_runtime_path(cluster_name))
cmd = [
"helm",
"upgrade",
"--install",
release,
chart,
"-n",
namespace,
"--create-namespace",
"--kubeconfig",
kcfg,
"--wait",
"--timeout",
f"{_helm_timeout_sec()}s",
]
ver = (chart_version or "").strip()
if ver:
cmd.extend(["--version", ver])
if values_file is not None:
cmd.extend(["-f", str(values_file)])
if extra_args:
cmd.extend(extra_args)
return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60)
def _helm_uninstall(cluster_name: str, release: str, namespace: str) -> tuple[int, str, str]:
kcfg = str(_kubeconfig_runtime_path(cluster_name))
return _run_cmd(
["helm", "uninstall", release, "-n", namespace, "--kubeconfig", kcfg, "--wait", "--timeout", "600s"],
timeout=660,
)
# --- Установка / удаление по типам ---
def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple[bool, str]:
"""ingress-nginx с NodePort для kind."""
helm_repo_ensure()
extra: list[str] = [
"--set",
"controller.service.type=NodePort",
"--set",
"controller.service.nodePorts.http=30080",
]
rc, out, err = _helm_upgrade_install(
cluster_name,
"ingress-nginx",
"ingress-nginx/ingress-nginx",
"ingress-nginx",
chart_version=chart_version,
extra_args=extra,
)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "ingress-nginx установлен."
def uninstall_ingress_nginx(cluster_name: str) -> tuple[bool, str]:
rc, out, err = _helm_uninstall(cluster_name, "ingress-nginx", "ingress-nginx")
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0 and "not found" not in text.lower():
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "ingress-nginx удалён (или отсутствовал)."
def install_kube_prometheus_stack(
cluster_name: str,
*,
grafana_admin_user: str,
grafana_admin_password: str,
chart_version: str | None = None,
) -> tuple[bool, str]:
helm_repo_ensure()
values = {
"grafana": {
"adminUser": grafana_admin_user,
"adminPassword": grafana_admin_password,
}
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
try:
rc, out, err = _helm_upgrade_install(
cluster_name,
"kube-prometheus-stack",
"prometheus-community/kube-prometheus-stack",
"monitoring",
chart_version=chart_version,
values_file=tmp_path,
)
finally:
tmp_path.unlink(missing_ok=True)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "kube-prometheus-stack установлен."
def uninstall_kube_prometheus_stack(cluster_name: str) -> tuple[bool, str]:
rc, out, err = _helm_uninstall(cluster_name, "kube-prometheus-stack", "monitoring")
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0 and "not found" not in text.lower():
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "kube-prometheus-stack удалён (или отсутствовал)."
def install_metrics_server(cluster_name: str, chart_version: str | None = None) -> tuple[bool, str]:
helm_repo_ensure()
values = {
"args": [
"--kubelet-insecure-tls",
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP",
]
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False, encoding="utf-8") as tmp:
yaml.safe_dump(values, tmp, allow_unicode=True)
tmp_path = Path(tmp.name)
try:
rc, out, err = _helm_upgrade_install(
cluster_name,
"metrics-server",
"metrics-server/metrics-server",
"kube-system",
chart_version=chart_version,
values_file=tmp_path,
)
finally:
tmp_path.unlink(missing_ok=True)
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0:
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "metrics-server установлен."
def uninstall_metrics_server(cluster_name: str) -> tuple[bool, str]:
rc, out, err = _helm_uninstall(cluster_name, "metrics-server", "kube-system")
text = "\n".join(filter(None, [out, err])).strip()
if rc != 0 and "not found" not in text.lower():
raise HelmAddonError(text or f"helm exit {rc}", exit_code=rc)
return True, text or "metrics-server удалён (или отсутствовал)."
def install_istio_and_kiali(
cluster_name: str,
*,
kiali_username: str,
kiali_password: str,
istio_chart_version: str | None = None,
kiali_chart_version: str | None = None,
) -> tuple[bool, str]:
"""Istio base + istiod + секрет входа Kiali + chart kiali-server (strategy=login)."""
helm_repo_ensure()
_ensure_namespace(cluster_name, "istio-system")
log_parts: list[str] = []
istio_ver = (istio_chart_version or "").strip() or None
kiali_ver = (kiali_chart_version or "").strip() or None
rc, out, err = _helm_upgrade_install(
cluster_name,
"istio-base",
"istio/base",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(out or err)
if rc != 0:
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
rc2, o2, e2 = _helm_upgrade_install(
cluster_name,
"istiod",
"istio/istiod",
"istio-system",
chart_version=istio_ver,
)
log_parts.append(o2 or e2)
if rc2 != 0:
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
# Секрет для стратегии login (ключи username / passphrase — ожидание Kiali).
secret = {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {"name": "kiali", "namespace": "istio-system"},
"type": "Opaque",
"stringData": {
"username": kiali_username,
"passphrase": kiali_password,
},
}
rcs, msgs = _kubectl_apply_manifest(cluster_name, secret)
if rcs != 0:
raise HelmAddonError(f"Secret kiali: {msgs}", exit_code=rcs)
log_parts.append(msgs)
rc3, o3, e3 = _helm_upgrade_install(
cluster_name,
"kiali-server",
"kiali/kiali-server",
"istio-system",
chart_version=kiali_ver,
extra_args=["--set", "auth.strategy=login"],
)
log_parts.append(o3 or e3)
if rc3 != 0:
raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3)
return True, "\n\n".join(x for x in log_parts if x).strip() or "Istio и Kiali установлены."
def uninstall_istio_and_kiali(cluster_name: str) -> tuple[bool, str]:
"""Обратный порядок: Kiali → istiod → istio-base."""
parts: list[str] = []
for rel in ("kiali-server", "istiod", "istio-base"):
rc, out, err = _helm_uninstall(cluster_name, rel, "istio-system")
line = "\n".join(filter(None, [out, err])).strip()
if line:
parts.append(line)
if rc != 0 and "not found" not in line.lower() and "release: not found" not in line.lower():
raise HelmAddonError(line or f"helm uninstall {rel} код {rc}", exit_code=rc)
return True, "\n".join(parts).strip() or "Istio/Kiali удалены (или отсутствовали)."
+205
View File
@@ -0,0 +1,205 @@
"""История завершённых заданий (create/start/stop и т.д.) в ``clusters/<имя>/journal/jobs_history.json``.
Дополняет глобальный ``kind_k8s_jobs.json``: записи дублируются в каталог кластера для архива на томе.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import json
import logging
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from core.cluster_lifecycle import validate_cluster_name
from kind_k8s_paths import clusters_dir
logger = logging.getLogger("kind_k8s.job_journal")
JOURNAL_SUBDIR = "journal"
JOBS_HISTORY_FILENAME = "jobs_history.json"
JOURNAL_FILE_VERSION = 1
# Блокировки по имени кластера (один процесс uvicorn).
_locks_guard = threading.Lock()
_cluster_locks: dict[str, threading.Lock] = {}
def _cluster_lock(name: str) -> threading.Lock:
with _locks_guard:
if name not in _cluster_locks:
_cluster_locks[name] = threading.Lock()
return _cluster_locks[name]
def _max_entries() -> int:
raw = (os.environ.get("KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES") or "500").strip()
try:
return max(20, min(int(raw), 5000))
except ValueError:
return 500
def _max_log_lines() -> int:
raw = (os.environ.get("KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES") or "2000").strip()
try:
return max(50, min(int(raw), 20000))
except ValueError:
return 2000
def journal_file_path(cluster_name: str) -> Path:
"""Путь к JSON с историей заданий кластера."""
return clusters_dir() / cluster_name.strip() / JOURNAL_SUBDIR / JOBS_HISTORY_FILENAME
def append_job_from_record_sync(
*,
job_id: str,
kind: str,
cluster_name: str | None,
status: str,
message: str | None,
created_at_utc: str,
log_lines: list[str],
result: dict[str, Any] | None,
) -> None:
"""
Добавить запись о завершённом задании в журнал каталога кластера.
Вызывается из потока после ``job_store`` (to_thread); без паролей в result.
"""
if not cluster_name or not str(cluster_name).strip():
return
name = str(cluster_name).strip()
if not validate_cluster_name(name):
logger.debug("Журнал кластера: пропуск, некорректное имя «%s»", name)
return
cdir = clusters_dir() / name
if not cdir.is_dir():
logger.debug("Журнал кластера: каталог %s отсутствует", cdir)
return
jdir = cdir / JOURNAL_SUBDIR
finished = datetime.now(timezone.utc).isoformat()
cap_lines = _max_log_lines()
lines = list(log_lines)[-cap_lines:] if log_lines else []
entry: dict[str, Any] = {
"version": JOURNAL_FILE_VERSION,
"job_id": job_id,
"kind": kind,
"cluster_name": name,
"status": status,
"message": message,
"created_at_utc": created_at_utc,
"finished_at_utc": finished,
"log_lines": lines,
"result": result,
}
path = jdir / JOBS_HISTORY_FILENAME
lock = _cluster_lock(name)
with lock:
jdir.mkdir(parents=True, exist_ok=True)
entries: list[dict[str, Any]] = []
if path.is_file():
try:
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
if isinstance(data, dict) and isinstance(data.get("entries"), list):
entries = [e for e in data["entries"] if isinstance(e, dict)]
except (OSError, json.JSONDecodeError) as e:
logger.warning("Журнал %s: не прочитан, создаём заново: %s", path, e)
entries = []
entries.insert(0, entry)
max_e = _max_entries()
if len(entries) > max_e:
entries = entries[:max_e]
payload = {"file_version": JOURNAL_FILE_VERSION, "entries": entries}
tmp = path.with_suffix(path.suffix + ".tmp")
try:
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
tmp.replace(path)
logger.info("Журнал кластера «%s»: запись %s (%s)", name, job_id, kind)
except OSError as e:
logger.warning("Журнал %s: запись не удалась: %s", path, e)
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
def read_cluster_journal_sync(cluster_name: str) -> dict[str, Any] | None:
"""Прочитать файл журнала кластера или ``None``."""
n = cluster_name.strip()
if not validate_cluster_name(n):
return None
path = journal_file_path(n)
if not path.is_file():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]:
"""Все записи из журналов кластеров, отсортированные по времени (новые первыми)."""
root = clusters_dir()
if not root.is_dir():
return []
flat: list[tuple[str, dict[str, Any]]] = []
for sub in sorted(root.iterdir()):
if not sub.is_dir():
continue
name = sub.name
if not validate_cluster_name(name):
continue
path = sub / JOURNAL_SUBDIR / JOBS_HISTORY_FILENAME
if not path.is_file():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
entries = data.get("entries") if isinstance(data, dict) else None
if not isinstance(entries, list):
continue
for e in entries:
if isinstance(e, dict):
flat.append((name, e))
def sort_key(item: tuple[str, dict[str, Any]]) -> str:
return str(item[1].get("finished_at_utc") or item[1].get("created_at_utc") or "")
flat.sort(key=sort_key, reverse=True)
return flat
def collect_recent_journal_entries_page_sync(*, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
"""
Страница записей из всех ``clusters/*/journal/jobs_history.json``, новые первыми.
В каждую запись добавляется поле ``source_cluster`` (имя каталога).
Возвращает ``(страница записей, общее число записей)``.
"""
lim = max(1, min(limit, 100))
off = max(0, offset)
flat = _collect_all_journal_rows_sorted_sync()
total = len(flat)
chunk = flat[off : off + lim]
out: list[dict[str, Any]] = []
for cluster_dir_name, e in chunk:
row = dict(e)
row["source_cluster"] = cluster_dir_name
out.append(row)
return out, total
+53
View File
@@ -386,34 +386,87 @@ class JobStore:
async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None: async def set_success(self, job_id: str, *, result: dict[str, Any] | None = None, message: str | None = None) -> None:
logs = take_logs_finalize_sync(job_id) logs = take_logs_finalize_sync(job_id)
snapshot: dict[str, Any] | None = None
async with self._lock: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "success" self._jobs[job_id].status = "success"
self._jobs[job_id].result = result self._jobs[job_id].result = result
self._jobs[job_id].message = message self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs self._jobs[job_id].log_lines = logs
r = self._jobs[job_id]
snapshot = {
"job_id": r.job_id,
"kind": r.kind,
"cluster_name": r.cluster_name,
"status": r.status,
"message": r.message,
"created_at_utc": r.created_at_utc,
"log_lines": list(r.log_lines),
"result": dict(r.result) if r.result else None,
}
set_progress_sync(job_id, "Готово", 100) set_progress_sync(job_id, "Готово", 100)
await self._persist_async() await self._persist_async()
if snapshot and snapshot.get("cluster_name"):
from core.job_journal import append_job_from_record_sync
await asyncio.to_thread(append_job_from_record_sync, **snapshot)
async def set_failed(self, job_id: str, message: str) -> None: async def set_failed(self, job_id: str, message: str) -> None:
logs = take_logs_finalize_sync(job_id) logs = take_logs_finalize_sync(job_id)
# Чтобы в kind_k8s_jobs.json и в UI всегда был хотя бы текст ошибки (ранний сбой без stdout).
if not logs:
logs = [f"[ошибка] {message}"]
snapshot: dict[str, Any] | None = None
async with self._lock: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "failed" self._jobs[job_id].status = "failed"
self._jobs[job_id].message = message self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs self._jobs[job_id].log_lines = logs
r = self._jobs[job_id]
snapshot = {
"job_id": r.job_id,
"kind": r.kind,
"cluster_name": r.cluster_name,
"status": r.status,
"message": r.message,
"created_at_utc": r.created_at_utc,
"log_lines": list(r.log_lines),
"result": dict(r.result) if r.result else None,
}
logger.warning("Задание %s завершилось ошибкой: %s", job_id, message) logger.warning("Задание %s завершилось ошибкой: %s", job_id, message)
await self._persist_async() await self._persist_async()
if snapshot and snapshot.get("cluster_name"):
from core.job_journal import append_job_from_record_sync
await asyncio.to_thread(append_job_from_record_sync, **snapshot)
async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None: async def set_cancelled(self, job_id: str, message: str = "Операция отменена") -> None:
logs = take_logs_finalize_sync(job_id) logs = take_logs_finalize_sync(job_id)
if not logs:
logs = [f"[отмена] {message}"]
snapshot: dict[str, Any] | None = None
async with self._lock: async with self._lock:
if job_id in self._jobs: if job_id in self._jobs:
self._jobs[job_id].status = "cancelled" self._jobs[job_id].status = "cancelled"
self._jobs[job_id].message = message self._jobs[job_id].message = message
self._jobs[job_id].log_lines = logs self._jobs[job_id].log_lines = logs
r = self._jobs[job_id]
snapshot = {
"job_id": r.job_id,
"kind": r.kind,
"cluster_name": r.cluster_name,
"status": r.status,
"message": r.message,
"created_at_utc": r.created_at_utc,
"log_lines": list(r.log_lines),
"result": dict(r.result) if r.result else None,
}
logger.info("Задание %s отменено: %s", job_id, message) logger.info("Задание %s отменено: %s", job_id, message)
await self._persist_async() await self._persist_async()
if snapshot and snapshot.get("cluster_name"):
from core.job_journal import append_job_from_record_sync
await asyncio.to_thread(append_job_from_record_sync, **snapshot)
async def get(self, job_id: str) -> JobRecord | None: async def get(self, job_id: str) -> JobRecord | None:
async with self._lock: async with self._lock:
+106 -1
View File
@@ -22,11 +22,13 @@
| `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. | | `GET /` | HTML-панель: единая карточка «панель + среда», статистика, **ссылка с имени кластера на** `GET /cluster/<имя>` (сводка ресурсов, kubectl, действия), **старт/стоп** кластера с тем же журналом (фоновые **POST …/start** и **…/stop**), модалка узлов/подов; шапка — пилюли, Swagger / ReDoc / Health в отдельных окнах. |
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). | | `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). |
| `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. | | `GET /cluster/{name}/edit` | HTML **редактирование** сохранённого `kind-config.yaml` и полей `meta.json` (простой режим: тег/workers; расширенный: полный YAML kind Cluster). Сохранение — **`PUT /api/v1/clusters/{name}/config`**. |
| `GET /cluster-addons` | HTML **Аддоны**: выбор кластера, **`GET /api/v1/helm/chart-versions`** для выпадающих списков версий чартов (или «Последняя»), установка/удаление Helm-релизов. |
| `GET /journal` | HTML **Журнал**: **`GET /api/v1/journal/recent`** с пагинацией (**30** записей на страницу), навигация по страницам внизу таблицы. |
| `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path`**`GET /api/v1/docs/readme`**, с `?path=app/docs/…`**`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **H2****одна карточка** (заголовок h2 и содержимое до следующего h2 вместе). Заголовок вкладки браузера: **«Документация — …»** + текст **первого H1** документа + имя приложения (`KIND_K8S_APP_TITLE` на `body`). В шапке на этой странице активна только **Документация**; **Панель** как обычная пилюля (на дашборде активна **Панель**). Путь к README: `KIND_K8S_README_PATH` или `README.md` рядом с `app/`; в образе — `/opt/kind-k8s/README.md`. | | `GET /documentation` | HTML-оболочка; **`documentation.js`**: без `path`**`GET /api/v1/docs/readme`**, с `?path=app/docs/…`**`GET /api/v1/docs/file`**; разбор Markdown из **`/static/js/vendor/`** (marked, DOMPurify). Каждая секция по **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`). |
Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/documentation.html` (README). Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (контент панели), `app/templates/cluster_detail.html` (страница кластера), `app/templates/cluster_edit.html` (редактирование конфигурации), `app/templates/cluster_addons.html` (Helm-аддоны), `app/templates/journal.html` (журнал заданий), `app/templates/documentation.html` (README).
**kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** — **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**. **kubectl на хосте не обязателен:** бинарник есть в образе; узлы и поды доступны через API и веб-UI. Внутри контейнера веб-приложения `kubectl` использует временный kubeconfig с `server` через **`host.docker.internal:<порт>`** (см. `kubeconfig_patch.py`, `extra_hosts` в compose). Скачивание для хоста — **`GET …/kubeconfig`** (файл **`kubeconfig.host`** при наличии). Для консоли: **`make docker kubectl CLUSTER=<имя>`** — **`/work/clusters/<имя>/kubeconfig`**; при сбое попробуйте kubectl **с хоста** с **`clusters/<имя>/kubeconfig.host`**. Перезапуск веб-сервиса: **`make docker restart`**. Подробности — **README.md**.
@@ -54,6 +56,18 @@
| GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) | | GET | `/api/v1/clusters/{name}/overview` | Сводка для страницы UI: метрики узлов, агрегаты, блоки **`k8s_*`** (JSON из `kubectl get … -o json`) |
| POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта | | POST | `/api/v1/clusters/{name}/pods/restart` | Удалить под (`kubectl delete pod`) для мягкого рестарта |
| DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` | | DELETE | `/api/v1/clusters/{name}` | Удалить кластер и данные в `clusters/` |
| GET | `/api/v1/clusters/{name}/addons/status` | Статус Helm-релизов (ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali); нужен **helm** в образе |
| GET | `/api/v1/helm/chart-versions` | Версии чартов для UI (после `helm repo update`); кэш **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`**; поля: `ingress_nginx`, `kube_prometheus_stack`, `metrics_server`, `istio`, `kiali_server` |
| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx** (NodePort 30080); тело: `{ "chart_version": "опционально" }` (пусто = последняя) |
| DELETE | `/api/v1/clusters/{name}/addons/ingress-nginx` | Удалить ingress-nginx |
| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально **`chart_version`** |
| DELETE | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Удалить стек |
| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server** (kind: `--kubelet-insecure-tls`); тело опционально: `{ "chart_version": "…" }` или `{}` |
| DELETE | `/api/v1/clusters/{name}/addons/metrics-server` | Удалить metrics-server |
| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: `kiali_username`, `kiali_password` (≥8), опционально **`istio_chart_version`**, **`kiali_chart_version`** |
| DELETE | `/api/v1/clusters/{name}/addons/istio-kiali` | Удалить kiali-server, istiod, istio-base |
| GET | `/api/v1/journal/recent` | Пагинация журнала по всем кластерам: query **`limit`** (по умолчанию **30**, макс. 100), **`offset`** (по умолчанию **0**); в ответе **`total`**, **`total_pages`**, **`page`** |
| GET | `/api/v1/clusters/{name}/journal` | Полный файл журнала кластера (массив `entries` или пусто, если файла нет) |
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) | | GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) | | GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
| DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) | | DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
@@ -64,6 +78,7 @@
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется. - Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`). - В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
- Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**. - Снимок заданий сохраняется в JSON в каталоге **`clusters/`** (файл **`kind_k8s_jobs.json`** на томе с хоста) — после перезапуска контейнера список восстанавливается. Записи в статусе **queued**/**running** при старте помечаются как **failed** (процесс уже не выполняется). Путь переопределяется переменной **`KIND_K8S_JOBS_JSON`**.
- При **завершении** задания (успех / ошибка / отмена), если указано **`cluster_name`** и существует каталог **`clusters/<имя>/`**, в **`clusters/<имя>/journal/jobs_history.json`** дозаписывается запись с хвостом лога и **`result`** (без секретов). Лимиты: **`KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES`**, **`KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES`**. Чтение: **`GET /api/v1/journal/recent`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`**.
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI). - Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
- В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания. - В ответе задания поля **`progress_stage`** (текст этапа) и **`progress_percent`** (0–100) обновляются во время создания.
- В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**). - В **GET /api/v1/jobs** (список) поле **`progress_log`** всегда **пустой массив** — меньше трафика; полный хвост — в **GET /api/v1/jobs/{job_id}** (лимит строк: `KIND_K8S_JOB_API_LOG_MAX_LINES`, по умолчанию **5000**).
@@ -271,6 +286,96 @@ Accept: text/markdown
--- ---
## GET /api/v1/helm/chart-versions
Список версий чартов для выпадающих списков на **`/cluster-addons`**. Выполняется **`helm repo update`** и для каждого чарта — **`helm search repo <chart> --versions -o json`**. Результаты кэшируются на **`KIND_K8S_HELM_VERSIONS_CACHE_SEC`** (по умолчанию **600** с) отдельно для каждого рефа чарта. Длина списка ограничена **`KIND_K8S_HELM_VERSIONS_MAX`** (по умолчанию **80**).
**Пример ответа 200:**
```json
{
"ingress_nginx": ["4.14.0", "4.13.3", "4.12.1"],
"kube_prometheus_stack": ["69.0.0", "68.4.3"],
"metrics_server": ["3.12.2", "3.12.1"],
"istio": ["1.24.3", "1.24.2"],
"kiali_server": ["2.1.0", "2.0.0"]
}
```
При недоступном **helm****503** с текстом ошибки.
---
## GET /api/v1/journal/recent
Объединение записей из всех **`clusters/*/journal/jobs_history.json`**, сортировка по **`finished_at_utc`** (новые первыми). В каждой записи добавляется **`source_cluster`** — имя каталога кластера.
**Query:**
| Параметр | Описание |
|----------|----------|
| `limit` | Записей на страницу, **1100**, по умолчанию **30** (как в веб-интерфейсе **`/journal`**) |
| `offset` | Смещение от начала списка, **≥ 0**, по умолчанию **0** (первая страница: `offset=0`, вторая при `limit=30`: `offset=30`) |
**Поля ответа:** **`limit`**, **`offset`**, **`total`** (всего записей), **`page`** (текущая страница с 1), **`total_pages`**, **`entries`** (фрагмент текущей страницы).
**Пример ответа 200 (первая страница, до 30 записей):**
```json
{
"limit": 30,
"offset": 0,
"total": 75,
"page": 1,
"total_pages": 3,
"entries": [
{
"job_id": "a1b2c3",
"kind": "create_cluster",
"cluster_name": "dev",
"source_cluster": "dev",
"status": "success",
"message": null,
"created_at_utc": "2026-04-04T11:58:00+00:00",
"finished_at_utc": "2026-04-04T12:05:00+00:00",
"log_lines": ["[1] …", "[2] …"],
"result": { "cluster_name": "dev" }
}
]
}
```
---
## GET /api/v1/clusters/{name}/journal
Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**.
**Пример ответа 200:**
```json
{
"cluster_name": "dev",
"file_version": 1,
"entries": [
{
"version": 1,
"job_id": "a1b2c3",
"kind": "stop_containers",
"cluster_name": "dev",
"status": "success",
"message": null,
"created_at_utc": "2026-04-04T10:00:00+00:00",
"finished_at_utc": "2026-04-04T10:00:15+00:00",
"log_lines": [],
"result": null
}
]
}
```
---
## GET /api/v1/jobs ## GET /api/v1/jobs
Список последних фоновых заданий, от новых к старым. Поле **`progress_log`** в каждом элементе **пустое** — используйте **GET /api/v1/jobs/{job_id}** для журнала. Список последних фоновых заданий, от новых к старым. Поле **`progress_log`** в каждом элементе **пустое** — используйте **GET /api/v1/jobs/{job_id}** для журнала.
+30
View File
@@ -120,6 +120,36 @@ async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse
) )
@app.get("/cluster-addons", response_class=HTMLResponse, summary="Helm-аддоны для кластера")
async def cluster_addons_page(request: Request) -> HTMLResponse:
"""Выбор кластера и установка/удаление ingress-nginx, prometheus-stack, metrics-server, Istio+Kiali."""
if not _templates_dir.is_dir():
return HTMLResponse(
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
status_code=500,
)
return templates.TemplateResponse(
request,
"cluster_addons.html",
{"app_title": settings.app_title, "nav_active": "cluster_addons"},
)
@app.get("/journal", response_class=HTMLResponse, summary="Журнал завершённых заданий по кластерам")
async def journal_page(request: Request) -> HTMLResponse:
"""Агрегат записей из ``clusters/<имя>/journal/jobs_history.json`` (создание/старт/стоп и др.)."""
if not _templates_dir.is_dir():
return HTMLResponse(
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
status_code=500,
)
return templates.TemplateResponse(
request,
"journal.html",
{"app_title": settings.app_title, "nav_active": "journal"},
)
@app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания") @app.get("/cluster-create", response_class=HTMLResponse, summary="Создание кластера и задания")
async def cluster_create_page(request: Request) -> HTMLResponse: async def cluster_create_page(request: Request) -> HTMLResponse:
"""Форма создания кластера, прогресс и список последних заданий.""" """Форма создания кластера, прогресс и список последних заданий."""
+125
View File
@@ -283,3 +283,128 @@ class ClusterConfigUpdateResponse(BaseModel):
registered_in_kind: bool registered_in_kind: bool
kind_note: str kind_note: str
summary: ClusterSummary = Field(description="Актуальная сводка для строки таблицы / панели") summary: ClusterSummary = Field(description="Актуальная сводка для строки таблицы / панели")
# --- Helm-аддоны (страница «Дополнения кластера») ---
class ClusterAddonsStatusResponse(BaseModel):
"""Какие релизы Helm обнаружены для кластера (по helm list -A)."""
cluster_name: str
ingress_nginx: bool = Field(description="Релиз ingress-nginx в ns ingress-nginx")
kube_prometheus_stack: bool = Field(description="Релиз kube-prometheus-stack в ns monitoring")
metrics_server: bool = Field(description="Релиз metrics-server в ns kube-system")
istio_base: bool
istiod: bool
kiali_server: bool
istio_mesh_ready: bool = Field(
description="Установлены istio-base, istiod и kiali-server в istio-system",
)
class HelmAddonApplyResponse(BaseModel):
"""Результат синхронной установки/удаления чарта."""
ok: bool = True
message: str
log: str = Field(default="", description="Сводный вывод helm/kubectl")
class IngressNginxInstallRequest(BaseModel):
"""Тело POST …/addons/ingress-nginx — установка ingress-nginx."""
chart_version: str | None = Field(
default=None,
max_length=64,
description="Версия чарта; пусто — последняя из репозитория",
)
class KubePrometheusStackInstallRequest(BaseModel):
"""Тело POST …/addons/kube-prometheus-stack."""
grafana_admin_user: str = Field(default="admin", min_length=1, max_length=256)
grafana_admin_password: str = Field(..., min_length=8, max_length=256)
chart_version: str | None = Field(
default=None,
max_length=64,
description="Версия чарта kube-prometheus-stack; пусто — последняя",
)
class MetricsServerInstallRequest(BaseModel):
"""Тело POST …/addons/metrics-server (все поля необязательны)."""
chart_version: str | None = Field(
default=None,
max_length=64,
description="Версия чарта metrics-server; пусто — последняя",
)
class IstioKialiInstallRequest(BaseModel):
"""Тело POST …/addons/istio-kiali — Istio + Kiali со входом по логину."""
kiali_username: str = Field(default="kiali-admin", min_length=1, max_length=256)
kiali_password: str = Field(..., min_length=8, max_length=256)
istio_chart_version: str | None = Field(
default=None,
max_length=64,
description="Версия чартов istio/base и istio/istiod; пусто — последняя",
)
kiali_chart_version: str | None = Field(
default=None,
max_length=64,
description="Версия чарта kiali/kiali-server; пусто — последняя",
)
class HelmAddonChartVersionsResponse(BaseModel):
"""Список версий чартов для выпадающих списков на странице аддонов (после helm repo update)."""
ingress_nginx: list[str] = Field(default_factory=list)
kube_prometheus_stack: list[str] = Field(default_factory=list)
metrics_server: list[str] = Field(default_factory=list)
istio: list[str] = Field(default_factory=list, description="Версии istio/base (для base+istiod)")
kiali_server: list[str] = Field(default_factory=list)
class JournalEntryModel(BaseModel):
"""Одна запись из ``clusters/<имя>/journal/jobs_history.json``."""
model_config = {"extra": "allow"}
version: int | None = Field(default=None, description="Версия схемы записи в файле")
job_id: str = ""
kind: str = ""
cluster_name: str = ""
status: str = ""
message: str | None = None
created_at_utc: str = ""
finished_at_utc: str | None = None
log_lines: list[str] = Field(default_factory=list)
result: dict[str, Any] | None = None
source_cluster: str | None = Field(
default=None,
description="Имя каталога кластера в ответе GET /journal/recent",
)
class ClusterJournalResponse(BaseModel):
"""Содержимое журнала одного кластера."""
cluster_name: str
file_version: int | None = None
entries: list[JournalEntryModel] = Field(default_factory=list)
class JournalRecentResponse(BaseModel):
"""Страница агрегированного журнала (все ``clusters/*/journal/jobs_history.json``)."""
limit: int = Field(description="Размер страницы (записей за запрос)")
offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)")
total: int = Field(ge=0, description="Всего записей во всех журналах")
page: int = Field(ge=1, description="Номер текущей страницы с 1")
total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет")
entries: list[JournalEntryModel] = Field(default_factory=list)
+305
View File
@@ -0,0 +1,305 @@
/**
* Страница Helm-аддонов: GET /api/v1/clusters, статус и POST/DELETE /addons/*.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
if ((body.getAttribute("data-dashboard-mode") || "") !== "addons") return;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/**
* @param {string} path
* @param {RequestInit} [opts]
*/
async function api(path, opts) {
const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, opts);
const text = await r.text();
var data;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
data = { raw: text };
}
if (!r.ok) {
const msg =
typeof data.detail === "string"
? data.detail
: Array.isArray(data.detail)
? data.detail.map(function (x) { return x.msg || JSON.stringify(x); }).join("; ")
: text || r.statusText;
const err = new Error(msg);
err.status = r.status;
throw err;
}
return data;
}
const sel = document.getElementById("addons-cluster-select");
const statusBar = document.getElementById("addons-status-bar");
const logPre = document.getElementById("addons-log-pre");
const overlay = document.getElementById("addons-busy-overlay");
const busyLabel = document.getElementById("addons-busy-label");
function getClusterName() {
if (!sel || !sel.value) return "";
return String(sel.value).trim();
}
function setBusy(show, label) {
if (!overlay) return;
overlay.classList.toggle("hidden", !show);
overlay.setAttribute("aria-busy", show ? "true" : "false");
overlay.setAttribute("aria-hidden", show ? "false" : "true");
document.body.classList.toggle("cluster-addons-busy", !!show);
if (busyLabel && label) busyLabel.textContent = label;
}
/** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */
function hideAddonsPageLoadingOverlay() {
const el = document.getElementById("addons-page-loading-overlay");
if (!el) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("cluster-addons-page-loading");
}
function appendLog(line) {
if (!logPre) return;
logPre.textContent = (logPre.textContent ? logPre.textContent + "\n" : "") + line;
logPre.scrollTop = logPre.scrollHeight;
}
function formatStatus(st) {
if (!st) return "";
const parts = [
"ingress-nginx: " + (st.ingress_nginx ? "да" : "нет"),
"prometheus-stack: " + (st.kube_prometheus_stack ? "да" : "нет"),
"metrics-server: " + (st.metrics_server ? "да" : "нет"),
"Istio+Kiali: " + (st.istio_mesh_ready ? "да" : "нет"),
];
return parts.join(" · ");
}
/**
* Заполнить выпадающий список версиями чарта; первая опция «последняя».
* @param {HTMLSelectElement | null} selectEl
* @param {string[] | undefined} versions
*/
function fillVersionSelect(selectEl, versions) {
if (!selectEl) return;
const cur = selectEl.value;
selectEl.innerHTML = "";
const o0 = document.createElement("option");
o0.value = "";
o0.textContent = "Последняя (из репозитория)";
selectEl.appendChild(o0);
(versions || []).forEach(function (v) {
if (!v) return;
const o = document.createElement("option");
o.value = v;
o.textContent = v;
selectEl.appendChild(o);
});
if (cur) {
for (let i = 0; i < selectEl.options.length; i++) {
if (selectEl.options[i].value === cur) {
selectEl.value = cur;
break;
}
}
}
}
async function loadChartVersions() {
try {
const d = await api("/helm/chart-versions");
fillVersionSelect(document.getElementById("addons-ing-version-select"), d.ingress_nginx);
fillVersionSelect(document.getElementById("addons-prom-version-select"), d.kube_prometheus_stack);
fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server);
fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio);
fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server);
} catch (e) {
appendLog("Список версий чартов недоступен: " + e.message);
}
}
async function loadClusters() {
if (!sel) return;
try {
const rows = await api("/clusters");
sel.innerHTML = "";
const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; });
if (!withKube.length) {
const o = document.createElement("option");
o.value = "";
o.textContent = "Нет кластеров с kubeconfig на диске";
sel.appendChild(o);
return;
}
const o0 = document.createElement("option");
o0.value = "";
o0.textContent = "— выберите кластер —";
sel.appendChild(o0);
withKube.forEach(function (c) {
const o = document.createElement("option");
o.value = c.name;
o.textContent = c.name + (c.kind_nodes_running ? "" : " (узлы остановлены)");
sel.appendChild(o);
});
} catch (e) {
sel.innerHTML = "";
const o = document.createElement("option");
o.value = "";
o.textContent = "Ошибка загрузки списка";
sel.appendChild(o);
appendLog("Ошибка GET /clusters: " + e.message);
}
}
async function refreshStatus() {
const n = getClusterName();
if (!statusBar) return;
if (!n) {
statusBar.textContent = "Выберите кластер для просмотра статуса аддонов.";
return;
}
try {
const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status");
statusBar.textContent = formatStatus(st);
} catch (e) {
statusBar.textContent = "Статус недоступен: " + e.message;
}
}
async function runInstall(endpoint, payload) {
const n = getClusterName();
if (!n) {
alert("Выберите кластер.");
return;
}
setBusy(true, "Установка (Helm), подождите…");
if (logPre) logPre.textContent = "";
appendLog("POST …/addons/" + endpoint + " для «" + n + "»");
try {
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: payload !== undefined ? JSON.stringify(payload) : "{}",
});
appendLog(r.message || "OK");
if (r.log) appendLog(r.log);
await refreshStatus();
} catch (e) {
appendLog("Ошибка: " + e.message);
} finally {
setBusy(false);
}
}
async function runDelete(endpoint) {
const n = getClusterName();
if (!n) {
alert("Выберите кластер.");
return;
}
if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return;
setBusy(true, "Удаление релиза, подождите…");
if (logPre) logPre.textContent = "";
appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»");
try {
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
method: "DELETE",
headers: { Accept: "application/json" },
});
appendLog(r.message || "OK");
if (r.log) appendLog(r.log);
await refreshStatus();
} catch (e) {
appendLog("Ошибка: " + e.message);
} finally {
setBusy(false);
}
}
document.querySelectorAll(".btn-addon-install").forEach(function (btn) {
btn.addEventListener("click", function () {
const ep = btn.getAttribute("data-endpoint") || "";
if (ep === "ingress-nginx") {
const sel = document.getElementById("addons-ing-version-select");
const v = sel && sel.value.trim();
runInstall(ep, v ? { chart_version: v } : {});
return;
}
if (ep === "kube-prometheus-stack") {
const u = document.getElementById("addons-grafana-user");
const p = document.getElementById("addons-grafana-pass");
const pv = document.getElementById("addons-prom-version-select");
const user = u ? u.value.trim() : "admin";
const pw = p ? p.value : "";
if (pw.length < 8) {
alert("Задайте пароль Grafana не короче 8 символов.");
return;
}
const payload = { grafana_admin_user: user, grafana_admin_password: pw };
if (pv && pv.value.trim()) payload.chart_version = pv.value.trim();
runInstall(ep, payload);
return;
}
if (ep === "metrics-server") {
const ms = document.getElementById("addons-ms-version-select");
const payload = {};
if (ms && ms.value.trim()) payload.chart_version = ms.value.trim();
runInstall(ep, Object.keys(payload).length ? payload : {});
return;
}
if (ep === "istio-kiali") {
const u = document.getElementById("addons-kiali-user");
const p = document.getElementById("addons-kiali-pass");
const isel = document.getElementById("addons-istio-version-select");
const ksel = document.getElementById("addons-kiali-version-select");
const user = u ? u.value.trim() : "kiali-admin";
const pw = p ? p.value : "";
if (pw.length < 8) {
alert("Задайте пароль Kiali не короче 8 символов.");
return;
}
const payload = { kiali_username: user, kiali_password: pw };
if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim();
if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim();
runInstall(ep, payload);
}
});
});
document.querySelectorAll(".btn-addon-remove").forEach(function (btn) {
btn.addEventListener("click", function () {
const ep = btn.getAttribute("data-endpoint") || "";
if (ep) runDelete(ep);
});
});
const refreshBtn = document.getElementById("addons-refresh-status");
if (refreshBtn) refreshBtn.addEventListener("click", function () { refreshStatus(); });
if (sel) {
sel.addEventListener("change", function () { refreshStatus(); });
}
loadClusters()
.then(function () {
return refreshStatus();
})
.then(function () {
return loadChartVersions();
})
.finally(function () {
hideAddonsPageLoadingOverlay();
});
})();
+94 -1
View File
@@ -189,6 +189,50 @@
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
function isJobLogModalOpen() {
const el = document.getElementById("job-log-modal-overlay");
return !!(el && !el.classList.contains("hidden"));
}
function closeJobLogModal() {
const el = document.getElementById("job-log-modal-overlay");
if (el) el.classList.add("hidden");
}
/**
* Открыть модалку с текстовым журналом завершённого задания (GET /jobs/{id}).
* @param {string} jobId
*/
function openJobLogModal(jobId) {
const overlay = document.getElementById("job-log-modal-overlay");
const sub = document.getElementById("job-log-modal-sub");
const pre = document.getElementById("job-log-modal-body");
const titleEl = document.getElementById("job-log-modal-title");
if (!overlay || !pre) return;
pre.textContent = "Загрузка журнала…";
if (sub) sub.textContent = "Задание: " + jobId;
if (titleEl) titleEl.textContent = "Журнал задания";
overlay.classList.remove("hidden");
api("/jobs/" + encodeURIComponent(jobId))
.then(function (job) {
if (titleEl) {
titleEl.textContent =
"Журнал задания · " + (job.status || "") + (job.kind ? " · " + job.kind : "");
}
if (sub) {
var parts = ["Задание: " + jobId];
if (job.cluster_name) parts.push("Кластер: " + job.cluster_name);
if (job.message) parts.push(job.message);
sub.textContent = parts.join(" · ");
}
var lines = job.progress_log || [];
pre.textContent = lines.length ? lines.join("\n") : job.message || "(журнал пуст)";
})
.catch(function (e) {
pre.textContent = "Не удалось загрузить журнал: " + (e && e.message ? e.message : String(e));
});
}
/** /**
* Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке. * Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке.
* @param {string} clusterName * @param {string} clusterName
@@ -1760,6 +1804,27 @@
} }
} }
/**
* Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка).
*/
function bindJobsTableJobLogOpen() {
const tbl = document.getElementById("tbl-jobs");
if (!tbl || tbl.getAttribute("data-jobs-job-log-bound") === "1") return;
tbl.setAttribute("data-jobs-job-log-bound", "1");
tbl.addEventListener(
"click",
function (ev) {
const btn = ev.target && ev.target.closest ? ev.target.closest("button.jobs-open-log-btn") : null;
if (!btn || !tbl.contains(btn)) return;
const jobId = btn.getAttribute("data-job-id");
if (!jobId) return;
ev.preventDefault();
openJobLogModal(jobId);
},
false,
);
}
async function loadJobs() { async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody"); const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg"); const msg = document.getElementById("jobs-msg");
@@ -1782,6 +1847,14 @@
j.progress_stage + j.progress_stage +
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : ""); (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
} }
var msgCol =
"<div class=\"jobs-msg-primary\">" + escapeHtml(cellMsg) + "</div>";
if (j.status === "failed" || j.status === "cancelled") {
msgCol +=
"<button type=\"button\" class=\"btn-secondary jobs-open-log-btn\" data-job-id=\"" +
escapeHtml(j.job_id || "") +
"\">Журнал</button>";
}
const iso = j.created_at_utc || ""; const iso = j.created_at_utc || "";
const timeLabel = formatJobTimeUtc(iso); const timeLabel = formatJobTimeUtc(iso);
tr.innerHTML = tr.innerHTML =
@@ -1801,7 +1874,7 @@
st + st +
"</span></td>" + "</span></td>" +
"<td class=\"jobs-msg-cell\">" + "<td class=\"jobs-msg-cell\">" +
escapeHtml(cellMsg) + msgCol +
"</td>"; "</td>";
frag.appendChild(tr); frag.appendChild(tr);
}); });
@@ -2324,6 +2397,10 @@
}); });
} }
if (document.getElementById("tbl-jobs")) {
bindJobsTableJobLogOpen();
}
const mClose = document.getElementById("modal-close"); const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal); if (mClose) mClose.addEventListener("click", closeModal);
@@ -2340,6 +2417,10 @@
closeConfirmModal(confirmModalIsAlert ? true : false); closeConfirmModal(confirmModalIsAlert ? true : false);
return; return;
} }
if (isJobLogModalOpen()) {
closeJobLogModal();
return;
}
if (isProvisionLogModalOpen()) { if (isProvisionLogModalOpen()) {
closeProvisionLogModal(); closeProvisionLogModal();
return; return;
@@ -2378,6 +2459,17 @@
}); });
} }
const jobLogOv = document.getElementById("job-log-modal-overlay");
const jobLogClose = document.getElementById("job-log-modal-close");
if (jobLogClose) {
jobLogClose.addEventListener("click", closeJobLogModal);
}
if (jobLogOv) {
jobLogOv.addEventListener("click", function (ev) {
if (ev.target === jobLogOv) closeJobLogModal();
});
}
window.addEventListener( window.addEventListener(
"scroll", "scroll",
function () { function () {
@@ -2419,6 +2511,7 @@
} }
bindActionTooltipHosts(document.getElementById("modal-overlay")); bindActionTooltipHosts(document.getElementById("modal-overlay"));
bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay")); bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
bindActionTooltipHosts(document.getElementById("job-log-modal-overlay"));
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
+347
View File
@@ -0,0 +1,347 @@
/**
* Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу).
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Размер страницы (синхронно с default limit в API). */
var PAGE_SIZE = 30;
/** Текущая страница с 1 (состояние для пагинации). */
var currentPage = 1;
/**
* @param {string} path
*/
async function apiGet(path) {
const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, { headers: { Accept: "application/json" } });
const text = await r.text();
var data;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
data = { raw: text };
}
if (!r.ok) {
const msg =
typeof data.detail === "string"
? data.detail
: Array.isArray(data.detail)
? data.detail.map(function (x) { return x.msg || JSON.stringify(x); }).join("; ")
: text || r.statusText;
const err = new Error(msg);
err.status = r.status;
throw err;
}
return data;
}
const tbody = document.getElementById("journal-tbody");
const statusEl = document.getElementById("journal-status");
const emptyEl = document.getElementById("journal-empty");
const tableWrap = document.getElementById("journal-table-wrap");
const refreshBtn = document.getElementById("journal-refresh-btn");
const pagNav = document.getElementById("journal-pagination");
/** Скрыть полноэкранный спиннер первой загрузки (повторные вызовы безопасны). */
function hideJournalPageLoadingOverlay() {
const el = document.getElementById("journal-page-loading-overlay");
if (!el) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("journal-page-loading");
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
function formatUtc(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
} catch (e) {
return iso;
}
}
function statusClass(st) {
const s = (st || "").toLowerCase();
if (s === "success") return "journal-status-pill journal-status-pill--ok";
if (s === "failed") return "journal-status-pill journal-status-pill--err";
if (s === "cancelled") return "journal-status-pill journal-status-pill--warn";
return "journal-status-pill";
}
/**
* Список номеров страниц и многоточий для компактной навигации.
* @param {number} current
* @param {number} total
* @returns {(number|string)[]}
*/
function visiblePageItems(current, total) {
const delta = 2;
const range = [];
let i;
for (i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) {
range.push(i);
}
}
const out = [];
let l = 0;
for (i = 0; i < range.length; i++) {
const p = range[i];
if (l) {
if (p - l === 2) out.push(l + 1);
else if (p - l > 2) out.push("…");
}
out.push(p);
l = p;
}
return out;
}
/**
* @param {number} itemCount число строк на текущей странице (для подписи «записи NM»)
*/
function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) {
if (!pagNav) return;
pagNav.innerHTML = "";
if (total <= 0 || totalPages <= 0) {
pagNav.classList.add("hidden");
return;
}
pagNav.classList.remove("hidden");
const wrap = document.createElement("div");
wrap.className = "journal-pagination-inner";
function mkBtn(label, disabled, ariaLabel, onClick) {
const b = document.createElement("button");
b.type = "button";
b.className = "journal-pag-btn";
b.textContent = label;
b.setAttribute("aria-label", ariaLabel);
b.disabled = !!disabled;
if (!disabled && onClick) b.addEventListener("click", onClick);
return b;
}
const firstOff = 0;
const prevOff = Math.max(0, offset - limit);
const nextOff = Math.min(Math.max(0, total - 1), offset + limit);
const lastOff = totalPages > 0 ? (totalPages - 1) * limit : 0;
wrap.appendChild(
mkBtn("« Первая", page <= 1, "Первая страница", function () {
onSelectPage(1);
}),
);
wrap.appendChild(
mkBtn(" Назад", page <= 1, "Предыдущая страница", function () {
onSelectPage(page - 1);
}),
);
const pagesEl = document.createElement("div");
pagesEl.className = "journal-pag-pages";
pagesEl.setAttribute("role", "group");
pagesEl.setAttribute("aria-label", "Номера страниц");
visiblePageItems(page, totalPages).forEach(function (item) {
if (item === "…") {
const span = document.createElement("span");
span.className = "journal-pag-ellipsis";
span.setAttribute("aria-hidden", "true");
span.textContent = "…";
pagesEl.appendChild(span);
return;
}
const pNum = item;
const b = document.createElement("button");
b.type = "button";
b.className = "journal-pag-num" + (pNum === page ? " journal-pag-num--active" : "");
b.textContent = String(pNum);
b.setAttribute("aria-label", "Страница " + pNum);
if (pNum === page) b.setAttribute("aria-current", "page");
else b.removeAttribute("aria-current");
if (pNum !== page) {
b.addEventListener("click", function () {
onSelectPage(pNum);
});
}
pagesEl.appendChild(b);
});
wrap.appendChild(pagesEl);
wrap.appendChild(
mkBtn("Вперёд ", page >= totalPages, "Следующая страница", function () {
onSelectPage(page + 1);
}),
);
wrap.appendChild(
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () {
onSelectPage(totalPages);
}),
);
pagNav.appendChild(wrap);
const from = total === 0 || itemCount === 0 ? 0 : offset + 1;
const to = total === 0 || itemCount === 0 ? 0 : offset + itemCount;
const hint = document.createElement("p");
hint.className = "muted journal-pagination-summary";
hint.textContent =
from && to
? "Записи " + from + "" + to + " из " + total + " · страница " + page + " из " + totalPages
: "Всего записей: " + total + (totalPages ? " · страниц: " + totalPages : "");
pagNav.appendChild(hint);
}
function goToPage(p) {
if (p < 1) p = 1;
currentPage = p;
loadJournal(false);
}
/**
* @param {boolean} resetPage сбросить на 1 (после «Обновить»)
*/
async function loadJournal(resetPage) {
if (!tbody || !statusEl) {
hideJournalPageLoadingOverlay();
return;
}
if (resetPage) currentPage = 1;
const offset = (currentPage - 1) * PAGE_SIZE;
statusEl.textContent = "Загрузка…";
tbody.innerHTML = "";
if (emptyEl) emptyEl.classList.add("hidden");
if (tableWrap) tableWrap.classList.remove("hidden");
if (pagNav) pagNav.classList.add("hidden");
try {
const data = await apiGet(
"/journal/recent?limit=" +
encodeURIComponent(String(PAGE_SIZE)) +
"&offset=" +
encodeURIComponent(String(offset)),
);
const entries = data.entries || [];
const total = typeof data.total === "number" ? data.total : entries.length;
const limit = typeof data.limit === "number" ? data.limit : PAGE_SIZE;
const respOffset = typeof data.offset === "number" ? data.offset : offset;
const totalPages = typeof data.total_pages === "number" ? data.total_pages : Math.ceil(total / limit) || 0;
let page = typeof data.page === "number" ? data.page : currentPage;
if (total > 0 && currentPage > totalPages) {
currentPage = totalPages;
await loadJournal(false);
return;
}
if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) {
currentPage = totalPages;
await loadJournal(false);
return;
}
page = currentPage;
if (!entries.length) {
if (emptyEl) emptyEl.classList.remove("hidden");
if (tableWrap) tableWrap.classList.add("hidden");
statusEl.textContent =
total === 0
? "Нет записей в журналах."
: "Страница пуста (всего записей: " + total + ").";
renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage);
return;
}
const fromN = respOffset + 1;
const toN = respOffset + entries.length;
statusEl.textContent =
"Записи " + fromN + "" + toN + " из " + total + " · страница " + page + " из " + (totalPages || 1) + ".";
entries.forEach(function (e) {
const tr = document.createElement("tr");
tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatUtc(e.finished_at_utc || e.created_at_utc);
const msg = (e.message || "").trim();
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
const logText = lines.join("\n");
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML =
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
"<td><code class=\"journal-code\">" + esc(e.kind || "") + "</code></td>" +
"<td><span class=\"" + esc(statusClass(e.status)) + "\">" + esc(e.status || "") + "</span></td>" +
"<td class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" +
"<td class=\"journal-cell-actions\">" +
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
detailsId + "\">Показать лог</button>" +
"</td>";
tbody.appendChild(tr);
if (logText) {
const trDetails = document.createElement("tr");
trDetails.className = "journal-details-row hidden";
trDetails.innerHTML =
"<td colspan=\"6\" class=\"journal-details-cell\">" +
"<pre id=\"" + detailsId + "\" class=\"mono journal-log-pre\" hidden></pre>" +
"</td>";
tbody.appendChild(trDetails);
const pre = trDetails.querySelector("pre");
if (pre) pre.textContent = logText;
const btn = tr.querySelector(".btn-journal-toggle");
if (btn && trDetails && pre) {
btn.addEventListener("click", function () {
const open = trDetails.classList.contains("hidden");
trDetails.classList.toggle("hidden", !open);
pre.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.textContent = open ? "Скрыть лог" : "Показать лог";
});
}
} else {
const btn = tr.querySelector(".btn-journal-toggle");
if (btn) {
btn.disabled = true;
btn.textContent = "Нет строк";
}
}
});
renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage);
} catch (err) {
statusEl.textContent = "Ошибка: " + err.message;
if (emptyEl) emptyEl.classList.remove("hidden");
if (tableWrap) tableWrap.classList.add("hidden");
if (pagNav) pagNav.classList.add("hidden");
} finally {
hideJournalPageLoadingOverlay();
}
}
if (refreshBtn) refreshBtn.addEventListener("click", function () { loadJournal(true); });
loadJournal(false);
})();
+302 -1
View File
@@ -964,7 +964,16 @@ html[data-theme="light"] .cluster-node-res-card__bar-track {
word-break: break-word; word-break: break-word;
} }
/* Заголовок блока заданий + кнопка очистки */ /* Заголовок блока заданий + кнопка «Журнал» у failed/cancelled */
.jobs-msg-cell .jobs-msg-primary {
margin-bottom: 0.35rem;
}
.jobs-open-log-btn {
margin-top: 0.25rem;
font-size: 0.875rem;
}
.jobs-card-toolbar { .jobs-card-toolbar {
margin-bottom: 0.65rem; margin-bottom: 0.65rem;
} }
@@ -1572,6 +1581,25 @@ html[data-theme="light"] .icon-btn--danger {
.provision-log-modal-overlay { .provision-log-modal-overlay {
z-index: 500; z-index: 500;
} }
.job-log-modal-overlay {
z-index: 500;
}
.modal-box--job-log .job-log-modal-body {
max-height: min(70vh, 32rem);
overflow: auto;
margin: 0.35rem 0 0;
padding: 0.65rem 0.75rem;
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
}
html[data-theme="light"] .modal-box--job-log .job-log-modal-body {
background: rgba(0, 0, 0, 0.04);
}
.modal-box--provision-log .provision-log-body { .modal-box--provision-log .provision-log-body {
max-height: min(70vh, 32rem); max-height: min(70vh, 32rem);
overflow: auto; overflow: auto;
@@ -2272,6 +2300,12 @@ body.dashboard-home-loading {
body.cluster-page-loading { body.cluster-page-loading {
overflow: hidden; overflow: hidden;
} }
/* Первая загрузка страниц «Аддоны» и «Журнал» */
body.cluster-addons-page-loading,
body.journal-page-loading {
overflow: hidden;
}
.page-loading-overlay { .page-loading-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -2714,3 +2748,270 @@ html[data-theme="light"] .cluster-edit-yaml-preview-head {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* Страница «Аддоны»: Helm-чарты по выбранному кластеру */
.cluster-addons-toolbar-card .cluster-addons-cluster-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.65rem 1rem;
margin-bottom: 0.35rem;
}
.cluster-addons-cluster-label {
display: block;
width: 100%;
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 0.2rem;
}
.cluster-addons-cluster-select {
min-width: min(100%, 18rem);
flex: 1 1 14rem;
max-width: 28rem;
}
.cluster-addons-hint {
margin: 0.35rem 0 0;
font-size: 0.82rem;
}
.cluster-addons-status-bar {
margin-top: 0.65rem;
font-size: 0.85rem;
line-height: 1.45;
}
.cluster-addons-grid {
display: grid;
gap: 1rem;
margin: 1rem 0;
}
@media (min-width: 52rem) {
.cluster-addons-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.cluster-addon-card__title {
margin: 0 0 0.35rem;
font-size: 1.05rem;
}
.cluster-addon-card__desc {
margin: 0 0 0.75rem;
font-size: 0.85rem;
line-height: 1.45;
}
.cluster-addon-card__actions {
margin-top: 0.85rem;
gap: 0.5rem;
align-items: center;
}
.cluster-addon-card--wide {
grid-column: 1 / -1;
}
.cluster-addon-istio-grid {
margin-top: 0.25rem;
}
.cluster-addons-log-card {
margin-bottom: 1.5rem;
}
.cluster-addons-log-pre {
margin: 0.5rem 0 0;
max-height: min(50vh, 22rem);
overflow: auto;
padding: 0.65rem 0.75rem;
font-size: 0.78rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
}
html[data-theme="light"] .cluster-addons-log-pre {
background: rgba(15, 23, 42, 0.04);
}
.cluster-addons-busy-overlay {
z-index: 9995;
}
body.cluster-addons-busy {
overflow: hidden;
}
/* Версии чартов на странице аддонов */
.cluster-addons-version-select {
width: 100%;
max-width: 22rem;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
/* Страница «Журнал» */
.journal-toolbar-card .journal-toolbar-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-top: 0.35rem;
}
.journal-status {
margin: 0.65rem 0 0;
}
.journal-empty {
margin: 0.5rem 0;
}
.journal-table-wrap {
overflow-x: auto;
margin-top: 0.5rem;
}
.journal-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.journal-table th,
.journal-table td {
padding: 0.45rem 0.55rem;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.journal-table th {
font-weight: 600;
color: var(--muted);
}
.journal-code {
font-size: 0.8rem;
}
.journal-cell-time {
white-space: nowrap;
}
.journal-cell-msg {
max-width: 14rem;
word-break: break-word;
}
.journal-cell-actions {
white-space: nowrap;
}
.journal-status-pill {
display: inline-block;
padding: 0.12rem 0.45rem;
border-radius: 999px;
font-size: 0.78rem;
background: rgba(148, 163, 184, 0.2);
}
.journal-status-pill--ok {
background: rgba(34, 197, 94, 0.2);
color: var(--text);
}
.journal-status-pill--err {
background: rgba(239, 68, 68, 0.2);
}
.journal-status-pill--warn {
background: rgba(234, 179, 8, 0.22);
}
.journal-details-cell {
padding: 0.5rem 0.55rem 0.85rem;
background: rgba(0, 0, 0, 0.08);
}
html[data-theme="light"] .journal-details-cell {
background: rgba(15, 23, 42, 0.04);
}
.journal-log-pre {
margin: 0;
max-height: min(40vh, 18rem);
overflow: auto;
padding: 0.5rem 0.65rem;
font-size: 0.76rem;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
border-radius: 8px;
border: 1px solid var(--border);
}
.journal-page-size-hint {
font-size: 0.88rem;
}
/* Пагинация журнала */
.journal-pagination {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.journal-pagination-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.35rem 0.5rem;
}
.journal-pag-btn {
padding: 0.4rem 0.65rem;
font-size: 0.82rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.journal-pag-btn:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.45);
}
.journal-pag-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.journal-pag-pages {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
margin: 0 0.15rem;
}
.journal-pag-num {
min-width: 2.35rem;
padding: 0.4rem 0.5rem;
font-size: 0.85rem;
font-weight: 500;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--text);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.journal-pag-num:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.1);
}
.journal-pag-num--active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(129, 140, 248, 0.25));
border-color: rgba(99, 102, 241, 0.5);
color: var(--text);
cursor: default;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
}
.journal-pag-ellipsis {
padding: 0 0.2rem;
color: var(--muted);
font-size: 0.9rem;
user-select: none;
}
.journal-pagination-summary {
width: 100%;
text-align: center;
margin: 0.85rem 0 0;
font-size: 0.84rem;
}
@media (max-width: 640px) {
.journal-pagination-inner {
flex-direction: column;
}
.journal-pag-pages {
order: -1;
margin: 0.25rem 0;
}
}
+2
View File
@@ -57,6 +57,8 @@
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #} {# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
<div class="nav-links-scroll"> <div class="nav-links-scroll">
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a> <a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
<a href="/cluster-addons" class="nav-link nav-pill{% if nav_active|default('') == 'cluster_addons' %} nav-pill--active{% endif %}">Аддоны</a>
<a href="/journal" class="nav-link nav-pill{% if nav_active|default('') == 'journal' %} 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>
</div> </div>
<div class="nav-dropdown"> <div class="nav-dropdown">
+175
View File
@@ -0,0 +1,175 @@
{# Установка и удаление Helm-аддонов в выбранном кластере.
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block page_title %}Аддоны кластера{% endblock %}
{% block body_extra_class %} cluster-addons-page-loading{% endblock %}
{% block body_attrs %}data-dashboard-mode="addons"{% endblock %}
{% block footer %}
<div class="footer-inner">
<p class="footer-copyright">
© {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %}
{% block content %}
{# Полноэкранный спиннер до загрузки списка кластеров, статуса и версий чартов; скрывается в cluster_addons.js #}
<div
id="addons-page-loading-overlay"
class="page-loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Загрузка страницы аддонов"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span class="page-loading-label">Загрузка списка кластеров и версий чартов…</span>
</div>
</div>
<section class="card hero-panel cluster-create-hero" aria-labelledby="addons-hero-title">
<h1 class="page-title" id="addons-hero-title">Дополнения кластера (Helm)</h1>
<p class="muted page-lead">
Выберите кластер с сохранённым kubeconfig. Установка и удаление выполняются через <code>helm</code> в контейнере веб-приложения;
операции могут занимать несколько минут — дождитесь ответа сервера. Для kind у ingress-nginx задан NodePort <strong>30080</strong>.
</p>
</section>
<section class="card create-cluster-card cluster-addons-toolbar-card">
<h2 class="create-cluster-card__title">Кластер</h2>
<div class="cluster-addons-cluster-row">
<label for="addons-cluster-select" class="cluster-addons-cluster-label">Активный кластер</label>
<select id="addons-cluster-select" class="cluster-addons-cluster-select" aria-describedby="addons-cluster-hint">
<option value="">— загрузка списка —</option>
</select>
<button type="button" class="btn-secondary" id="addons-refresh-status">Обновить статус</button>
</div>
<p id="addons-cluster-hint" class="muted cluster-addons-hint">
Нужен файл <code>clusters/&lt;имя&gt;/kubeconfig</code> и доступ API из контейнера.
</p>
<p id="addons-versions-hint" class="muted cluster-addons-hint">
Список версий чартов подгружается с сервера (<code>GET /api/v1/helm/chart-versions</code>); значение «Последняя» не передаёт <code>--version</code> в Helm.
</p>
<div id="addons-status-bar" class="cluster-addons-status-bar muted" role="status" aria-live="polite"></div>
</section>
<div class="cluster-addons-grid">
<section class="card cluster-addon-card" data-addon="ingress-nginx">
<h2 class="cluster-addon-card__title">ingress-nginx</h2>
<p class="muted cluster-addon-card__desc">Контроллер Ingress; для kind: NodePort HTTP на порту 30080.</p>
<div class="create-form-field">
<label for="addons-ing-version-select">Версия чарта</label>
<select id="addons-ing-version-select" class="cluster-addons-version-select" aria-describedby="addons-versions-hint">
<option value="">Последняя (из репозитория)</option>
</select>
</div>
<div class="cluster-addon-card__actions row">
<button type="button" class="btn-addon-install" data-endpoint="ingress-nginx">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="ingress-nginx">Удалить</button>
</div>
</section>
<section class="card cluster-addon-card" data-addon="kube-prometheus-stack">
<h2 class="cluster-addon-card__title">kube-prometheus-stack</h2>
<p class="muted cluster-addon-card__desc">Prometheus Operator, Alertmanager, Grafana.</p>
<div class="create-form-field">
<label for="addons-grafana-user">Логин администратора Grafana</label>
<input id="addons-grafana-user" type="text" value="admin" maxlength="256" autocomplete="username" />
</div>
<div class="create-form-field">
<label for="addons-grafana-pass">Пароль администратора Grafana</label>
<input id="addons-grafana-pass" type="password" minlength="8" maxlength="256" autocomplete="new-password" placeholder="не менее 8 символов" />
</div>
<div class="create-form-field">
<label for="addons-prom-version-select">Версия чарта</label>
<select id="addons-prom-version-select" class="cluster-addons-version-select">
<option value="">Последняя (из репозитория)</option>
</select>
</div>
<div class="cluster-addon-card__actions row">
<button type="button" class="btn-addon-install" data-endpoint="kube-prometheus-stack">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="kube-prometheus-stack">Удалить</button>
</div>
</section>
<section class="card cluster-addon-card" data-addon="metrics-server">
<h2 class="cluster-addon-card__title">metrics-server</h2>
<p class="muted cluster-addon-card__desc">Метрики узлов и подов для <code>kubectl top</code>; для kind включён <code>--kubelet-insecure-tls</code>.</p>
<div class="create-form-field">
<label for="addons-ms-version-select">Версия чарта</label>
<select id="addons-ms-version-select" class="cluster-addons-version-select">
<option value="">Последняя (из репозитория)</option>
</select>
</div>
<div class="cluster-addon-card__actions row">
<button type="button" class="btn-addon-install" data-endpoint="metrics-server">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="metrics-server">Удалить</button>
</div>
</section>
<section class="card cluster-addon-card cluster-addon-card--wide" data-addon="istio-kiali">
<h2 class="cluster-addon-card__title">Istio + Kiali</h2>
<p class="muted cluster-addon-card__desc">
Чарты <code>istio/base</code>, <code>istio/istiod</code> и <code>kiali-server</code> в <code>istio-system</code>. Вход в Kiali — стратегия
<strong>login</strong> (секрет с логином и паролем).
</p>
<div class="create-form-grid cluster-addon-istio-grid">
<div class="create-form-field">
<label for="addons-kiali-user">Имя пользователя Kiali</label>
<input id="addons-kiali-user" type="text" value="kiali-admin" maxlength="256" autocomplete="username" />
</div>
<div class="create-form-field">
<label for="addons-kiali-pass">Пароль Kiali</label>
<input id="addons-kiali-pass" type="password" minlength="8" maxlength="256" autocomplete="new-password" placeholder="не менее 8 символов" />
</div>
<div class="create-form-field">
<label for="addons-istio-version-select">Версия чартов Istio (base + istiod)</label>
<select id="addons-istio-version-select" class="cluster-addons-version-select">
<option value="">Последняя (из репозитория)</option>
</select>
</div>
<div class="create-form-field">
<label for="addons-kiali-version-select">Версия чарта Kiali</label>
<select id="addons-kiali-version-select" class="cluster-addons-version-select">
<option value="">Последняя (из репозитория)</option>
</select>
</div>
</div>
<div class="cluster-addon-card__actions row">
<button type="button" class="btn-addon-install" data-endpoint="istio-kiali">Установить</button>
<button type="button" class="btn-secondary btn-addon-remove" data-endpoint="istio-kiali">Удалить</button>
</div>
</section>
</div>
<section class="card cluster-addons-log-card">
<h2 class="create-cluster-card__title">Журнал последней операции</h2>
<pre id="addons-log-pre" class="mono cluster-addons-log-pre" role="log" aria-live="polite"></pre>
</section>
<div
id="addons-busy-overlay"
class="page-loading-overlay cluster-addons-busy-overlay hidden"
role="status"
aria-live="polite"
aria-busy="false"
aria-hidden="true"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span id="addons-busy-label" class="page-loading-label">Выполняется операция Helm…</span>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/cluster_addons.js" defer></script>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
{# Агрегированный журнал завершённых заданий (JSON в каталогах кластеров).
Автор: Сергей Антропов — https://devops.org.ru #}
{% extends "base.html" %}
{% block page_title %}Журнал{% endblock %}
{% block body_extra_class %} journal-page-loading{% endblock %}
{% block body_attrs %}data-dashboard-mode="journal"{% endblock %}
{% block footer %}
<div class="footer-inner">
<p class="footer-copyright">
© {{ app_title }} ·
<a href="https://devops.org.ru" target="_blank" rel="noopener">devops.org.ru</a>
</p>
</div>
{% endblock %}
{% block content %}
{# Полноэкранный спиннер до первой загрузки журнала; скрывается в journal.js #}
<div
id="journal-page-loading-overlay"
class="page-loading-overlay"
role="status"
aria-live="polite"
aria-busy="true"
aria-label="Загрузка журнала"
>
<div class="page-loading-backdrop" aria-hidden="true"></div>
<div class="page-loading-center">
<span class="spinner page-loading-spinner" aria-hidden="true"></span>
<span class="page-loading-label">Загрузка журнала…</span>
</div>
</div>
<section class="card hero-panel cluster-create-hero" aria-labelledby="journal-hero-title">
<h1 class="page-title" id="journal-hero-title">Журнал заданий</h1>
<p class="muted page-lead">
Записи о завершённых операциях (<strong>create</strong>, <strong>start</strong>, <strong>stop</strong> и др.) хранятся в
<code>clusters/&lt;имя&gt;/journal/jobs_history.json</code> на томе с хоста. Сводка по всем кластерам, по <strong>30</strong> записей на страницу;
детали задания в процессе — по-прежнему в <a href="/cluster-create">создании кластера</a> и <code>GET /api/v1/jobs/{id}</code>.
</p>
</section>
<section class="card create-cluster-card journal-toolbar-card">
<h2 class="create-cluster-card__title">Действия</h2>
<div class="journal-toolbar-row">
<button type="button" class="btn-secondary" id="journal-refresh-btn">Обновить</button>
<span class="muted journal-page-size-hint">На странице по <strong>30</strong> записей.</span>
</div>
<p id="journal-status" class="muted journal-status" role="status" aria-live="polite"></p>
</section>
<section class="card journal-list-card">
<h2 class="create-cluster-card__title">Последние записи</h2>
<div id="journal-empty" class="muted journal-empty hidden">Нет записей в журналах кластеров (или каталоги кластеров ещё не созданы).</div>
<div id="journal-table-wrap" class="journal-table-wrap">
<table class="journal-table" id="journal-table" aria-describedby="journal-hero-title">
<thead>
<tr>
<th scope="col">Завершено (UTC)</th>
<th scope="col">Кластер</th>
<th scope="col">Тип</th>
<th scope="col">Статус</th>
<th scope="col">Сообщение</th>
<th scope="col">Журнал</th>
</tr>
</thead>
<tbody id="journal-tbody"></tbody>
</table>
</div>
<nav
id="journal-pagination"
class="journal-pagination hidden"
aria-label="Страницы журнала"
role="navigation"
></nav>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/js/journal.js" defer></script>
{% endblock %}
@@ -54,6 +54,31 @@
</div> </div>
</div> </div>
{# Журнал фонового задания (ошибка / отмена) — страница «Последние задания». #}
<div
id="job-log-modal-overlay"
class="modal-overlay job-log-modal-overlay hidden"
role="dialog"
aria-modal="true"
aria-labelledby="job-log-modal-title"
>
<div class="modal-box modal-box--job-log">
<div class="modal-head modal-head--cluster">
<h3 id="job-log-modal-title" class="modal-title-text modal-title-text--center">Журнал задания</h3>
<div class="modal-toolbar" role="toolbar" aria-label="Закрыть окно">
<button type="button" class="modal-close btn-secondary" id="job-log-modal-close">Закрыть</button>
</div>
</div>
<p id="job-log-modal-sub" class="muted modal-sub" aria-live="polite"></p>
<pre
id="job-log-modal-body"
class="mono job-log-modal-body"
role="region"
aria-label="Текст журнала задания"
></pre>
</div>
</div>
<div <div
id="confirm-modal-overlay" id="confirm-modal-overlay"
class="modal-overlay confirm-modal-overlay hidden" class="modal-overlay confirm-modal-overlay hidden"
+9
View File
@@ -16,6 +16,7 @@ services:
args: args:
KIND_VERSION: ${KIND_VERSION:-0.24.0} KIND_VERSION: ${KIND_VERSION:-0.24.0}
KUBECTL_VERSION: ${KUBECTL_VERSION:-} KUBECTL_VERSION: ${KUBECTL_VERSION:-}
HELM_VERSION: ${HELM_VERSION:-v3.16.3}
image: kind-k8s-tools:local image: kind-k8s-tools:local
volumes: volumes:
- ./clusters:/work/clusters - ./clusters:/work/clusters
@@ -55,6 +56,14 @@ services:
KIND_K8S_README_PATH: ${KIND_K8S_README_PATH:-} KIND_K8S_README_PATH: ${KIND_K8S_README_PATH:-}
KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-} KIND_K8S_WAIT_NODES: ${KIND_K8S_WAIT_NODES:-}
KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-} KIND_K8S_WAIT_NODES_TIMEOUT_SEC: ${KIND_K8S_WAIT_NODES_TIMEOUT_SEC:-}
# Таймаут subprocess helm/kubectl на странице «Аддоны» (секунды, 60–7200).
KIND_K8S_HELM_TIMEOUT_SEC: ${KIND_K8S_HELM_TIMEOUT_SEC:-}
# Кэш списков версий чартов (helm search repo), секунды; лимит строк на чарт — KIND_K8S_HELM_VERSIONS_MAX.
KIND_K8S_HELM_VERSIONS_CACHE_SEC: ${KIND_K8S_HELM_VERSIONS_CACHE_SEC:-}
KIND_K8S_HELM_VERSIONS_MAX: ${KIND_K8S_HELM_VERSIONS_MAX:-}
# Журнал заданий в clusters/<имя>/journal/jobs_history.json
KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES: ${KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES:-}
KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES: ${KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES:-}
KIND_K8S_APP_TITLE: ${KIND_K8S_APP_TITLE:-} KIND_K8S_APP_TITLE: ${KIND_K8S_APP_TITLE:-}
# 1 — uvicorn --reload (изменения в ./app); 0 — один процесс без reload. # 1 — uvicorn --reload (изменения в ./app); 0 — один процесс без reload.
KIND_K8S_UVICORN_RELOAD: ${KIND_K8S_UVICORN_RELOAD:-1} KIND_K8S_UVICORN_RELOAD: ${KIND_K8S_UVICORN_RELOAD:-1}
+31
View File
@@ -0,0 +1,31 @@
# Переменные окружения Kind Clusters Dashboard (только имена; значения задаются в .env или в shell).
# Автор: Сергей Антропов — https://devops.org.ru
KIND_VERSION
KUBECTL_VERSION
HELM_VERSION
CONTAINER_SOCKET
KIND_K8S_WEB_PORT
KIND_K8S_PATCH_KUBECONFIG
KIND_K8S_KUBECONFIG_CLIENT_HOST
KIND_K8S_KUBECONFIG_TLS_SERVER_NAME
CONTAINER_CLI
KIND_K8S_SKIP_VERSION_LIST
KIND_K8S_VERSION_LIST_DISPLAY
KIND_K8S_HUB_TAGS_MAX_PAGES
KIND_K8S_DEBUG
KIND_K8S_JOB_LOG_MAX_LINES
KIND_K8S_STREAM_PTY
KIND_K8S_DOCKER_PULL_PLAIN
KIND_K8S_JOB_API_LOG_MAX_LINES
KIND_K8S_JOBS_JSON
KIND_K8S_README_PATH
KIND_K8S_WAIT_NODES
KIND_K8S_WAIT_NODES_TIMEOUT_SEC
KIND_K8S_HELM_TIMEOUT_SEC
KIND_K8S_HELM_VERSIONS_CACHE_SEC
KIND_K8S_HELM_VERSIONS_MAX
KIND_K8S_CLUSTER_JOURNAL_MAX_ENTRIES
KIND_K8S_CLUSTER_JOURNAL_MAX_LOG_LINES
KIND_K8S_APP_TITLE
KIND_K8S_UVICORN_RELOAD