Журнал: пагинация 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:
@@ -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/
|
||||||
|
|||||||
@@ -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`**), опрос статуса чаще, чем общие таблицы; кнопка **«Отменить»** — прерывание с завершением текущей дочерней команды.
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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="")
|
||||||
|
|||||||
@@ -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 удалены (или отсутствовали)."
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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` | Записей на страницу, **1–100**, по умолчанию **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
@@ -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:
|
||||||
"""Форма создания кластера, прогресс и список последних заданий."""
|
"""Форма создания кластера, прогресс и список последних заданий."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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 число строк на текущей странице (для подписи «записи N–M»)
|
||||||
|
*/
|
||||||
|
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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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/<имя>/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 %}
|
||||||
@@ -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/<имя>/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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user