Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"""Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali.
|
||||
|
||||
Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``).
|
||||
После каждой операции в ``clusters/<имя>/helm_addon_log.json`` **добавляется** новая запись в массив ``entries``
|
||||
(история сохраняется, лимит ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
@@ -10,7 +12,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from core.cluster_lifecycle import validate_cluster_name
|
||||
@@ -28,6 +34,7 @@ from core.helm_addons import (
|
||||
uninstall_metrics_server,
|
||||
)
|
||||
from core.kind_guard import kind_cluster_lock
|
||||
from core.provision_log import write_cluster_helm_addon_log
|
||||
from models.schemas import (
|
||||
ClusterAddonsStatusResponse,
|
||||
HelmAddonApplyResponse,
|
||||
@@ -57,6 +64,79 @@ def _map_helm_error(e: HelmAddonError) -> HTTPException:
|
||||
return HTTPException(status_code=500, detail=msg)
|
||||
|
||||
|
||||
def _parse_optional_values_yaml(raw: str | None) -> dict[str, Any] | None:
|
||||
"""
|
||||
Разбор опционального пользовательского YAML для Helm values.
|
||||
|
||||
Пустая строка и ``null`` → ``None``. Корень документа должен быть mapping (объект YAML),
|
||||
иначе 400. Используется :func:`yaml.safe_load`.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(s)
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Некорректный YAML в values_yaml: {e}",
|
||||
) from e
|
||||
if data is None:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="values_yaml: в корне ожидается объект YAML (mapping), не список и не скаляр.",
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _run_helm_with_journal(
|
||||
cluster_name: str,
|
||||
job_kind: str,
|
||||
result_meta: dict[str, Any],
|
||||
sync_call: Callable[[], tuple[bool, str]],
|
||||
) -> str:
|
||||
"""
|
||||
Выполнить операцию под блокировкой kind, добавить запись в историю ``helm_addon_log.json``.
|
||||
|
||||
При ошибке запись всё равно добавляется со статусом ``failed``.
|
||||
"""
|
||||
log = ""
|
||||
try:
|
||||
async with kind_cluster_lock:
|
||||
_, log = await asyncio.to_thread(sync_call)
|
||||
await asyncio.to_thread(
|
||||
write_cluster_helm_addon_log,
|
||||
cluster_name=cluster_name,
|
||||
job_id=str(uuid.uuid4()),
|
||||
job_kind=job_kind,
|
||||
status="success",
|
||||
message=None,
|
||||
lines=log.splitlines() if log else [],
|
||||
result=result_meta,
|
||||
)
|
||||
return log
|
||||
except HelmAddonError as e:
|
||||
msg = str(e)
|
||||
lines = log.splitlines() if log else []
|
||||
if msg and msg not in lines:
|
||||
lines = [msg] + lines
|
||||
await asyncio.to_thread(
|
||||
write_cluster_helm_addon_log,
|
||||
cluster_name=cluster_name,
|
||||
job_id=str(uuid.uuid4()),
|
||||
job_kind=job_kind,
|
||||
status="failed",
|
||||
message=msg,
|
||||
lines=lines,
|
||||
result=result_meta,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@router.get(
|
||||
"/clusters/{name}/addons/status",
|
||||
response_model=ClusterAddonsStatusResponse,
|
||||
@@ -102,11 +182,17 @@ async def post_install_ingress_nginx(
|
||||
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
|
||||
user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
|
||||
meta = {"addon": "ingress-nginx", "action": "install", "chart_version": ver}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_install_ingress_nginx",
|
||||
meta,
|
||||
lambda: install_ingress_nginx(n, ver, user_v),
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -120,11 +206,16 @@ 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
|
||||
meta = {"addon": "ingress-nginx", "action": "delete"}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_delete_ingress_nginx",
|
||||
meta,
|
||||
lambda: 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))
|
||||
|
||||
|
||||
@@ -143,17 +234,29 @@ async def post_install_kube_prometheus_stack(
|
||||
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,
|
||||
u = body.grafana_admin_user.strip()
|
||||
user_v = _parse_optional_values_yaml(body.values_yaml)
|
||||
meta = {
|
||||
"addon": "kube-prometheus-stack",
|
||||
"action": "install",
|
||||
"chart_version": prom_ver,
|
||||
"grafana_admin_user": u,
|
||||
}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_install_kube_prometheus_stack",
|
||||
meta,
|
||||
lambda: install_kube_prometheus_stack(
|
||||
n,
|
||||
grafana_admin_user=body.grafana_admin_user.strip(),
|
||||
grafana_admin_user=u,
|
||||
grafana_admin_password=body.grafana_admin_password,
|
||||
chart_version=prom_ver,
|
||||
)
|
||||
except HelmAddonError as e:
|
||||
raise _map_helm_error(e) from e
|
||||
user_values=user_v,
|
||||
),
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -167,11 +270,16 @@ 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
|
||||
meta = {"addon": "kube-prometheus-stack", "action": "delete"}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_delete_kube_prometheus_stack",
|
||||
meta,
|
||||
lambda: 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))
|
||||
|
||||
|
||||
@@ -190,11 +298,17 @@ async def post_install_metrics_server(
|
||||
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
|
||||
user_v = _parse_optional_values_yaml(body.values_yaml if body else None)
|
||||
meta = {"addon": "metrics-server", "action": "install", "chart_version": ms_ver}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_install_metrics_server",
|
||||
meta,
|
||||
lambda: install_metrics_server(n, ms_ver, user_v),
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -208,11 +322,16 @@ 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
|
||||
meta = {"addon": "metrics-server", "action": "delete"}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_delete_metrics_server",
|
||||
meta,
|
||||
lambda: 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))
|
||||
|
||||
|
||||
@@ -234,18 +353,35 @@ async def post_install_istio_kiali(
|
||||
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,
|
||||
ku = body.kiali_username.strip()
|
||||
kiali_uv = _parse_optional_values_yaml(body.values_yaml)
|
||||
istio_base_v = _parse_optional_values_yaml(body.istio_base_values_yaml)
|
||||
istiod_v = _parse_optional_values_yaml(body.istio_istiod_values_yaml)
|
||||
meta = {
|
||||
"addon": "istio-kiali",
|
||||
"action": "install",
|
||||
"istio_chart_version": istio_ver,
|
||||
"kiali_chart_version": kiali_ver,
|
||||
"kiali_username": ku,
|
||||
}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_install_istio_kiali",
|
||||
meta,
|
||||
lambda: install_istio_and_kiali(
|
||||
n,
|
||||
kiali_username=body.kiali_username.strip(),
|
||||
kiali_username=ku,
|
||||
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
|
||||
istio_base_values=istio_base_v,
|
||||
istiod_values=istiod_v,
|
||||
kiali_user_values=kiali_uv,
|
||||
),
|
||||
)
|
||||
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))
|
||||
|
||||
@@ -259,9 +395,14 @@ 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
|
||||
meta = {"addon": "istio-kiali", "action": "delete"}
|
||||
try:
|
||||
log = await _run_helm_with_journal(
|
||||
n,
|
||||
"helm_addon_delete_istio_kiali",
|
||||
meta,
|
||||
lambda: 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))
|
||||
|
||||
@@ -52,6 +52,7 @@ from core.container_resource_stats import (
|
||||
collect_single_cluster_resource_stats,
|
||||
running_kind_clusters_mask,
|
||||
)
|
||||
from core.helm_addons import HELM_INSTALLABLE_ADDON_IDS
|
||||
from core.job_store import (
|
||||
JobRecord,
|
||||
end_job_tracking,
|
||||
@@ -198,6 +199,7 @@ def _stats_sync() -> StatsResponse:
|
||||
total_workers_from_meta=total_workers,
|
||||
jobs_total=len(jobs),
|
||||
jobs_recent_failed=failed,
|
||||
helm_addons_installable_count=len(HELM_INSTALLABLE_ADDON_IDS),
|
||||
cluster_resources=res_blocks,
|
||||
cluster_resources_error=res_err,
|
||||
aggregate_cluster_resources=agg,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``).
|
||||
"""Справочник версий Helm-чартов и сборка YAML values для редактора на странице аддонов.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
@@ -11,14 +11,76 @@ 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
|
||||
from core.helm_addons import (
|
||||
HelmAddonError,
|
||||
compose_ingress_effective_values,
|
||||
compose_istio_mesh_preview_values,
|
||||
compose_kube_prometheus_effective_values,
|
||||
compose_metrics_server_effective_values,
|
||||
dump_helm_values_yaml,
|
||||
helm_addon_chart_versions_all,
|
||||
helm_repo_ensure,
|
||||
require_helm_binary,
|
||||
)
|
||||
from models.schemas import (
|
||||
HelmAddonChartVersionsResponse,
|
||||
HelmAddonComposeBatchRequest,
|
||||
HelmAddonComposeBatchResponse,
|
||||
HelmAddonComposeValuesRequest,
|
||||
HelmAddonComposeValuesResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kind_k8s.api.helm_charts")
|
||||
|
||||
router = APIRouter(tags=["helm-charts"])
|
||||
|
||||
|
||||
def _compose_values_sync(req: HelmAddonComposeValuesRequest) -> HelmAddonComposeValuesResponse:
|
||||
"""Синхронная сборка YAML (вызов из ``asyncio.to_thread``): ``helm show values`` + оверлеи."""
|
||||
helm_repo_ensure()
|
||||
addon = req.addon
|
||||
if addon == "ingress-nginx":
|
||||
data = compose_ingress_effective_values(req.chart_version)
|
||||
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
|
||||
if addon == "kube-prometheus-stack":
|
||||
u = (req.grafana_admin_user or "admin").strip() or "admin"
|
||||
pw = (req.grafana_admin_password or "").strip() or "ChangeMe12345"
|
||||
data = compose_kube_prometheus_effective_values(req.chart_version, u, pw)
|
||||
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
|
||||
if addon == "metrics-server":
|
||||
data = compose_metrics_server_effective_values(req.chart_version)
|
||||
return HelmAddonComposeValuesResponse(values_yaml=dump_helm_values_yaml(data))
|
||||
if addon == "istio-kiali":
|
||||
k, ib, ir = compose_istio_mesh_preview_values(req.istio_chart_version, req.kiali_chart_version)
|
||||
return HelmAddonComposeValuesResponse(
|
||||
values_yaml=dump_helm_values_yaml(k),
|
||||
istio_base_values_yaml=dump_helm_values_yaml(ib),
|
||||
istio_istiod_values_yaml=dump_helm_values_yaml(ir),
|
||||
)
|
||||
raise RuntimeError(f"неизвестный addon: {addon}")
|
||||
|
||||
|
||||
def _compose_batch_sync(body: HelmAddonComposeBatchRequest) -> HelmAddonComposeBatchResponse:
|
||||
"""Один ``helm repo update`` и шесть выводов ``helm show values`` (с оверлеями где нужно)."""
|
||||
helm_repo_ensure()
|
||||
u = (body.grafana_admin_user or "admin").strip() or "admin"
|
||||
pw = (body.grafana_admin_password or "").strip() or "ChangeMe12345"
|
||||
ing = dump_helm_values_yaml(compose_ingress_effective_values(body.ingress_chart_version))
|
||||
prom = dump_helm_values_yaml(
|
||||
compose_kube_prometheus_effective_values(body.kube_prometheus_chart_version, u, pw),
|
||||
)
|
||||
ms = dump_helm_values_yaml(compose_metrics_server_effective_values(body.metrics_server_chart_version))
|
||||
k, ib, ir = compose_istio_mesh_preview_values(body.istio_chart_version, body.kiali_chart_version)
|
||||
return HelmAddonComposeBatchResponse(
|
||||
ingress_nginx_values_yaml=ing,
|
||||
kube_prometheus_values_yaml=prom,
|
||||
metrics_server_values_yaml=ms,
|
||||
kiali_values_yaml=dump_helm_values_yaml(k),
|
||||
istio_base_values_yaml=dump_helm_values_yaml(ib),
|
||||
istio_istiod_values_yaml=dump_helm_values_yaml(ir),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/helm/chart-versions",
|
||||
response_model=HelmAddonChartVersionsResponse,
|
||||
@@ -42,3 +104,54 @@ async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse:
|
||||
logger.exception("Ошибка получения версий чартов")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return HelmAddonChartVersionsResponse(**data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/helm/addons/compose-values",
|
||||
response_model=HelmAddonComposeValuesResponse,
|
||||
summary="Собрать YAML values для редактора аддонов (helm show values + форма)",
|
||||
)
|
||||
async def post_helm_addon_compose_values(
|
||||
body: HelmAddonComposeValuesRequest,
|
||||
) -> HelmAddonComposeValuesResponse:
|
||||
"""
|
||||
Выполняет ``helm repo update`` (внутри compose-функций) и ``helm show values`` для выбранного аддона.
|
||||
|
||||
Для **kube-prometheus-stack** в YAML подставляются логин и пароль Grafana из тела запроса;
|
||||
если пароль пуст, в preview используется временная строка ``ChangeMe12345`` (замените перед установкой).
|
||||
|
||||
Для **istio-kiali** возвращаются три блока YAML: **kiali-server**, **istio/base**, **istio/istiod**.
|
||||
"""
|
||||
try:
|
||||
require_helm_binary()
|
||||
except HelmAddonError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
try:
|
||||
return await asyncio.to_thread(_compose_values_sync, body)
|
||||
except HelmAddonError as e:
|
||||
logger.exception("compose-values: ошибка helm")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/helm/addons/compose-values-batch",
|
||||
response_model=HelmAddonComposeBatchResponse,
|
||||
summary="Собрать все YAML values для страницы аддонов одним запросом",
|
||||
)
|
||||
async def post_helm_addon_compose_values_batch(
|
||||
body: HelmAddonComposeBatchRequest,
|
||||
) -> HelmAddonComposeBatchResponse:
|
||||
"""
|
||||
Один проход **``helm repo update``** и подстановка версий из селектов / полей формы на странице аддонов.
|
||||
|
||||
Удобнее, чем четыре отдельных **compose-values**, когда UI обновляет все карточки сразу.
|
||||
"""
|
||||
try:
|
||||
require_helm_binary()
|
||||
except HelmAddonError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
try:
|
||||
return await asyncio.to_thread(_compose_batch_sync, body)
|
||||
except HelmAddonError as e:
|
||||
logger.exception("compose-values-batch: ошибка helm")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``.
|
||||
"""Чтение журналов: задания (jobs_history), развёртывание (provision_log), Helm-аддоны (helm_addon_log).
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
@@ -13,7 +13,18 @@ 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
|
||||
from core.provision_log import (
|
||||
HELM_ADDON_LOG_FILENAME,
|
||||
PROVISION_LOG_FILENAME,
|
||||
collect_cluster_dir_logs_page_sync,
|
||||
)
|
||||
from models.schemas import (
|
||||
ClusterDirLogEntryModel,
|
||||
ClusterJournalResponse,
|
||||
JournalEntryModel,
|
||||
JournalPagedDirLogsResponse,
|
||||
JournalRecentResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("kind_k8s.api.journal")
|
||||
|
||||
@@ -26,20 +37,52 @@ def _journal_total_pages(total: int, limit: int) -> int:
|
||||
return (total + limit - 1) // limit
|
||||
|
||||
|
||||
def _build_paged_dir_response(
|
||||
rows: list[dict],
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
total: int,
|
||||
) -> JournalPagedDirLogsResponse:
|
||||
total_pages = _journal_total_pages(total, limit)
|
||||
if total <= 0:
|
||||
page = 1
|
||||
else:
|
||||
page = min((offset // limit) + 1, total_pages)
|
||||
page = max(1, page)
|
||||
entries = [ClusterDirLogEntryModel.model_validate(r) for r in rows]
|
||||
return JournalPagedDirLogsResponse(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
total=total,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/journal/recent",
|
||||
response_model=JournalRecentResponse,
|
||||
summary="Страница журнала по всем кластерам (пагинация)",
|
||||
summary="Страница журнала заданий (jobs_history), опционально один кластер",
|
||||
)
|
||||
async def get_journal_recent(
|
||||
limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"),
|
||||
offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"),
|
||||
cluster: str | None = Query(
|
||||
default=None,
|
||||
description="Имя кластера: только записи из clusters/<имя>/journal/jobs_history.json",
|
||||
),
|
||||
) -> JournalRecentResponse:
|
||||
"""Сборка из ``clusters/*/journal/jobs_history.json``, новые первыми; ``total`` и ``total_pages`` для UI."""
|
||||
"""Сборка из ``journal/jobs_history.json``; при ``cluster`` — только выбранный каталог."""
|
||||
c = cluster.strip() if cluster else None
|
||||
if c and not validate_cluster_name(c):
|
||||
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
|
||||
rows, total = await asyncio.to_thread(
|
||||
collect_recent_journal_entries_page_sync,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
cluster_name=c,
|
||||
)
|
||||
entries = [JournalEntryModel.model_validate(r) for r in rows]
|
||||
total_pages = _journal_total_pages(total, limit)
|
||||
@@ -58,6 +101,44 @@ async def get_journal_recent(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/journal/provision",
|
||||
response_model=JournalPagedDirLogsResponse,
|
||||
summary="Журналы развёртывания по кластерам (provision_log.json)",
|
||||
)
|
||||
async def get_journal_provision_logs(
|
||||
limit: int = Query(default=30, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> JournalPagedDirLogsResponse:
|
||||
"""По одному последнему ``provision_log.json`` на кластер, новые первыми."""
|
||||
rows, total = await asyncio.to_thread(
|
||||
collect_cluster_dir_logs_page_sync,
|
||||
filename=PROVISION_LOG_FILENAME,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/journal/helm-addons",
|
||||
response_model=JournalPagedDirLogsResponse,
|
||||
summary="Журналы установки Helm-аддонов (helm_addon_log.json)",
|
||||
)
|
||||
async def get_journal_helm_addon_logs(
|
||||
limit: int = Query(default=30, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> JournalPagedDirLogsResponse:
|
||||
"""Все записи из ``helm_addon_log.json`` по кластерам (история операций), новые первыми."""
|
||||
rows, total = await asyncio.to_thread(
|
||||
collect_cluster_dir_logs_page_sync,
|
||||
filename=HELM_ADDON_LOG_FILENAME,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _build_paged_dir_response(rows, limit=limit, offset=offset, total=total)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/clusters/{name}/journal",
|
||||
response_model=ClusterJournalResponse,
|
||||
|
||||
@@ -22,7 +22,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from kind_k8s_paths import clusters_dir, data_root
|
||||
from kind_k8s_paths import clusters_dir, container_cli_name, data_root
|
||||
from kindest_node_tags import normalize_tag_v_prefix
|
||||
from kubeconfig_patch import (
|
||||
kubeconfig_host_file,
|
||||
@@ -504,13 +504,15 @@ def create_cluster_non_interactive(
|
||||
if not use_existing_config and (workers < 0 or workers > 20):
|
||||
raise KindClusterError("Количество worker-нод должно быть от 0 до 20.")
|
||||
|
||||
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
|
||||
node_image = f"kindest/node:{ver_tag}"
|
||||
|
||||
# Каталог данных кластера до долгих шагов — чтобы при сбое журнал в journal/jobs_history.json
|
||||
# и provision_log могли быть привязаны к существующему clusters/<имя>/.
|
||||
root = data_root()
|
||||
cdir = clusters_dir()
|
||||
out_dir = cdir / name
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ver_tag = normalize_tag_v_prefix(kubernetes_version_tag)
|
||||
node_image = f"kindest/node:{ver_tag}"
|
||||
cfg_path = out_dir / "kind-config.yaml"
|
||||
kube_path = out_dir / "kubeconfig"
|
||||
meta_path = out_dir / "meta.json"
|
||||
@@ -912,14 +914,14 @@ def read_meta_json(cluster_name: str) -> dict[str, object] | None:
|
||||
|
||||
|
||||
def _container_cli_bin() -> str:
|
||||
"""Имя CLI к сокету (docker / podman), как в kubeconfig_patch."""
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
"""Имя CLI к сокету: см. :func:`kind_k8s_paths.container_cli_name`."""
|
||||
return container_cli_name()
|
||||
|
||||
|
||||
def configured_container_cli() -> str:
|
||||
"""Активное имя CLI из ``CONTAINER_CLI`` (docker/podman).
|
||||
"""Имя CLI, которое реально вызывает процесс (в контейнере веб-UI — всегда ``docker``).
|
||||
|
||||
Единый источник для проверки движка, сбора ``docker stats``/``podman stats`` и поля ``container_cli`` в GET /stats.
|
||||
Единый источник для ``docker stats``/``podman stats``, ``kind``, health и поля ``container_cli`` в API.
|
||||
"""
|
||||
return _container_cli_bin()
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from core.cluster_lifecycle import _sort_kind_node_containers, list_registered_kind_clusters
|
||||
from kind_k8s_paths import container_cli_name
|
||||
from models.schemas import AggregateResourcesSummary, KindClusterResources, KindNodeResourceStat
|
||||
|
||||
logger = logging.getLogger("kind_k8s.container_resource_stats")
|
||||
@@ -24,7 +24,7 @@ _STAT_FORMAT = "{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}
|
||||
|
||||
|
||||
def _container_cli_bin() -> str:
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
return container_cli_name()
|
||||
|
||||
|
||||
def _list_running_container_names(cli: str) -> set[str]:
|
||||
|
||||
+259
-58
@@ -37,6 +37,15 @@ HELM_REPOS: dict[str, str] = {
|
||||
"kiali": "https://kiali.org/helm-charts",
|
||||
}
|
||||
|
||||
# Идентификаторы аддонов, доступных для установки на странице «Аддоны» и через API ``/clusters/{name}/addons/…``.
|
||||
# Должен совпадать с набором в ``HelmComposeValuesRequest.addon`` и кнопками в ``cluster_addons.html``.
|
||||
HELM_INSTALLABLE_ADDON_IDS: tuple[str, ...] = (
|
||||
"ingress-nginx",
|
||||
"kube-prometheus-stack",
|
||||
"metrics-server",
|
||||
"istio-kiali",
|
||||
)
|
||||
|
||||
# Ссылки для ``helm search repo <ref> --versions`` (ключи совпадают с полями API версий чартов).
|
||||
HELM_ADDON_CHART_REFS: dict[str, str] = {
|
||||
"ingress_nginx": "ingress-nginx/ingress-nginx",
|
||||
@@ -59,6 +68,121 @@ class HelmAddonError(Exception):
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def dump_helm_values_yaml(data: dict[str, Any]) -> str:
|
||||
"""Сериализация mapping в YAML для UI и временных файлов Helm."""
|
||||
return yaml.safe_dump(
|
||||
data,
|
||||
allow_unicode=True,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
)
|
||||
|
||||
|
||||
def helm_show_values_dict(chart_ref: str, chart_version: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Словарь дефолтных values чарта: ``helm show values <ref> [--version …]``.
|
||||
|
||||
При пустом stdout или ошибке разбора возвращает ``{}`` (ошибка helm — :class:`HelmAddonError`).
|
||||
"""
|
||||
require_helm_binary()
|
||||
cref = chart_ref.strip()
|
||||
if not cref:
|
||||
return {}
|
||||
cmd = ["helm", "show", "values", cref]
|
||||
ver = (chart_version or "").strip()
|
||||
if ver:
|
||||
cmd.extend(["--version", ver])
|
||||
rc, out, err = _run_cmd(cmd, timeout=180)
|
||||
if rc != 0:
|
||||
raise HelmAddonError(
|
||||
(err or out or f"helm show values {cref} код {rc}")[:4000],
|
||||
exit_code=rc,
|
||||
)
|
||||
raw = (out or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
logger.warning("YAML из helm show values %s: %s", cref, e)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _ingress_kind_service_overlay() -> dict[str, Any]:
|
||||
"""То же, что передаётся через ``--set`` при установке ingress для kind."""
|
||||
return {
|
||||
"controller": {
|
||||
"service": {
|
||||
"type": "NodePort",
|
||||
"nodePorts": {"http": 30080},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def compose_ingress_effective_values(chart_version: str | None = None) -> dict[str, Any]:
|
||||
"""Дефолты чарта ingress-nginx + сервис NodePort (как в install). Репозитории — см. :func:`helm_repo_ensure`."""
|
||||
base = helm_show_values_dict("ingress-nginx/ingress-nginx", chart_version)
|
||||
return deep_merge_values(base, _ingress_kind_service_overlay())
|
||||
|
||||
|
||||
def compose_kube_prometheus_effective_values(
|
||||
chart_version: str | None,
|
||||
grafana_admin_user: str,
|
||||
grafana_admin_password: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Дефолты kube-prometheus-stack + учётные данные Grafana."""
|
||||
base = helm_show_values_dict("prometheus-community/kube-prometheus-stack", chart_version)
|
||||
overlay: dict[str, Any] = {
|
||||
"grafana": {
|
||||
"adminUser": grafana_admin_user.strip(),
|
||||
"adminPassword": grafana_admin_password,
|
||||
}
|
||||
}
|
||||
return deep_merge_values(base, overlay)
|
||||
|
||||
|
||||
def compose_metrics_server_effective_values(chart_version: str | None = None) -> dict[str, Any]:
|
||||
"""Дефолты metrics-server + args для kind (как в install)."""
|
||||
base = helm_show_values_dict("metrics-server/metrics-server", chart_version)
|
||||
overlay: dict[str, Any] = {
|
||||
"args": [
|
||||
"--kubelet-insecure-tls",
|
||||
"--kubelet-preferred-address-types=InternalIP,Hostname,ExternalIP",
|
||||
]
|
||||
}
|
||||
return deep_merge_values(base, overlay)
|
||||
|
||||
|
||||
def compose_istio_mesh_preview_values(
|
||||
istio_chart_version: str | None = None,
|
||||
kiali_chart_version: str | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Три словаря values: ``kiali-server``, ``istio/base``, ``istio/istiod``
|
||||
(чистые ``helm show values`` для редактирования в UI).
|
||||
"""
|
||||
kiali = helm_show_values_dict("kiali/kiali-server", kiali_chart_version)
|
||||
ib = helm_show_values_dict("istio/base", istio_chart_version)
|
||||
ird = helm_show_values_dict("istio/istiod", istio_chart_version)
|
||||
return kiali, ib, ird
|
||||
|
||||
|
||||
def deep_merge_values(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Рекурсивное объединение mapping: ключи из ``override`` перекрывают ``base``;
|
||||
для вложенных dict сливаем дальше (как типичный merge для Helm values).
|
||||
"""
|
||||
out: dict[str, Any] = dict(base)
|
||||
for k, v in override.items():
|
||||
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
|
||||
out[k] = deep_merge_values(out[k], v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _helm_timeout_sec() -> int:
|
||||
raw = (os.environ.get("KIND_K8S_HELM_TIMEOUT_SEC") or "900").strip()
|
||||
try:
|
||||
@@ -283,6 +407,7 @@ def _helm_upgrade_install(
|
||||
chart_version: str | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
values_file: Path | None = None,
|
||||
extra_values_files: list[Path] | None = None,
|
||||
) -> tuple[int, str, str]:
|
||||
kcfg = str(_kubeconfig_runtime_path(cluster_name))
|
||||
cmd = [
|
||||
@@ -305,6 +430,8 @@ def _helm_upgrade_install(
|
||||
cmd.extend(["--version", ver])
|
||||
if values_file is not None:
|
||||
cmd.extend(["-f", str(values_file)])
|
||||
for p in extra_values_files or []:
|
||||
cmd.extend(["-f", str(p)])
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
return _run_cmd(cmd, timeout=_helm_timeout_sec() + 60)
|
||||
@@ -321,23 +448,41 @@ def _helm_uninstall(cluster_name: str, release: str, namespace: str) -> tuple[in
|
||||
# --- Установка / удаление по типам ---
|
||||
|
||||
|
||||
def install_ingress_nginx(cluster_name: str, chart_version: str | None) -> tuple[bool, str]:
|
||||
"""ingress-nginx с NodePort для kind."""
|
||||
def install_ingress_nginx(
|
||||
cluster_name: str,
|
||||
chart_version: str | None,
|
||||
user_values: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
ingress-nginx: полный YAML values из UI (``helm show values`` + NodePort) или переданный словарь;
|
||||
после файла values остаются ``--set`` для NodePort (приоритет как у Helm).
|
||||
"""
|
||||
helm_repo_ensure()
|
||||
eff = user_values if user_values is not None else compose_ingress_effective_values(chart_version)
|
||||
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,
|
||||
)
|
||||
user_tmp: Path | None = None
|
||||
try:
|
||||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-ing-values-")
|
||||
os.close(fd)
|
||||
user_tmp = Path(name)
|
||||
user_tmp.write_text(dump_helm_values_yaml(eff), encoding="utf-8")
|
||||
rc, out, err = _helm_upgrade_install(
|
||||
cluster_name,
|
||||
"ingress-nginx",
|
||||
"ingress-nginx/ingress-nginx",
|
||||
"ingress-nginx",
|
||||
chart_version=chart_version,
|
||||
extra_args=extra,
|
||||
extra_values_files=[user_tmp],
|
||||
)
|
||||
finally:
|
||||
if user_tmp is not None:
|
||||
user_tmp.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)
|
||||
@@ -358,18 +503,23 @@ def install_kube_prometheus_stack(
|
||||
grafana_admin_user: str,
|
||||
grafana_admin_password: str,
|
||||
chart_version: str | None = None,
|
||||
user_values: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Полный values из UI или автосборка (``helm show values`` + Grafana из формы)."""
|
||||
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)
|
||||
if user_values is not None:
|
||||
values = user_values
|
||||
else:
|
||||
values = compose_kube_prometheus_effective_values(
|
||||
chart_version,
|
||||
grafana_admin_user,
|
||||
grafana_admin_password,
|
||||
)
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".yaml", prefix="helm-prom-stack-")
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
|
||||
rc, out, err = _helm_upgrade_install(
|
||||
cluster_name,
|
||||
"kube-prometheus-stack",
|
||||
@@ -394,18 +544,22 @@ def uninstall_kube_prometheus_stack(cluster_name: str) -> tuple[bool, str]:
|
||||
return True, text or "kube-prometheus-stack удалён (или отсутствовал)."
|
||||
|
||||
|
||||
def install_metrics_server(cluster_name: str, chart_version: str | None = None) -> tuple[bool, str]:
|
||||
def install_metrics_server(
|
||||
cluster_name: str,
|
||||
chart_version: str | None = None,
|
||||
user_values: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Полный values из UI или автосборка (чарт + args для kind)."""
|
||||
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)
|
||||
if user_values is not None:
|
||||
values = user_values
|
||||
else:
|
||||
values = compose_metrics_server_effective_values(chart_version)
|
||||
fd_m, tmp_ms = tempfile.mkstemp(suffix=".yaml", prefix="helm-metrics-")
|
||||
os.close(fd_m)
|
||||
tmp_path = Path(tmp_ms)
|
||||
try:
|
||||
tmp_path.write_text(dump_helm_values_yaml(values), encoding="utf-8")
|
||||
rc, out, err = _helm_upgrade_install(
|
||||
cluster_name,
|
||||
"metrics-server",
|
||||
@@ -437,8 +591,16 @@ def install_istio_and_kiali(
|
||||
kiali_password: str,
|
||||
istio_chart_version: str | None = None,
|
||||
kiali_chart_version: str | None = None,
|
||||
istio_base_values: dict[str, Any] | None = None,
|
||||
istiod_values: dict[str, Any] | None = None,
|
||||
kiali_user_values: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Istio base + istiod + секрет входа Kiali + chart kiali-server (strategy=login)."""
|
||||
"""
|
||||
Istio base + istiod (опциональные values из UI) + секрет Kiali + kiali-server.
|
||||
|
||||
Для каждого чарта: если словарь ``None`` — без файла ``-f`` (дефолты чарта);
|
||||
если непустой dict — полный values как в редакторе.
|
||||
"""
|
||||
helm_repo_ensure()
|
||||
_ensure_namespace(cluster_name, "istio-system")
|
||||
|
||||
@@ -446,27 +608,53 @@ def install_istio_and_kiali(
|
||||
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)
|
||||
base_tmp: Path | None = None
|
||||
try:
|
||||
base_file: Path | None = None
|
||||
if istio_base_values:
|
||||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istio-base-")
|
||||
os.close(fd)
|
||||
base_tmp = Path(name)
|
||||
base_tmp.write_text(dump_helm_values_yaml(istio_base_values), encoding="utf-8")
|
||||
base_file = base_tmp
|
||||
rc, out, err = _helm_upgrade_install(
|
||||
cluster_name,
|
||||
"istio-base",
|
||||
"istio/base",
|
||||
"istio-system",
|
||||
chart_version=istio_ver,
|
||||
values_file=base_file,
|
||||
)
|
||||
log_parts.append(out or err)
|
||||
if rc != 0:
|
||||
raise HelmAddonError((err or out or f"istio-base exit {rc}")[:8000], exit_code=rc)
|
||||
finally:
|
||||
if base_tmp is not None:
|
||||
base_tmp.unlink(missing_ok=True)
|
||||
|
||||
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)
|
||||
istiod_tmp: Path | None = None
|
||||
try:
|
||||
istiod_file: Path | None = None
|
||||
if istiod_values:
|
||||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-istiod-")
|
||||
os.close(fd)
|
||||
istiod_tmp = Path(name)
|
||||
istiod_tmp.write_text(dump_helm_values_yaml(istiod_values), encoding="utf-8")
|
||||
istiod_file = istiod_tmp
|
||||
rc2, o2, e2 = _helm_upgrade_install(
|
||||
cluster_name,
|
||||
"istiod",
|
||||
"istio/istiod",
|
||||
"istio-system",
|
||||
chart_version=istio_ver,
|
||||
values_file=istiod_file,
|
||||
)
|
||||
log_parts.append(o2 or e2)
|
||||
if rc2 != 0:
|
||||
raise HelmAddonError((e2 or o2 or f"istiod exit {rc2}")[:8000], exit_code=rc2)
|
||||
finally:
|
||||
if istiod_tmp is not None:
|
||||
istiod_tmp.unlink(missing_ok=True)
|
||||
|
||||
# Секрет для стратегии login (ключи username / passphrase — ожидание Kiali).
|
||||
secret = {
|
||||
@@ -484,14 +672,27 @@ def install_istio_and_kiali(
|
||||
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"],
|
||||
)
|
||||
kiali_extra: list[Path] = []
|
||||
kiali_tmp: Path | None = None
|
||||
try:
|
||||
if kiali_user_values:
|
||||
fd, name = tempfile.mkstemp(suffix=".yaml", prefix="helm-kiali-user-")
|
||||
os.close(fd)
|
||||
kiali_tmp = Path(name)
|
||||
kiali_tmp.write_text(dump_helm_values_yaml(kiali_user_values), encoding="utf-8")
|
||||
kiali_extra.append(kiali_tmp)
|
||||
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"],
|
||||
extra_values_files=kiali_extra or None,
|
||||
)
|
||||
finally:
|
||||
if kiali_tmp is not None:
|
||||
kiali_tmp.unlink(missing_ok=True)
|
||||
log_parts.append(o3 or e3)
|
||||
if rc3 != 0:
|
||||
raise HelmAddonError((e3 or o3 or f"kiali-server exit {rc3}")[:8000], exit_code=rc3)
|
||||
|
||||
+46
-9
@@ -82,9 +82,18 @@ def append_job_from_record_sync(
|
||||
return
|
||||
|
||||
cdir = clusters_dir() / name
|
||||
# Страница «Журнал» читает clusters/<имя>/journal/jobs_history.json — нужен каталог кластера.
|
||||
# После отмены create_cluster каталог мог быть удалён (rmtree) — не создаём пустой каталог заново.
|
||||
if not cdir.is_dir():
|
||||
logger.debug("Журнал кластера: каталог %s отсутствует", cdir)
|
||||
return
|
||||
if (status or "").lower() == "cancelled":
|
||||
logger.debug("Журнал кластера: каталог %s удалён при отмене — запись jobs_history пропускаем", cdir)
|
||||
return
|
||||
try:
|
||||
cdir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Журнал: создан каталог кластера для записи jobs_history: %s", cdir)
|
||||
except OSError as e:
|
||||
logger.warning("Журнал кластера: не удалось создать %s: %s", cdir, e)
|
||||
return
|
||||
|
||||
jdir = cdir / JOURNAL_SUBDIR
|
||||
finished = datetime.now(timezone.utc).isoformat()
|
||||
@@ -185,21 +194,49 @@ def _collect_all_journal_rows_sorted_sync() -> list[tuple[str, dict[str, Any]]]:
|
||||
return flat
|
||||
|
||||
|
||||
def collect_recent_journal_entries_page_sync(*, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]:
|
||||
def collect_recent_journal_entries_page_sync(
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
cluster_name: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""
|
||||
Страница записей из всех ``clusters/*/journal/jobs_history.json``, новые первыми.
|
||||
Страница записей из ``journal/jobs_history.json``, новые первыми.
|
||||
|
||||
В каждую запись добавляется поле ``source_cluster`` (имя каталога).
|
||||
Возвращает ``(страница записей, общее число записей)``.
|
||||
Если ``cluster_name`` задан — только этот кластер (файл на диске), иначе объединение по всем кластерам.
|
||||
В каждую запись добавляется поле ``source_cluster``.
|
||||
"""
|
||||
lim = max(1, min(limit, 100))
|
||||
off = max(0, offset)
|
||||
|
||||
if cluster_name and str(cluster_name).strip():
|
||||
n = str(cluster_name).strip()
|
||||
if not validate_cluster_name(n):
|
||||
return [], 0
|
||||
raw = read_cluster_journal_sync(n)
|
||||
if not raw or not isinstance(raw.get("entries"), list):
|
||||
return [], 0
|
||||
entries = [e for e in raw["entries"] if isinstance(e, dict)]
|
||||
|
||||
def _entry_time_key(e: dict[str, Any]) -> str:
|
||||
return str(e.get("finished_at_utc") or e.get("created_at_utc") or "")
|
||||
|
||||
entries.sort(key=_entry_time_key, reverse=True)
|
||||
total = len(entries)
|
||||
chunk = entries[off : off + lim]
|
||||
out: list[dict[str, Any]] = []
|
||||
for e in chunk:
|
||||
row = dict(e)
|
||||
row["source_cluster"] = n
|
||||
out.append(row)
|
||||
return out, total
|
||||
|
||||
flat = _collect_all_journal_rows_sorted_sync()
|
||||
total = len(flat)
|
||||
chunk = flat[off : off + lim]
|
||||
out: list[dict[str, Any]] = []
|
||||
out2: 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
|
||||
out2.append(row)
|
||||
return out2, total
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Сохранение полного журнала развёртывания кластера в ``clusters/<имя>/provision_log.json``.
|
||||
|
||||
Журнал операций Helm-аддонов — ``helm_addon_log.json``: **история** операций (массив ``entries``),
|
||||
старый формат «один объект в файле» при чтении мигрируется автоматически.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
@@ -8,10 +11,13 @@ 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.provision_log")
|
||||
@@ -19,12 +25,42 @@ logger = logging.getLogger("kind_k8s.provision_log")
|
||||
PROVISION_LOG_FILENAME = "provision_log.json"
|
||||
PROVISION_LOG_VERSION = 1
|
||||
|
||||
HELM_ADDON_LOG_FILENAME = "helm_addon_log.json"
|
||||
"""Имя файла истории Helm; корневой объект с полем ``entries`` (версия файла 2)."""
|
||||
HELM_ADDON_LOG_VERSION = 1
|
||||
"""Версия схемы одной записи внутри ``entries``."""
|
||||
HELM_ADDON_LOG_FILE_VERSION = 2
|
||||
"""Версия обёртки файла (список ``entries``)."""
|
||||
|
||||
_locks_guard = threading.Lock()
|
||||
_helm_log_locks: dict[str, threading.Lock] = {}
|
||||
|
||||
|
||||
def _helm_cluster_lock(cluster_name: str) -> threading.Lock:
|
||||
with _locks_guard:
|
||||
if cluster_name not in _helm_log_locks:
|
||||
_helm_log_locks[cluster_name] = threading.Lock()
|
||||
return _helm_log_locks[cluster_name]
|
||||
|
||||
|
||||
def _max_helm_addon_entries() -> int:
|
||||
raw = (os.environ.get("KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES") or "500").strip()
|
||||
try:
|
||||
return max(20, min(int(raw), 5000))
|
||||
except ValueError:
|
||||
return 500
|
||||
|
||||
|
||||
def provision_log_file_path(cluster_name: str) -> Path:
|
||||
"""Путь к JSON с журналом операции для кластера ``cluster_name``."""
|
||||
return clusters_dir() / cluster_name.strip() / PROVISION_LOG_FILENAME
|
||||
|
||||
|
||||
def helm_addon_log_file_path(cluster_name: str) -> Path:
|
||||
"""Путь к JSON с историей журналов установки/удаления Helm-аддонов."""
|
||||
return clusters_dir() / cluster_name.strip() / HELM_ADDON_LOG_FILENAME
|
||||
|
||||
|
||||
def write_cluster_provision_log(
|
||||
*,
|
||||
cluster_name: str,
|
||||
@@ -72,3 +108,156 @@ def write_cluster_provision_log(
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _helm_addon_rows_from_parsed_json(data: dict[str, Any], cname: str) -> list[dict[str, Any]]:
|
||||
"""Разобрать содержимое ``helm_addon_log.json`` в плоский список строк для UI/API."""
|
||||
raw_entries = data.get("entries")
|
||||
if isinstance(raw_entries, list) and raw_entries:
|
||||
out: list[dict[str, Any]] = []
|
||||
for e in raw_entries:
|
||||
if isinstance(e, dict):
|
||||
row = dict(e)
|
||||
row["source_cluster"] = cname
|
||||
out.append(row)
|
||||
return out
|
||||
# Легаси: один объект операции в корне файла
|
||||
if data.get("job_id") is not None or data.get("finished_at_utc") is not None:
|
||||
row = dict(data)
|
||||
row["source_cluster"] = cname
|
||||
return [row]
|
||||
return []
|
||||
|
||||
|
||||
def write_cluster_helm_addon_log(
|
||||
*,
|
||||
cluster_name: str,
|
||||
job_id: str,
|
||||
job_kind: str,
|
||||
status: str,
|
||||
message: str | None,
|
||||
lines: list[str],
|
||||
result: dict[str, Any] | None,
|
||||
) -> Path | None:
|
||||
"""
|
||||
Добавить запись в историю ``helm_addon_log.json`` (новые записи в начале списка).
|
||||
|
||||
Файл хранит ``{"file_version": 2, "entries": [ ... ]}``; лимит длины — ``KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES``.
|
||||
"""
|
||||
name = cluster_name.strip()
|
||||
cdir = clusters_dir() / name
|
||||
if not cdir.is_dir():
|
||||
logger.debug("Каталог кластера отсутствует, helm_addon_log не пишем: %s", cdir)
|
||||
return None
|
||||
|
||||
path = cdir / HELM_ADDON_LOG_FILENAME
|
||||
new_entry: dict[str, Any] = {
|
||||
"version": HELM_ADDON_LOG_VERSION,
|
||||
"job_id": job_id,
|
||||
"kind": job_kind,
|
||||
"cluster_name": name,
|
||||
"finished_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"message": message,
|
||||
"lines": list(lines),
|
||||
"result": result,
|
||||
}
|
||||
|
||||
lock = _helm_cluster_lock(name)
|
||||
with lock:
|
||||
entries: list[dict[str, Any]] = []
|
||||
if path.is_file():
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
ex = raw.get("entries")
|
||||
if isinstance(ex, list):
|
||||
entries = [e for e in ex if isinstance(e, dict)]
|
||||
elif raw.get("job_id") is not None or raw.get("finished_at_utc") is not None:
|
||||
legacy = {k: v for k, v in raw.items() if k != "source_cluster"}
|
||||
entries = [legacy]
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("helm_addon_log %s не прочитан, создаём заново: %s", path, e)
|
||||
entries = []
|
||||
|
||||
entries.insert(0, new_entry)
|
||||
cap = _max_helm_addon_entries()
|
||||
if len(entries) > cap:
|
||||
entries = entries[:cap]
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"file_version": HELM_ADDON_LOG_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(
|
||||
"Сохранён журнал Helm-аддонов: %s (запись %s, всего записей в файле: %s)",
|
||||
path,
|
||||
job_id,
|
||||
len(entries),
|
||||
)
|
||||
return path
|
||||
except OSError as e:
|
||||
logger.warning("Не удалось записать %s: %s", path, e)
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def collect_cluster_dir_logs_page_sync(
|
||||
*,
|
||||
filename: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""
|
||||
Собрать JSON с диска по кластерам.
|
||||
|
||||
- ``provision_log.json`` — по одной строке на кластер (как раньше).
|
||||
- ``helm_addon_log.json`` — по одной строке на **каждую** запись в ``entries`` (или одна для легаси-файла).
|
||||
|
||||
Сортировка по ``finished_at_utc`` (новые первыми), затем пагинация.
|
||||
"""
|
||||
lim = max(1, min(limit, 100))
|
||||
off = max(0, offset)
|
||||
root = clusters_dir()
|
||||
if not root.is_dir():
|
||||
return [], 0
|
||||
rows: list[dict[str, Any]] = []
|
||||
for sub in sorted(root.iterdir()):
|
||||
if not sub.is_dir():
|
||||
continue
|
||||
cname = sub.name
|
||||
if not validate_cluster_name(cname):
|
||||
continue
|
||||
path = sub / filename
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if filename == HELM_ADDON_LOG_FILENAME:
|
||||
rows.extend(_helm_addon_rows_from_parsed_json(data, cname))
|
||||
else:
|
||||
row = dict(data)
|
||||
row["source_cluster"] = cname
|
||||
rows.append(row)
|
||||
|
||||
def sort_key(r: dict[str, Any]) -> str:
|
||||
return str(r.get("finished_at_utc") or "")
|
||||
|
||||
rows.sort(key=sort_key, reverse=True)
|
||||
total = len(rows)
|
||||
chunk = rows[off : off + lim]
|
||||
return chunk, total
|
||||
|
||||
@@ -31,7 +31,7 @@ from core.cluster_lifecycle import (
|
||||
validate_cluster_name,
|
||||
)
|
||||
from kindest_node_tags import fetch_kindest_node_tags, normalize_tag_v_prefix
|
||||
from kind_k8s_paths import clusters_dir
|
||||
from kind_k8s_paths import clusters_dir, container_cli_name
|
||||
|
||||
# Имя кластера: поддомен DNS (RFC 1123) — дублируем только для CLI-подсказок;
|
||||
# основная проверка в core.cluster_lifecycle.
|
||||
@@ -42,7 +42,7 @@ def _which(cmd: str) -> str | None:
|
||||
|
||||
|
||||
def _container_cli_bin() -> str:
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
return container_cli_name()
|
||||
|
||||
|
||||
def _in_container() -> bool:
|
||||
|
||||
+95
-17
@@ -19,16 +19,17 @@
|
||||
|
||||
| Маршрут | Описание |
|
||||
|---------|----------|
|
||||
| `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}/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 /` | HTML **Панель**: CTA создания кластера, карточка **Статистика** (среда kind/kubectl, счётчики), отдельная карточка **Ресурсы узлов (сводка)** (донаты по **`GET /api/v1/stats`**); полная таблица кластеров — на **`GET /clusters`**. |
|
||||
| `GET /clusters` | HTML **Кластеры**: шапка с кнопкой **Создать кластер** (`/cluster-create`), сводка **Ресурсы узлов**, таблица кластеров (**старт/стоп**, ссылка на `GET /cluster/<имя>`, модалки как на панели); скрипт **`dashboard.js`**. |
|
||||
| `GET /cluster/{name}` | HTML **страница кластера**: донаты «Ресурсы узлов (сводка)», карточки узлов, **таблицы Kubernetes** (данные API кластера в JSON), кнопка **Рестарт** у подов (**`POST …/pods/restart`**), те же кнопки действий, что в таблице на главной; данные — **`GET /api/v1/clusters/{name}/overview`** (автообновление с интервалом панели). В шапке активна пилюля **Кластеры** (`nav_active: clusters`). |
|
||||
| `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 /helm/chart-versions`**, **`POST /helm/addons/compose-values`** (подстановка **реальных** `helm show values` + логины в редактор), три YAML для Istio, установка/удаление релизов. |
|
||||
| `GET /journal` | HTML **Журналы**: переключатель (как «Простой/Расширенный» в редактировании кластера) — **по кластеру** (`journal/recent?cluster=`), **развёртывание** (`/journal/provision`), **Helm-аддоны** (`/journal/helm-addons`); пагинация по **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 /ui` | Редирект **307** на `/` (удобный ярлык). |
|
||||
| `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/cluster_addons.html` (Helm-аддоны), `app/templates/journal.html` (журнал заданий), `app/templates/documentation.html` (README).
|
||||
Шаблоны: `app/templates/base.html` (шапка, навигация), `app/templates/dashboard.html` (панель), `app/templates/clusters.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**.
|
||||
|
||||
@@ -58,27 +59,87 @@
|
||||
| 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": "опционально" }` (пусто = последняя) |
|
||||
| POST | `/api/v1/clusters/{name}/addons/ingress-nginx` | Установить **ingress-nginx**; тело: опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта, см. **compose-values** и раздел ниже) |
|
||||
| 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`** |
|
||||
| POST | `/api/v1/clusters/{name}/addons/kube-prometheus-stack` | Установить **kube-prometheus-stack**; тело: `grafana_admin_user`, `grafana_admin_password` (≥8), опционально `chart_version`, опционально **`values_yaml`** (полный YAML чарта; пусто — автосборка) |
|
||||
| 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": "…" }` или `{}` |
|
||||
| POST | `/api/v1/clusters/{name}/addons/metrics-server` | Установить **metrics-server**; тело опционально: `chart_version`, **`values_yaml`** (полный YAML; пусто — автосборка с args для kind) |
|
||||
| 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`** |
|
||||
| POST | `/api/v1/clusters/{name}/addons/istio-kiali` | Установить **istio-base**, **istiod**, секрет и **kiali-server**; тело: учётные данные Kiali, версии, опционально **`values_yaml`** (kiali-server), **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`** |
|
||||
| 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` или пусто, если файла нет) |
|
||||
| POST | `/api/v1/helm/addons/compose-values` | Собрать YAML для **одного** аддона (тело: `addon`, версии, поля Grafana при необходимости) |
|
||||
| POST | `/api/v1/helm/addons/compose-values-batch` | Собрать **все** шесть YAML для страницы аддонов **одним** запросом (один **`helm repo update`**); предпочтительно для UI |
|
||||
| GET | `/api/v1/journal/recent` | Журнал заданий из **`journal/jobs_history.json`**: query **`limit`**, **`offset`**, опционально **`cluster`** (только выбранный каталог); ответ **`total`**, **`total_pages`**, **`page`** |
|
||||
| GET | `/api/v1/journal/provision` | Сводка **`provision_log.json`** по кластерам (по одному последнему файлу на каталог), пагинация |
|
||||
| GET | `/api/v1/journal/helm-addons` | Сводка **`helm_addon_log.json`**: все записи из **`entries`** по кластерам (история), тот же формат строки, что у provision |
|
||||
| GET | `/api/v1/clusters/{name}/journal` | Полный файл **`jobs_history.json`** кластера (массив `entries` или пусто) |
|
||||
| GET | `/api/v1/jobs` | Последние задания (`progress_log` в ответе пустой — полный журнал только в GET по `job_id`) |
|
||||
| GET | `/api/v1/jobs/{job_id}` | Статус задания + полный хвост `progress_log` (лимит см. `KIND_K8S_JOB_API_LOG_MAX_LINES`) |
|
||||
| DELETE | `/api/v1/jobs` | Удалить из памяти **завершённые** задания (`removed` — число записей) |
|
||||
| POST | `/api/v1/jobs/{job_id}/cancel` | Прервать задание: активная команда завершается принудительно (скачивание образа, создание кластера, старт/стоп узла) |
|
||||
|
||||
После каждого **POST/DELETE …/addons/…** в каталоге кластера в **`helm_addon_log.json`** **добавляется** новая запись в массив **`entries`** (история сохраняется, лимит **`KIND_K8S_HELM_ADDON_LOG_MAX_ENTRIES`**). Формат записи как у **`provision_log.json`**: `lines`, `kind`, `status`, `result`; пароли в файл не пишутся.
|
||||
|
||||
#### POST `/api/v1/helm/addons/compose-values-batch`
|
||||
|
||||
Тело **`HelmAddonComposeBatchRequest`**: версии чартов из селектов (`ingress_chart_version`, `kube_prometheus_chart_version`, `metrics_server_chart_version`, `istio_chart_version`, `kiali_chart_version`) и опционально **`grafana_admin_user`** / **`grafana_admin_password`**.
|
||||
|
||||
Ответ **`HelmAddonComposeBatchResponse`**: поля **`ingress_nginx_values_yaml`**, **`kube_prometheus_values_yaml`**, **`metrics_server_values_yaml`**, **`kiali_values_yaml`**, **`istio_base_values_yaml`**, **`istio_istiod_values_yaml`**.
|
||||
|
||||
#### POST `/api/v1/helm/addons/compose-values`
|
||||
|
||||
Тело JSON (**`HelmAddonComposeValuesRequest`**):
|
||||
|
||||
- **`addon`**: `ingress-nginx` | `kube-prometheus-stack` | `metrics-server` | `istio-kiali`
|
||||
- **`chart_version`** — для ingress / kube-prometheus-stack / metrics-server (пусто = последняя версия в `helm search`)
|
||||
- **`grafana_admin_user`**, **`grafana_admin_password`** — для `kube-prometheus-stack` (если пароль пуст, в preview подставляется временная строка **`ChangeMe12345`** — замените в YAML или в форме перед установкой)
|
||||
- **`istio_chart_version`**, **`kiali_chart_version`** — для `istio-kiali`
|
||||
|
||||
Ответ (**`HelmAddonComposeValuesResponse`**): поле **`values_yaml`** — полный текст для основного чарта; для **`istio-kiali`** дополнительно **`istio_base_values_yaml`** и **`istio_istiod_values_yaml`**. Выполняется **`helm repo update`** (внутри цепочки) и **`helm show values`**.
|
||||
|
||||
#### Поля YAML в POST …/addons/… (установка)
|
||||
|
||||
Строки **`values_yaml`** / **`istio_base_values_yaml`** / **`istio_istiod_values_yaml`**: корень YAML — **object**. Пустая строка или отсутствие поля — сервер подставляет значения как при **compose-values** (кроме Istio: пустой блок = без файла **`-f`** для соответствующего чарта). Максимум **131072** символа на поле.
|
||||
|
||||
- **ingress-nginx** — один файл **`-f`** с полным values; затем **`--set`** NodePort (сильнее файла).
|
||||
- **kube-prometheus-stack** — один файл: либо из **`values_yaml`**, либо автосборка из версии и полей Grafana.
|
||||
- **metrics-server** — аналогично.
|
||||
- **istio-kiali** — до трёх опциональных файлов: **istio/base**, **istio/istiod**, **kiali-server**; секрет логина Kiali и **`auth.strategy=login`** отдельно.
|
||||
|
||||
**Пример POST compose-values (`kube-prometheus-stack`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"addon": "kube-prometheus-stack",
|
||||
"chart_version": "65.1.1",
|
||||
"grafana_admin_user": "admin",
|
||||
"grafana_admin_password": "MySecurePwd12"
|
||||
}
|
||||
```
|
||||
|
||||
**Пример ответа 200 (фрагмент):** поле **`values_yaml`** — многострочная строка с полным YAML чарта.
|
||||
|
||||
**Пример POST установки istio-kiali с тремя YAML:**
|
||||
|
||||
```json
|
||||
{
|
||||
"kiali_username": "kiali-admin",
|
||||
"kiali_password": "MySecurePwd12",
|
||||
"istio_chart_version": "1.22.0",
|
||||
"kiali_chart_version": "1.89.0",
|
||||
"istio_base_values_yaml": "defaultRevision: default\n",
|
||||
"istio_istiod_values_yaml": "pilot:\\n resources: {}\\n",
|
||||
"values_yaml": "auth:\\n strategy: token\\n"
|
||||
}
|
||||
```
|
||||
|
||||
### Фоновые задания (jobs)
|
||||
|
||||
- Хранятся **только в памяти** процесса uvicorn; после перезапуска контейнера история обнуляется.
|
||||
- В памяти держится не более **200** записей; при превышении старые задания вытесняются (`app/core/job_store.py`).
|
||||
- Снимок заданий сохраняется в 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`**.
|
||||
- При **завершении** задания (успех / ошибка / отмена), если указано **`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?cluster=…`**, **`GET /api/v1/clusters/{name}/journal`**, страница **`GET /journal`** (режим «По кластеру»).
|
||||
- Операции **Helm-аддонов** (install/delete) **дописывают** запись в **`clusters/<имя>/helm_addon_log.json`** (массив **`entries`**, новые сверху), тот же набор полей, что у **`provision_log.json`**. Сводка по всем кластерам: **`GET /api/v1/journal/helm-addons`**.
|
||||
- Создание кластера: `POST /api/v1/clusters` → опрос `GET /api/v1/jobs/{job_id}` (как в веб-UI).
|
||||
- В ответе задания поля **`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**).
|
||||
@@ -203,6 +264,7 @@ Accept: text/markdown
|
||||
"total_workers_from_meta": 2,
|
||||
"jobs_total": 3,
|
||||
"jobs_recent_failed": 0,
|
||||
"helm_addons_installable_count": 4,
|
||||
"cluster_resources": [
|
||||
{
|
||||
"cluster_name": "dev",
|
||||
@@ -250,6 +312,7 @@ Accept: text/markdown
|
||||
- `total_workers_from_meta` — целое **≥ 0**; **0**, если ни в одном `meta.json` нет поля `worker_nodes` или оно не число.
|
||||
- `jobs_total` — число заданий в текущей памяти процесса (не более 200).
|
||||
- `jobs_recent_failed` — сколько заданий в этом хранилище сейчас в статусе `failed` (не «последние N», а счётчик по всему снимку).
|
||||
- `helm_addons_installable_count` — число типовых Helm-аддонов в каталоге UI (**Аддоны**, `HELM_INSTALLABLE_ADDON_IDS` в `core/helm_addons.py`); совпадает с кнопками установки на `/cluster-addons`.
|
||||
- `cluster_resources` — по каждому имени из `kind get clusters`; если узлы остановлены, `nodes` пустой, в `note` пояснение.
|
||||
- `cluster_resources_error` — если CLI (`CONTAINER_CLI`) не найден в PATH и т.п.; тогда `cluster_resources` может быть пустым, а `aggregate_cluster_resources` — нулевая сводка.
|
||||
- `aggregate_cluster_resources` — агрегаты по **запущенным** узлам для донат-диаграмм на главной: средние проценты CPU/RAM, средняя доля RAM из строки `memory_usage`, кольца сети/диска по суммарному I/O (шкала 0–100 относительно порога 8 GiB на полное кольцо).
|
||||
@@ -308,16 +371,17 @@ Accept: text/markdown
|
||||
|
||||
## GET /api/v1/journal/recent
|
||||
|
||||
Объединение записей из всех **`clusters/*/journal/jobs_history.json`**, сортировка по **`finished_at_utc`** (новые первыми). В каждой записи добавляется **`source_cluster`** — имя каталога кластера.
|
||||
Записи из **`journal/jobs_history.json`**. Без параметра **`cluster`** — объединение по всем кластерам, сортировка по времени (новые первыми). С **`cluster=<имя>`** — только файл выбранного каталога (порядок как в файле, новые сверху). В каждой записи есть **`source_cluster`**.
|
||||
|
||||
**Query:**
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `limit` | Записей на страницу, **1–100**, по умолчанию **30** (как в веб-интерфейсе **`/journal`**) |
|
||||
| `offset` | Смещение от начала списка, **≥ 0**, по умолчанию **0** (первая страница: `offset=0`, вторая при `limit=30`: `offset=30`) |
|
||||
| `limit` | Записей на страницу, **1–100**, по умолчанию **30** |
|
||||
| `offset` | Смещение, **≥ 0**, по умолчанию **0** |
|
||||
| `cluster` | Необязательно: DNS-имя кластера — только его **`jobs_history.json`** |
|
||||
|
||||
**Поля ответа:** **`limit`**, **`offset`**, **`total`** (всего записей), **`page`** (текущая страница с 1), **`total_pages`**, **`entries`** (фрагмент текущей страницы).
|
||||
**Поля ответа:** **`limit`**, **`offset`**, **`total`**, **`page`**, **`total_pages`**, **`entries`** (элементы **`JournalEntryModel`** — в т.ч. **`log_lines`**).
|
||||
|
||||
**Пример ответа 200 (первая страница, до 30 записей):**
|
||||
|
||||
@@ -347,6 +411,20 @@ Accept: text/markdown
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/journal/provision
|
||||
|
||||
Для каждого каталога **`clusters/<имя>/`**, где есть **`provision_log.json`**, читается один объект; список сортируется по **`finished_at_utc`** (новые первыми). Пагинация: **`limit`**, **`offset`** (те же ограничения, что у **`/journal/recent`**).
|
||||
|
||||
**Ответ:** **`JournalPagedDirLogsResponse`** — в каждом элементе **`entries`** поля как у файла provision: **`kind`**, **`lines`**, **`status`**, **`message`**, **`result`**, плюс **`source_cluster`**.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/journal/helm-addons
|
||||
|
||||
Аналогично **`/journal/provision`**, но источник — **`helm_addon_log.json`**: для каждого кластера разворачиваются **все** элементы массива **`entries`** (история операций Helm), строки сортируются по **`finished_at_utc`** (новые первыми). Формат одной записи совпадает с **`provision_log.json`**.
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/clusters/{name}/journal
|
||||
|
||||
Содержимое файла **`clusters/<имя>/journal/jobs_history.json`**. Если файла нет — **`entries`: []**.
|
||||
|
||||
@@ -32,3 +32,22 @@ def data_root() -> Path:
|
||||
def clusters_dir() -> Path:
|
||||
"""``<data_root>/clusters``."""
|
||||
return data_root() / "clusters"
|
||||
|
||||
|
||||
def container_cli_name() -> str:
|
||||
"""
|
||||
Имя исполняемого файла для вызовов к движку контейнеров (``docker`` / ``podman``).
|
||||
|
||||
В образе веб-UI ставится только **docker-cli** (Alpine ``apk add docker-cli``); бинарника
|
||||
``podman`` в контейнере нет. На хост при этом может быть смонтирован сокет **Podman**
|
||||
(см. ``CONTAINER_SOCKET``): API совместим с Docker, клиент ``docker`` обращается к нему
|
||||
через ``DOCKER_HOST`` и смонтированный ``/var/run/docker.sock``.
|
||||
|
||||
Поэтому при ``KIND_K8S_IN_CONTAINER`` всегда возвращаем ``"docker"`` — независимо от
|
||||
``CONTAINER_CLI`` в ``.env`` (его имеет смысл задавать ``podman`` только при локальном
|
||||
запуске приложения на хосте без контейнера).
|
||||
"""
|
||||
raw_in = (os.environ.get("KIND_K8S_IN_CONTAINER") or "").strip().lower()
|
||||
if raw_in in ("1", "true", "yes", "on"):
|
||||
return "docker"
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
|
||||
@@ -32,6 +32,8 @@ import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from kind_k8s_paths import container_cli_name
|
||||
|
||||
logger = logging.getLogger("kind_k8s.kubeconfig_patch")
|
||||
|
||||
# Имя файла kubeconfig с server для доступа с хоста (не использовать для kubectl в контейнере веб-приложения).
|
||||
@@ -39,8 +41,8 @@ KUBECONFIG_HOST_BASENAME = "kubeconfig.host"
|
||||
|
||||
|
||||
def _container_cli() -> str:
|
||||
"""CLI для `port` (обычно docker к сокету Docker или Podman)."""
|
||||
return (os.environ.get("CONTAINER_CLI") or "docker").strip() or "docker"
|
||||
"""CLI для ``port`` к смонтированному сокету (в контейнере веб-UI — ``docker``)."""
|
||||
return container_cli_name()
|
||||
|
||||
|
||||
def _control_plane_container_name(cluster_name: str) -> str:
|
||||
|
||||
+18
-3
@@ -60,9 +60,24 @@ if _static_dir.is_dir():
|
||||
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||
|
||||
|
||||
@app.get("/clusters", response_class=HTMLResponse, summary="Список кластеров и сводка узлов")
|
||||
async def clusters_page(request: Request) -> HTMLResponse:
|
||||
"""Страница «Кластеры»: донаты ресурсов узлов и таблица (как раньше на панели)."""
|
||||
if not _templates_dir.is_dir():
|
||||
return HTMLResponse(
|
||||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||||
status_code=500,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clusters.html",
|
||||
{"app_title": settings.app_title, "nav_active": "clusters"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse, summary="Веб-интерфейс")
|
||||
async def dashboard(request: Request) -> HTMLResponse:
|
||||
"""Главная страница: обзор, сводка по узлам, таблица кластеров."""
|
||||
"""Главная панель: CTA создания, статистика среды (без таблицы кластеров — см. /clusters)."""
|
||||
if not _templates_dir.is_dir():
|
||||
return HTMLResponse(
|
||||
content="<p>Шаблоны не найдены. Ожидается каталог app/templates/</p>",
|
||||
@@ -92,7 +107,7 @@ async def cluster_detail_page(request: Request, cluster_name: str) -> HTMLRespon
|
||||
"cluster_detail.html",
|
||||
{
|
||||
"app_title": settings.app_title,
|
||||
"nav_active": "panel",
|
||||
"nav_active": "clusters",
|
||||
"cluster_name": n,
|
||||
},
|
||||
)
|
||||
@@ -114,7 +129,7 @@ async def cluster_edit_page(request: Request, cluster_name: str) -> HTMLResponse
|
||||
"cluster_edit.html",
|
||||
{
|
||||
"app_title": settings.app_title,
|
||||
"nav_active": "panel",
|
||||
"nav_active": "clusters",
|
||||
"cluster_name": n,
|
||||
},
|
||||
)
|
||||
|
||||
+134
-1
@@ -117,6 +117,11 @@ class StatsResponse(BaseModel):
|
||||
)
|
||||
jobs_total: int
|
||||
jobs_recent_failed: int
|
||||
helm_addons_installable_count: int = Field(
|
||||
0,
|
||||
ge=0,
|
||||
description="Число типовых Helm-аддонов в каталоге UI (страница «Аддоны»), доступных для установки",
|
||||
)
|
||||
cluster_resources: list[KindClusterResources] = Field(
|
||||
default_factory=list,
|
||||
description="CPU/RAM/I/O узлов kind по данным container CLI",
|
||||
@@ -311,6 +316,9 @@ class HelmAddonApplyResponse(BaseModel):
|
||||
log: str = Field(default="", description="Сводный вывод helm/kubectl")
|
||||
|
||||
|
||||
_VALUES_YAML_MAX = 131_072
|
||||
|
||||
|
||||
class IngressNginxInstallRequest(BaseModel):
|
||||
"""Тело POST …/addons/ingress-nginx — установка ingress-nginx."""
|
||||
|
||||
@@ -319,6 +327,15 @@ class IngressNginxInstallRequest(BaseModel):
|
||||
max_length=64,
|
||||
description="Версия чарта; пусто — последняя из репозитория",
|
||||
)
|
||||
values_yaml: str | None = Field(
|
||||
default=None,
|
||||
max_length=_VALUES_YAML_MAX,
|
||||
description=(
|
||||
"Полный YAML values для чарта (корень — объект), как в редакторе на странице аддонов "
|
||||
"(``helm show values`` + сервис для kind). Пусто — собрать автоматически на сервере. "
|
||||
"После ``-f`` применяются ``--set`` NodePort (приоритет Helm)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class KubePrometheusStackInstallRequest(BaseModel):
|
||||
@@ -331,6 +348,14 @@ class KubePrometheusStackInstallRequest(BaseModel):
|
||||
max_length=64,
|
||||
description="Версия чарта kube-prometheus-stack; пусто — последняя",
|
||||
)
|
||||
values_yaml: str | None = Field(
|
||||
default=None,
|
||||
max_length=_VALUES_YAML_MAX,
|
||||
description=(
|
||||
"Полный YAML values чарта (``helm show values`` + логин/пароль Grafana из формы). "
|
||||
"Пусто — собрать на сервере из версии чарта и полей формы."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MetricsServerInstallRequest(BaseModel):
|
||||
@@ -341,6 +366,14 @@ class MetricsServerInstallRequest(BaseModel):
|
||||
max_length=64,
|
||||
description="Версия чарта metrics-server; пусто — последняя",
|
||||
)
|
||||
values_yaml: str | None = Field(
|
||||
default=None,
|
||||
max_length=_VALUES_YAML_MAX,
|
||||
description=(
|
||||
"Полный YAML values чарта (``helm show values`` + args для kind). "
|
||||
"Пусто — собрать на сервере."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class IstioKialiInstallRequest(BaseModel):
|
||||
@@ -358,6 +391,75 @@ class IstioKialiInstallRequest(BaseModel):
|
||||
max_length=64,
|
||||
description="Версия чарта kiali/kiali-server; пусто — последняя",
|
||||
)
|
||||
values_yaml: str | None = Field(
|
||||
default=None,
|
||||
max_length=_VALUES_YAML_MAX,
|
||||
description=(
|
||||
"Полный YAML values чарта **kiali-server** (``helm show values``). "
|
||||
"Пусто — без файла ``-f`` (дефолты чарта). Секрет логина/пароля из формы отдельно; "
|
||||
"``auth.strategy=login`` через ``--set``."
|
||||
),
|
||||
)
|
||||
istio_base_values_yaml: str | None = Field(
|
||||
default=None,
|
||||
max_length=_VALUES_YAML_MAX,
|
||||
description="Полный YAML values чарта **istio/base**; пусто — без ``-f``.",
|
||||
)
|
||||
istio_istiod_values_yaml: str | None = Field(
|
||||
default=None,
|
||||
max_length=_VALUES_YAML_MAX,
|
||||
description="Полный YAML values чарта **istio/istiod**; пусто — без ``-f``.",
|
||||
)
|
||||
|
||||
|
||||
class HelmAddonComposeValuesRequest(BaseModel):
|
||||
"""Тело POST /helm/addons/compose-values — собрать YAML для редактора (helm show values + форма)."""
|
||||
|
||||
addon: Literal["ingress-nginx", "kube-prometheus-stack", "metrics-server", "istio-kiali"]
|
||||
chart_version: str | None = Field(
|
||||
default=None,
|
||||
max_length=64,
|
||||
description="Версия чарта: ingress-nginx, kube-prometheus-stack или metrics-server",
|
||||
)
|
||||
grafana_admin_user: str | None = Field(default=None, max_length=256)
|
||||
grafana_admin_password: str | None = Field(
|
||||
default=None,
|
||||
max_length=256,
|
||||
description="Для preview kube-prometheus-stack; если пусто — подставляется временный пароль в YAML",
|
||||
)
|
||||
istio_chart_version: str | None = Field(default=None, max_length=64)
|
||||
kiali_chart_version: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class HelmAddonComposeValuesResponse(BaseModel):
|
||||
"""Ответ compose-values: строки YAML для текстовых полей UI."""
|
||||
|
||||
values_yaml: str = Field(..., description="Основной чарт; для istio-kiali — kiali-server")
|
||||
istio_base_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/base")
|
||||
istio_istiod_values_yaml: str | None = Field(default=None, description="Только istio-kiali: istio/istiod")
|
||||
|
||||
|
||||
class HelmAddonComposeBatchRequest(BaseModel):
|
||||
"""Тело POST /helm/addons/compose-values-batch — одним запросом обновить все редакторы на странице аддонов."""
|
||||
|
||||
ingress_chart_version: str | None = Field(default=None, max_length=64)
|
||||
grafana_admin_user: str | None = Field(default=None, max_length=256)
|
||||
grafana_admin_password: str | None = Field(default=None, max_length=256)
|
||||
kube_prometheus_chart_version: str | None = Field(default=None, max_length=64)
|
||||
metrics_server_chart_version: str | None = Field(default=None, max_length=64)
|
||||
istio_chart_version: str | None = Field(default=None, max_length=64)
|
||||
kiali_chart_version: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class HelmAddonComposeBatchResponse(BaseModel):
|
||||
"""Шесть строк YAML за один вызов (один ``helm repo update``)."""
|
||||
|
||||
ingress_nginx_values_yaml: str
|
||||
kube_prometheus_values_yaml: str
|
||||
metrics_server_values_yaml: str
|
||||
kiali_values_yaml: str
|
||||
istio_base_values_yaml: str
|
||||
istio_istiod_values_yaml: str
|
||||
|
||||
|
||||
class HelmAddonChartVersionsResponse(BaseModel):
|
||||
@@ -400,7 +502,7 @@ class ClusterJournalResponse(BaseModel):
|
||||
|
||||
|
||||
class JournalRecentResponse(BaseModel):
|
||||
"""Страница агрегированного журнала (все ``clusters/*/journal/jobs_history.json``)."""
|
||||
"""Страница журнала заданий из ``journal/jobs_history.json`` (все кластеры или один)."""
|
||||
|
||||
limit: int = Field(description="Размер страницы (записей за запрос)")
|
||||
offset: int = Field(description="Смещение от начала отсортированного списка (0 = первая страница)")
|
||||
@@ -408,3 +510,34 @@ class JournalRecentResponse(BaseModel):
|
||||
page: int = Field(ge=1, description="Номер текущей страницы с 1")
|
||||
total_pages: int = Field(ge=0, description="Число страниц при данном limit; 0 если записей нет")
|
||||
entries: list[JournalEntryModel] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ClusterDirLogEntryModel(BaseModel):
|
||||
"""Запись из ``provision_log.json`` или ``helm_addon_log.json`` (формат как у развёртывания)."""
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
version: int | None = None
|
||||
job_id: str = ""
|
||||
kind: str = ""
|
||||
cluster_name: str = ""
|
||||
finished_at_utc: str = ""
|
||||
status: str = ""
|
||||
message: str | None = None
|
||||
lines: list[str] = Field(default_factory=list)
|
||||
result: dict[str, Any] | None = None
|
||||
source_cluster: str | None = Field(
|
||||
default=None,
|
||||
description="Имя каталога кластера в агрегированных ответах /journal/provision и /journal/helm-addons",
|
||||
)
|
||||
|
||||
|
||||
class JournalPagedDirLogsResponse(BaseModel):
|
||||
"""Пагинация по одному последнему логу на кластер (развёртывание или Helm-аддоны)."""
|
||||
|
||||
limit: int
|
||||
offset: int
|
||||
total: int
|
||||
page: int
|
||||
total_pages: int
|
||||
entries: list[ClusterDirLogEntryModel] = Field(default_factory=list)
|
||||
|
||||
+494
-46
@@ -12,6 +12,115 @@
|
||||
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
|
||||
/** data-endpoint кнопки → поле ответа GET …/addons/status */
|
||||
var ENDPOINT_TO_STATUS_KEY = {
|
||||
"ingress-nginx": "ingress_nginx",
|
||||
"kube-prometheus-stack": "kube_prometheus_stack",
|
||||
"metrics-server": "metrics_server",
|
||||
"istio-kiali": "istio_mesh_ready",
|
||||
};
|
||||
|
||||
var ADDON_HUMAN_LABEL = {
|
||||
"ingress-nginx": "ingress-nginx",
|
||||
"kube-prometheus-stack": "kube-prometheus-stack",
|
||||
"metrics-server": "metrics-server",
|
||||
"istio-kiali": "Istio + Kiali",
|
||||
};
|
||||
|
||||
/** Последний успешный снимок статуса (для подписи «Установить» / «Обновить»). */
|
||||
var lastAddonsStatus = null;
|
||||
|
||||
/** @type {((value: boolean) => void) | null} */
|
||||
var confirmModalResolver = null;
|
||||
var confirmModalIsAlert = false;
|
||||
|
||||
function isConfirmModalOpen() {
|
||||
const el = document.getElementById("confirm-modal-overlay");
|
||||
return !!(el && !el.classList.contains("hidden"));
|
||||
}
|
||||
|
||||
function closeConfirmModal(confirmed) {
|
||||
const ov = document.getElementById("confirm-modal-overlay");
|
||||
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||
if (ov) ov.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
||||
if (confirmModalResolver) {
|
||||
var fn = confirmModalResolver;
|
||||
confirmModalResolver = null;
|
||||
var out = confirmModalIsAlert ? true : !!confirmed;
|
||||
confirmModalIsAlert = false;
|
||||
fn(out);
|
||||
} else {
|
||||
confirmModalIsAlert = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное подтверждение (разметка из partials/dashboard_modals.html).
|
||||
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: boolean }} opts
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function openConfirmModal(opts) {
|
||||
return new Promise(function (resolve) {
|
||||
const ov = document.getElementById("confirm-modal-overlay");
|
||||
const titleEl = document.getElementById("confirm-modal-title");
|
||||
const msgEl = document.getElementById("confirm-modal-message");
|
||||
const okBtn = document.getElementById("confirm-modal-ok");
|
||||
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||
if (!ov || !titleEl || !msgEl || !okBtn) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (confirmModalResolver) {
|
||||
const prevFn = confirmModalResolver;
|
||||
confirmModalResolver = null;
|
||||
confirmModalIsAlert = false;
|
||||
prevFn(false);
|
||||
ov.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
||||
}
|
||||
confirmModalResolver = resolve;
|
||||
confirmModalIsAlert = !!opts.alert;
|
||||
titleEl.textContent = opts.title || "Подтвердите действие";
|
||||
msgEl.textContent = opts.message || "";
|
||||
okBtn.textContent = opts.confirmLabel || (confirmModalIsAlert ? "Понятно" : "Подтвердить");
|
||||
okBtn.className = opts.danger ? "btn-danger" : "";
|
||||
if (cancelBtn) {
|
||||
cancelBtn.classList.toggle("hidden", confirmModalIsAlert);
|
||||
}
|
||||
ov.classList.remove("hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function bindConfirmModalControls() {
|
||||
const confirmOv = document.getElementById("confirm-modal-overlay");
|
||||
const confirmCancel = document.getElementById("confirm-modal-cancel");
|
||||
const confirmOk = document.getElementById("confirm-modal-ok");
|
||||
if (confirmCancel) {
|
||||
confirmCancel.addEventListener("click", function () {
|
||||
closeConfirmModal(false);
|
||||
});
|
||||
}
|
||||
if (confirmOk) {
|
||||
confirmOk.addEventListener("click", function () {
|
||||
closeConfirmModal(true);
|
||||
});
|
||||
}
|
||||
if (confirmOv) {
|
||||
confirmOv.addEventListener("click", function (ev) {
|
||||
if (ev.target === confirmOv) closeConfirmModal(confirmModalIsAlert ? true : false);
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key !== "Escape" || !isConfirmModalOpen()) return;
|
||||
closeConfirmModal(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [opts]
|
||||
@@ -42,22 +151,80 @@
|
||||
|
||||
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");
|
||||
const addonsCardsWrap = document.getElementById("addons-cards-wrap");
|
||||
const helmWrap = document.getElementById("addons-helm-progress-wrap");
|
||||
const helmHint = document.getElementById("addons-helm-progress-hint");
|
||||
const helmBar = document.getElementById("addons-helm-progress-bar");
|
||||
const helmTrack = document.getElementById("addons-helm-progress-track");
|
||||
const helmLabel = document.getElementById("addons-helm-progress-label");
|
||||
const helmLogPanel = document.getElementById("addons-helm-log-panel");
|
||||
const bootstrapMsg = document.getElementById("addons-bootstrap-msg");
|
||||
|
||||
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 updateAddonsCardsVisibility() {
|
||||
if (!addonsCardsWrap) return;
|
||||
var n = getClusterName();
|
||||
addonsCardsWrap.classList.toggle("hidden", !n);
|
||||
addonsCardsWrap.setAttribute("aria-hidden", n ? "false" : "true");
|
||||
}
|
||||
|
||||
/**
|
||||
* Предупреждения загрузки списка кластеров / версий чартов (не путать с журналом Helm).
|
||||
* @param {string} text пустая строка — скрыть блок
|
||||
*/
|
||||
function setBootstrapMsg(text) {
|
||||
if (!bootstrapMsg) return;
|
||||
var s = (text || "").trim();
|
||||
if (!s) {
|
||||
bootstrapMsg.classList.add("hidden");
|
||||
bootstrapMsg.textContent = "";
|
||||
return;
|
||||
}
|
||||
bootstrapMsg.classList.remove("hidden");
|
||||
bootstrapMsg.textContent = s;
|
||||
}
|
||||
|
||||
/** Показать карточку «Ход операции Helm» (как create-progress-wrap на странице создания кластера). */
|
||||
function revealHelmProgressSection() {
|
||||
if (!helmWrap) return;
|
||||
helmWrap.classList.remove("hidden");
|
||||
helmWrap.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function updateHelmProgressBar(pct, stage) {
|
||||
var p = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
if (helmBar) helmBar.style.width = p + "%";
|
||||
if (helmTrack) helmTrack.setAttribute("aria-valuenow", String(p));
|
||||
if (helmLabel) helmLabel.textContent = stage || "—";
|
||||
}
|
||||
|
||||
/** Полный текст журнала в панели с теми же классами, что «Журнал задания» при создании кластера. */
|
||||
function setHelmLogText(text) {
|
||||
if (!helmLogPanel) return;
|
||||
helmLogPanel.textContent = text || "";
|
||||
helmLogPanel.scrollTop = helmLogPanel.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка форм на время синхронного запроса Helm (без полноэкранного спиннера).
|
||||
* @param {boolean} disabled
|
||||
*/
|
||||
function setAddonFormsDisabled(disabled) {
|
||||
document
|
||||
.querySelectorAll(
|
||||
"#addons-cards-wrap .cluster-addon-card button, #addons-cards-wrap .cluster-addon-card input, #addons-cards-wrap .cluster-addon-card select, #addons-cards-wrap .cluster-addon-card textarea",
|
||||
)
|
||||
.forEach(function (el) {
|
||||
el.disabled = !!disabled;
|
||||
});
|
||||
if (sel) sel.disabled = !!disabled;
|
||||
var refBtn = document.getElementById("addons-refresh-status");
|
||||
if (refBtn) refBtn.disabled = !!disabled;
|
||||
}
|
||||
|
||||
/** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */
|
||||
@@ -70,21 +237,177 @@
|
||||
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;
|
||||
/**
|
||||
* Текстовое сообщение в полосе статуса (нет кластера / ошибка).
|
||||
* @param {string} text
|
||||
*/
|
||||
function renderAddonStatusMessage(text) {
|
||||
if (!statusBar) return;
|
||||
statusBar.className = "cluster-addons-status-bar cluster-addons-status-bar--message muted";
|
||||
statusBar.replaceChildren();
|
||||
const p = document.createElement("p");
|
||||
p.className = "cluster-addons-status-msg";
|
||||
p.textContent = text;
|
||||
statusBar.appendChild(p);
|
||||
}
|
||||
|
||||
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(" · ");
|
||||
/** Максимальная длина поля values_yaml на сервере (см. Pydantic max_length). */
|
||||
var VALUES_YAML_MAX_LEN = 131072;
|
||||
|
||||
/**
|
||||
* Полоса под селектором кластера: только сообщения (нет кластера / ошибка), без сетки статусов.
|
||||
*/
|
||||
function clearAddonToolbarStatusBar() {
|
||||
if (!statusBar) return;
|
||||
statusBar.className = "cluster-addons-status-bar";
|
||||
statusBar.replaceChildren();
|
||||
}
|
||||
|
||||
/**
|
||||
* Бейджи «Установлен» / «Не установлен» в правом верхнем углу карточки аддона.
|
||||
* @param {object | null | undefined} st ответ GET …/addons/status или null при сбросе
|
||||
*/
|
||||
function updateAddonCardBadges(st) {
|
||||
document.querySelectorAll(".cluster-addon-card[data-addon]").forEach(function (card) {
|
||||
var da = card.getAttribute("data-addon") || "";
|
||||
var key = ENDPOINT_TO_STATUS_KEY[da];
|
||||
var on = !!(st && key && st[key]);
|
||||
var badge = card.querySelector(".cluster-addon-card__badge");
|
||||
if (!badge) return;
|
||||
badge.textContent = on ? "Установлен" : "Не установлен";
|
||||
badge.className =
|
||||
"cluster-addon-card__badge" + (on ? " cluster-addon-card__badge--on" : " cluster-addon-card__badge--off");
|
||||
var hum = ADDON_HUMAN_LABEL[da] || da;
|
||||
badge.setAttribute("aria-label", hum + ": " + (on ? "установлен" : "не установлен"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепить к телу POST YAML из textarea (полные values чарта). Для istio-kiali — три поля.
|
||||
* Пустой textarea → поле не отправляется (сервер соберёт defaults или пропустит -f).
|
||||
* @param {string} ep data-endpoint
|
||||
* @param {Record<string, unknown>} payload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function attachAddonYamlToPayload(ep, payload) {
|
||||
if (ep === "istio-kiali") {
|
||||
var triple = [
|
||||
{ id: "addons-values-yaml-istio-kiali", key: "values_yaml" },
|
||||
{ id: "addons-values-yaml-istio-base", key: "istio_base_values_yaml" },
|
||||
{ id: "addons-values-yaml-istio-istiod", key: "istio_istiod_values_yaml" },
|
||||
];
|
||||
for (var i = 0; i < triple.length; i++) {
|
||||
var ta = document.getElementById(triple[i].id);
|
||||
if (!ta) continue;
|
||||
var s = String(ta.value || "").trim();
|
||||
if (!s) continue;
|
||||
if (s.length > VALUES_YAML_MAX_LEN) {
|
||||
alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов).");
|
||||
return false;
|
||||
}
|
||||
payload[triple[i].key] = s;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
var id = "addons-values-yaml-" + ep;
|
||||
var ta2 = document.getElementById(id);
|
||||
if (!ta2) return true;
|
||||
var s2 = String(ta2.value || "").trim();
|
||||
if (!s2) return true;
|
||||
if (s2.length > VALUES_YAML_MAX_LEN) {
|
||||
alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов).");
|
||||
return false;
|
||||
}
|
||||
payload.values_yaml = s2;
|
||||
return true;
|
||||
}
|
||||
|
||||
var COMPOSE_DEBOUNCE_MS = 600;
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
var composeDebounceTimer = null;
|
||||
|
||||
/**
|
||||
* Заполнить все textarea: один POST **compose-values-batch** (один helm repo update на сервере).
|
||||
*/
|
||||
async function refreshAllComposeValuesFromServer() {
|
||||
setBootstrapMsg("");
|
||||
var si = document.getElementById("addons-ing-version-select");
|
||||
var pv = document.getElementById("addons-prom-version-select");
|
||||
var ms = document.getElementById("addons-ms-version-select");
|
||||
var isel = document.getElementById("addons-istio-version-select");
|
||||
var ksel = document.getElementById("addons-kiali-version-select");
|
||||
var gu = document.getElementById("addons-grafana-user");
|
||||
var gp = document.getElementById("addons-grafana-pass");
|
||||
var batchBody = {
|
||||
ingress_chart_version: si && si.value.trim() ? si.value.trim() : null,
|
||||
kube_prometheus_chart_version: pv && pv.value.trim() ? pv.value.trim() : null,
|
||||
metrics_server_chart_version: ms && ms.value.trim() ? ms.value.trim() : null,
|
||||
istio_chart_version: isel && isel.value.trim() ? isel.value.trim() : null,
|
||||
kiali_chart_version: ksel && ksel.value.trim() ? ksel.value.trim() : null,
|
||||
grafana_admin_user: gu ? gu.value.trim() || null : null,
|
||||
grafana_admin_password: gp ? gp.value || null : null,
|
||||
};
|
||||
try {
|
||||
var r = await api("/helm/addons/compose-values-batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(batchBody),
|
||||
});
|
||||
var ti = document.getElementById("addons-values-yaml-ingress-nginx");
|
||||
var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack");
|
||||
var tm = document.getElementById("addons-values-yaml-metrics-server");
|
||||
var tk = document.getElementById("addons-values-yaml-istio-kiali");
|
||||
var tb = document.getElementById("addons-values-yaml-istio-base");
|
||||
var tiod = document.getElementById("addons-values-yaml-istio-istiod");
|
||||
if (ti) ti.value = r.ingress_nginx_values_yaml || "";
|
||||
if (tp) tp.value = r.kube_prometheus_values_yaml || "";
|
||||
if (tm) tm.value = r.metrics_server_values_yaml || "";
|
||||
if (tk) tk.value = r.kiali_values_yaml || "";
|
||||
if (tb) tb.value = r.istio_base_values_yaml || "";
|
||||
if (tiod) tiod.value = r.istio_istiod_values_yaml || "";
|
||||
} catch (e) {
|
||||
setBootstrapMsg("Не удалось подставить values из чартов (helm show values): " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleComposeValuesRefresh() {
|
||||
if (composeDebounceTimer) clearTimeout(composeDebounceTimer);
|
||||
composeDebounceTimer = setTimeout(function () {
|
||||
composeDebounceTimer = null;
|
||||
refreshAllComposeValuesFromServer();
|
||||
}, COMPOSE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function addonEndpointInstalled(endpoint) {
|
||||
var key = ENDPOINT_TO_STATUS_KEY[endpoint];
|
||||
return !!(lastAddonsStatus && key && lastAddonsStatus[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Текст «Установить»/«Обновить», видимость «Удалить» только при установленном аддоне; равная ширина — в CSS.
|
||||
*/
|
||||
function updateAddonActionButtons() {
|
||||
document.querySelectorAll(".cluster-addon-card__actions").forEach(function (row) {
|
||||
var installBtn = row.querySelector(".btn-addon-install");
|
||||
var removeBtn = row.querySelector(".btn-addon-remove");
|
||||
if (!installBtn) return;
|
||||
var ep = installBtn.getAttribute("data-endpoint") || "";
|
||||
var on = addonEndpointInstalled(ep);
|
||||
installBtn.textContent = on ? "Обновить" : "Установить";
|
||||
var hum = ADDON_HUMAN_LABEL[ep] || ep;
|
||||
installBtn.setAttribute("aria-label", (on ? "Обновить " : "Установить ") + hum);
|
||||
if (removeBtn) {
|
||||
if (on) {
|
||||
removeBtn.classList.remove("hidden");
|
||||
removeBtn.removeAttribute("hidden");
|
||||
removeBtn.setAttribute("aria-label", "Удалить " + hum);
|
||||
} else {
|
||||
removeBtn.classList.add("hidden");
|
||||
removeBtn.setAttribute("hidden", "hidden");
|
||||
removeBtn.removeAttribute("aria-label");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,13 +443,15 @@
|
||||
async function loadChartVersions() {
|
||||
try {
|
||||
const d = await api("/helm/chart-versions");
|
||||
setBootstrapMsg("");
|
||||
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);
|
||||
await refreshAllComposeValuesFromServer();
|
||||
} catch (e) {
|
||||
appendLog("Список версий чартов недоступен: " + e.message);
|
||||
setBootstrapMsg("Список версий чартов недоступен: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +459,7 @@
|
||||
if (!sel) return;
|
||||
try {
|
||||
const rows = await api("/clusters");
|
||||
setBootstrapMsg("");
|
||||
sel.innerHTML = "";
|
||||
const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; });
|
||||
if (!withKube.length) {
|
||||
@@ -159,23 +485,37 @@
|
||||
o.value = "";
|
||||
o.textContent = "Ошибка загрузки списка";
|
||||
sel.appendChild(o);
|
||||
appendLog("Ошибка GET /clusters: " + e.message);
|
||||
setBootstrapMsg("Ошибка GET /clusters: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const n = getClusterName();
|
||||
if (!statusBar) return;
|
||||
if (!statusBar) {
|
||||
updateAddonsCardsVisibility();
|
||||
return;
|
||||
}
|
||||
if (!n) {
|
||||
statusBar.textContent = "Выберите кластер для просмотра статуса аддонов.";
|
||||
lastAddonsStatus = null;
|
||||
clearAddonToolbarStatusBar();
|
||||
updateAddonCardBadges(null);
|
||||
updateAddonActionButtons();
|
||||
updateAddonsCardsVisibility();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status");
|
||||
statusBar.textContent = formatStatus(st);
|
||||
lastAddonsStatus = st;
|
||||
clearAddonToolbarStatusBar();
|
||||
updateAddonCardBadges(st);
|
||||
updateAddonActionButtons();
|
||||
} catch (e) {
|
||||
statusBar.textContent = "Статус недоступен: " + e.message;
|
||||
lastAddonsStatus = null;
|
||||
renderAddonStatusMessage("Статус недоступен: " + e.message);
|
||||
updateAddonCardBadges(null);
|
||||
updateAddonActionButtons();
|
||||
}
|
||||
updateAddonsCardsVisibility();
|
||||
}
|
||||
|
||||
async function runInstall(endpoint, payload) {
|
||||
@@ -184,22 +524,63 @@
|
||||
alert("Выберите кластер.");
|
||||
return;
|
||||
}
|
||||
setBusy(true, "Установка (Helm), подождите…");
|
||||
if (logPre) logPre.textContent = "";
|
||||
appendLog("POST …/addons/" + endpoint + " для «" + n + "»");
|
||||
const isUpdate = addonEndpointInstalled(endpoint);
|
||||
const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
|
||||
revealHelmProgressSection();
|
||||
setAddonFormsDisabled(true);
|
||||
if (helmHint) {
|
||||
helmHint.textContent = isUpdate
|
||||
? "Обновление релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении."
|
||||
: "Установка релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении.";
|
||||
}
|
||||
updateHelmProgressBar(8, isUpdate ? "Обновление Helm…" : "Установка Helm…");
|
||||
setHelmLogText(
|
||||
"Кластер: «" +
|
||||
n +
|
||||
"»\n" +
|
||||
(isUpdate ? "POST (обновление)" : "POST (установка)") +
|
||||
" /api/v1/clusters/…/addons/" +
|
||||
endpoint +
|
||||
"\n\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);
|
||||
var okLines = [
|
||||
"Кластер: «" + n + "»",
|
||||
(isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint,
|
||||
"",
|
||||
r.message || "OK",
|
||||
];
|
||||
if (r.log) {
|
||||
okLines.push("");
|
||||
okLines.push(String(r.log));
|
||||
}
|
||||
setHelmLogText(okLines.join("\n"));
|
||||
updateHelmProgressBar(100, "Готово");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Операция завершена. Полный журнал добавлен в файл helm_addon_log.json в каталоге кластера (история записей).";
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
appendLog("Ошибка: " + e.message);
|
||||
var failLines = [
|
||||
"Кластер: «" + n + "»",
|
||||
(isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint,
|
||||
"",
|
||||
"Ошибка: " + e.message,
|
||||
];
|
||||
setHelmLogText(failLines.join("\n"));
|
||||
updateHelmProgressBar(100, "Ошибка");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Запрос не удался. При ответе сервера об ошибке Helm в журнале кластера могла появиться запись со статусом failed.";
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setAddonFormsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,22 +590,66 @@
|
||||
alert("Выберите кластер.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return;
|
||||
setBusy(true, "Удаление релиза, подождите…");
|
||||
if (logPre) logPre.textContent = "";
|
||||
appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»");
|
||||
const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
|
||||
const ok = await openConfirmModal({
|
||||
title: "Удалить аддон?",
|
||||
message:
|
||||
"Кластер «" +
|
||||
n +
|
||||
"»: снять релиз «" +
|
||||
hum +
|
||||
"» с кластера (helm uninstall). Действие необратимо для установленных компонентов.",
|
||||
confirmLabel: "Удалить",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
revealHelmProgressSection();
|
||||
setAddonFormsDisabled(true);
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Удаление релиза «" + hum + "» (helm uninstall); ниже — полный вывод по завершении.";
|
||||
}
|
||||
updateHelmProgressBar(8, "Удаление Helm…");
|
||||
setHelmLogText(
|
||||
"Кластер: «" + n + "»\nDELETE /api/v1/clusters/…/addons/" + endpoint + "\n\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);
|
||||
var okDel = [
|
||||
"Кластер: «" + n + "»",
|
||||
"DELETE /addons/" + endpoint,
|
||||
"",
|
||||
r.message || "OK",
|
||||
];
|
||||
if (r.log) {
|
||||
okDel.push("");
|
||||
okDel.push(String(r.log));
|
||||
}
|
||||
setHelmLogText(okDel.join("\n"));
|
||||
updateHelmProgressBar(100, "Готово");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Операция завершена. Запись добавлена в helm_addon_log.json в каталоге кластера (история).";
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
appendLog("Ошибка: " + e.message);
|
||||
var failDel = [
|
||||
"Кластер: «" + n + "»",
|
||||
"DELETE /addons/" + endpoint,
|
||||
"",
|
||||
"Ошибка: " + e.message,
|
||||
];
|
||||
setHelmLogText(failDel.join("\n"));
|
||||
updateHelmProgressBar(100, "Ошибка");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Запрос не удался. При ошибке Helm на сервере в журнале кластера могла появиться запись со статусом failed.";
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setAddonFormsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +659,10 @@
|
||||
if (ep === "ingress-nginx") {
|
||||
const sel = document.getElementById("addons-ing-version-select");
|
||||
const v = sel && sel.value.trim();
|
||||
runInstall(ep, v ? { chart_version: v } : {});
|
||||
const payload = {};
|
||||
if (v) payload.chart_version = v;
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
return;
|
||||
}
|
||||
if (ep === "kube-prometheus-stack") {
|
||||
@@ -249,6 +677,7 @@
|
||||
}
|
||||
const payload = { grafana_admin_user: user, grafana_admin_password: pw };
|
||||
if (pv && pv.value.trim()) payload.chart_version = pv.value.trim();
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
return;
|
||||
}
|
||||
@@ -256,7 +685,8 @@
|
||||
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 : {});
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
return;
|
||||
}
|
||||
if (ep === "istio-kiali") {
|
||||
@@ -273,6 +703,7 @@
|
||||
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();
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
}
|
||||
});
|
||||
@@ -292,6 +723,23 @@
|
||||
sel.addEventListener("change", function () { refreshStatus(); });
|
||||
}
|
||||
|
||||
bindConfirmModalControls();
|
||||
|
||||
[
|
||||
"addons-ing-version-select",
|
||||
"addons-prom-version-select",
|
||||
"addons-ms-version-select",
|
||||
"addons-istio-version-select",
|
||||
"addons-kiali-version-select",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener("change", function () { scheduleComposeValuesRefresh(); });
|
||||
});
|
||||
var grafanaUserEl = document.getElementById("addons-grafana-user");
|
||||
var grafanaPassEl = document.getElementById("addons-grafana-pass");
|
||||
if (grafanaUserEl) grafanaUserEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
|
||||
if (grafanaPassEl) grafanaPassEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
|
||||
|
||||
loadClusters()
|
||||
.then(function () {
|
||||
return refreshStatus();
|
||||
|
||||
+413
-224
@@ -32,9 +32,6 @@
|
||||
var cachedClusterResourcesByName = {};
|
||||
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
|
||||
var cachedHealthPayload = null;
|
||||
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
||||
var cachedLocalClusterDirsCount = null;
|
||||
|
||||
/**
|
||||
* Кэш GET /versions для проверки тега перед созданием кластера.
|
||||
* null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов.
|
||||
@@ -51,6 +48,11 @@
|
||||
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
|
||||
}
|
||||
|
||||
/** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */
|
||||
function isClustersListPage() {
|
||||
return document.body.getAttribute("data-dashboard-mode") === "clusters";
|
||||
}
|
||||
|
||||
function isCreatePage() {
|
||||
return document.body.getAttribute("data-dashboard-mode") === "create";
|
||||
}
|
||||
@@ -84,6 +86,16 @@
|
||||
document.body.classList.remove("dashboard-home-loading");
|
||||
}
|
||||
|
||||
/** Скрыть спиннер первой загрузки страницы «Кластеры». */
|
||||
function hideClustersPageLoadingOverlay() {
|
||||
const el = document.getElementById("clusters-page-loading-overlay");
|
||||
if (!el) return;
|
||||
el.classList.add("hidden");
|
||||
el.setAttribute("aria-busy", "false");
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("dashboard-clusters-loading");
|
||||
}
|
||||
|
||||
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
|
||||
function showClusterLifecycleJobOverlay(labelText) {
|
||||
const el = document.getElementById("cluster-lifecycle-job-overlay");
|
||||
@@ -149,32 +161,6 @@
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? "0" + n : String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC).
|
||||
* @param {string} iso
|
||||
*/
|
||||
function formatJobTimeUtc(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return (
|
||||
pad2(d.getUTCDate()) +
|
||||
"." +
|
||||
pad2(d.getUTCMonth() + 1) +
|
||||
"." +
|
||||
String(d.getUTCFullYear()).slice(-2) +
|
||||
" " +
|
||||
pad2(d.getUTCHours()) +
|
||||
":" +
|
||||
pad2(d.getUTCMinutes()) +
|
||||
" UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
|
||||
* @param {string} clusterName
|
||||
@@ -459,6 +445,7 @@
|
||||
const failureWrap = document.getElementById("stats-failure-wrap");
|
||||
const env = document.getElementById("stats-env");
|
||||
const metricsGrid = document.getElementById("stats-grid");
|
||||
const resCard = document.getElementById("home-resources-summary-card");
|
||||
if (failureWrap) {
|
||||
failureWrap.classList.remove("hidden");
|
||||
failureWrap.replaceChildren();
|
||||
@@ -480,6 +467,7 @@
|
||||
}
|
||||
if (env) env.classList.add("hidden");
|
||||
if (metricsGrid) metricsGrid.classList.add("hidden");
|
||||
if (resCard) resCard.classList.add("hidden");
|
||||
}
|
||||
|
||||
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
|
||||
@@ -487,31 +475,18 @@
|
||||
const failureWrap = document.getElementById("stats-failure-wrap");
|
||||
const env = document.getElementById("stats-env");
|
||||
const metricsGrid = document.getElementById("stats-grid");
|
||||
const resCard = document.getElementById("home-resources-summary-card");
|
||||
if (failureWrap) {
|
||||
failureWrap.classList.add("hidden");
|
||||
failureWrap.replaceChildren();
|
||||
}
|
||||
if (env) env.classList.remove("hidden");
|
||||
if (metricsGrid) metricsGrid.classList.remove("hidden");
|
||||
}
|
||||
|
||||
/** Карточка-метрика в ряду среды (без зелёного/красного статуса), напр. «Каталогов». */
|
||||
function buildStatsEnvMetricCard(label, valueText) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile stats-env-card stats-env-card--metric";
|
||||
const valEl = document.createElement("span");
|
||||
valEl.className = "stats-tile__value";
|
||||
valEl.textContent = valueText;
|
||||
const labEl = document.createElement("span");
|
||||
labEl.className = "stats-tile__label";
|
||||
labEl.textContent = label;
|
||||
tile.appendChild(valEl);
|
||||
tile.appendChild(labEl);
|
||||
return tile;
|
||||
if (resCard) resCard.classList.remove("hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats).
|
||||
* Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` — первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»).
|
||||
*/
|
||||
function paintStatsEnvRow() {
|
||||
const el = document.getElementById("stats-env");
|
||||
@@ -539,9 +514,6 @@
|
||||
el.appendChild(
|
||||
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
|
||||
);
|
||||
const dirVal =
|
||||
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
|
||||
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
@@ -659,7 +631,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``).
|
||||
* SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»).
|
||||
*/
|
||||
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
|
||||
renderAggregateDonutsToRoot(
|
||||
@@ -1016,6 +988,163 @@
|
||||
renderRows(root, items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Блоки K8s на странице кластера с фильтром по namespace (ключ → поле overview → id корня таблицы).
|
||||
* Автор: Сергей Антропов — https://devops.org.ru
|
||||
*/
|
||||
var CLUSTER_K8S_NS_BLOCKS = [
|
||||
{ key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" },
|
||||
{ key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" },
|
||||
{ key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" },
|
||||
{ key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" },
|
||||
{ key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" },
|
||||
{ key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" },
|
||||
{ key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" },
|
||||
{ key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" },
|
||||
{ key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" },
|
||||
{ key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" },
|
||||
];
|
||||
|
||||
/** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */
|
||||
var lastClusterK8sOverview = null;
|
||||
/** Однократная привязка делегирования change на фильтры namespace. */
|
||||
var clusterNsFiltersBound = false;
|
||||
|
||||
/**
|
||||
* Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview).
|
||||
* @param {object|undefined|null} block
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function k8sBlockItemsArray(block) {
|
||||
if (!block || typeof block !== "object") return [];
|
||||
var items = block.items;
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбор объектов по выбранному namespace (пустая строка — все).
|
||||
* @param {object[]|undefined} items
|
||||
* @param {string} ns
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function k8sFilterItemsByNamespace(items, ns) {
|
||||
if (!ns) return items || [];
|
||||
return (items || []).filter(function (it) {
|
||||
return ((it.metadata && it.metadata.namespace) || "") === ns;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта.
|
||||
* Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк).
|
||||
* @param {object[]|undefined} items
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function k8sSortNamespacedItems(items) {
|
||||
if (!items || !items.length) return items || [];
|
||||
return items.slice().sort(function (a, b) {
|
||||
var nsA = (a.metadata && a.metadata.namespace) || "";
|
||||
var nsB = (b.metadata && b.metadata.namespace) || "";
|
||||
var c = String(nsA).localeCompare(String(nsB), "ru");
|
||||
if (c !== 0) return c;
|
||||
var nA = (a.metadata && a.metadata.name) || "";
|
||||
var nB = (b.metadata && b.metadata.name) || "";
|
||||
return String(nA).localeCompare(String(nB), "ru");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace``
|
||||
* из таблиц ресурсов (pods/deployments/…); без дублей, сортировка по ru.
|
||||
* Автор: Сергей Антропов — https://devops.org.ru
|
||||
* @param {object} d ответ cluster overview
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function k8sNamespaceNamesFromOverview(d) {
|
||||
var seen = Object.create(null);
|
||||
var ordered = [];
|
||||
|
||||
function addName(raw) {
|
||||
if (raw == null) return;
|
||||
var s = String(raw).trim();
|
||||
if (!s || seen[s]) return;
|
||||
seen[s] = true;
|
||||
ordered.push(s);
|
||||
}
|
||||
|
||||
var nsBlock = d.k8s_namespaces;
|
||||
if (nsBlock && nsBlock.ok) {
|
||||
k8sBlockItemsArray(nsBlock).forEach(function (it) {
|
||||
var meta = it && it.metadata;
|
||||
addName(meta && meta.name);
|
||||
});
|
||||
}
|
||||
|
||||
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
|
||||
var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop];
|
||||
if (!blk || !blk.ok) continue;
|
||||
k8sBlockItemsArray(blk).forEach(function (it) {
|
||||
var meta = it && it.metadata;
|
||||
addName(meta && meta.namespace);
|
||||
});
|
||||
}
|
||||
|
||||
ordered.sort(function (a, b) {
|
||||
return a.localeCompare(b, "ru");
|
||||
});
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-<key>.
|
||||
* @param {object | null | undefined} block
|
||||
* @param {string | null} kubeErr
|
||||
* @param {HTMLElement | null} root
|
||||
* @param {string} filterKey
|
||||
* @param {function(HTMLElement, object[]): void} renderFn
|
||||
*/
|
||||
function clusterK8sRenderBlockNs(block, kubeErr, root, filterKey, renderFn) {
|
||||
if (!root) return;
|
||||
var sel = document.getElementById("cluster-k8s-filter-" + filterKey);
|
||||
var ns = sel && sel.value ? sel.value : "";
|
||||
if (kubeErr) {
|
||||
clusterK8sShowMessage(root, kubeErr);
|
||||
return;
|
||||
}
|
||||
if (!block || !block.ok) {
|
||||
clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны.");
|
||||
return;
|
||||
}
|
||||
var items = k8sFilterItemsByNamespace(k8sBlockItemsArray(block), ns);
|
||||
items = k8sSortNamespacedItems(items);
|
||||
renderFn(root, items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заполняет option во всех селектах фильтра; сохраняет прежнее value, если namespace ещё в списке.
|
||||
* @param {string[]} namespaceNames
|
||||
*/
|
||||
function clusterK8sPopulateNsSelects(namespaceNames) {
|
||||
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
|
||||
var sel = document.getElementById("cluster-k8s-filter-" + row.key);
|
||||
if (!sel) return;
|
||||
var prev = (sel.value || "").trim();
|
||||
sel.replaceChildren();
|
||||
var o0 = document.createElement("option");
|
||||
o0.value = "";
|
||||
o0.textContent = "Все namespace";
|
||||
sel.appendChild(o0);
|
||||
namespaceNames.forEach(function (name) {
|
||||
var o = document.createElement("option");
|
||||
o.value = name;
|
||||
o.textContent = name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if (prev && namespaceNames.indexOf(prev) >= 0) sel.value = prev;
|
||||
else sel.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
function k8sNodeReady(item) {
|
||||
const cond = (item.status && item.status.conditions) || [];
|
||||
for (var i = 0; i < cond.length; i++) {
|
||||
@@ -1244,13 +1373,70 @@
|
||||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Фабрика функций рендера по ключу фильтра (для подов нужен clusterName).
|
||||
* @param {string} cname
|
||||
* @returns {Record<string, function(HTMLElement, object[]): void>}
|
||||
*/
|
||||
function buildClusterK8sNsFilterRenderMap(cname) {
|
||||
return {
|
||||
pods: function (root, items) {
|
||||
renderK8sPodsTable(root, items, cname);
|
||||
},
|
||||
deployments: renderK8sDeploymentsTable,
|
||||
statefulsets: renderK8sStsDsTable,
|
||||
daemonsets: renderK8sStsDsTable,
|
||||
replicasets: renderK8sReplicaSetsTable,
|
||||
jobs: renderK8sJobsTable,
|
||||
cronjobs: renderK8sCronJobsTable,
|
||||
services: renderK8sServicesTable,
|
||||
ingresses: renderK8sIngressesTable,
|
||||
pvcs: renderK8sPvcsTable,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Перерисовка одной таблицы после смены select (без нового API-запроса).
|
||||
* @param {string} key ключ data-cluster-k8s-ns-filter
|
||||
*/
|
||||
function refillClusterK8sNsFilter(key) {
|
||||
if (!lastClusterK8sOverview) return;
|
||||
var d = lastClusterK8sOverview;
|
||||
var kubeErr = d.kubeconfig_error || null;
|
||||
var cname = d.cluster_name || getClusterPageName() || "";
|
||||
var renderByKey = buildClusterK8sNsFilterRenderMap(cname);
|
||||
var renderFn = renderByKey[key];
|
||||
if (!renderFn) return;
|
||||
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
|
||||
if (CLUSTER_K8S_NS_BLOCKS[i].key !== key) continue;
|
||||
var row = CLUSTER_K8S_NS_BLOCKS[i];
|
||||
clusterK8sRenderBlockNs(d[row.prop], kubeErr, document.getElementById(row.rootId), key, renderFn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Делегирование: один listener на document для всех .cluster-k8s-ns-select. */
|
||||
function ensureClusterNsFilterListeners() {
|
||||
if (clusterNsFiltersBound) return;
|
||||
document.addEventListener("change", function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t || !t.matches || !t.matches("select.cluster-k8s-ns-select[data-cluster-k8s-ns-filter]")) return;
|
||||
var key = t.getAttribute("data-cluster-k8s-ns-filter");
|
||||
if (key) refillClusterK8sNsFilter(key);
|
||||
});
|
||||
clusterNsFiltersBound = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблицы Kubernetes из полей ``k8s_*`` ответа overview.
|
||||
* @param {object} d
|
||||
*/
|
||||
function fillClusterK8sTablesFromOverview(d) {
|
||||
lastClusterK8sOverview = d;
|
||||
const kubeErr = d.kubeconfig_error || null;
|
||||
const cname = d.cluster_name || getClusterPageName() || "";
|
||||
ensureClusterNsFilterListeners();
|
||||
|
||||
clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_namespaces,
|
||||
@@ -1258,53 +1444,20 @@
|
||||
document.getElementById("cluster-k8s-namespaces-root"),
|
||||
renderK8sNamespacesTable,
|
||||
);
|
||||
clusterK8sRenderBlock(d.k8s_pods, kubeErr, document.getElementById("cluster-k8s-pods-root"), function (root, items) {
|
||||
renderK8sPodsTable(root, items, cname);
|
||||
|
||||
const renderByKey = buildClusterK8sNsFilterRenderMap(cname);
|
||||
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
|
||||
clusterK8sRenderBlockNs(
|
||||
d[row.prop],
|
||||
kubeErr,
|
||||
document.getElementById(row.rootId),
|
||||
row.key,
|
||||
renderByKey[row.key],
|
||||
);
|
||||
});
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_deployments,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-deployments-root"),
|
||||
renderK8sDeploymentsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_statefulsets,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-statefulsets-root"),
|
||||
renderK8sStsDsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_daemonsets,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-daemonsets-root"),
|
||||
renderK8sStsDsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_replicasets,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-replicasets-root"),
|
||||
renderK8sReplicaSetsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(d.k8s_jobs, kubeErr, document.getElementById("cluster-k8s-jobs-root"), renderK8sJobsTable);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_cronjobs,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-cronjobs-root"),
|
||||
renderK8sCronJobsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_services,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-services-root"),
|
||||
renderK8sServicesTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_ingresses,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-ingresses-root"),
|
||||
renderK8sIngressesTable,
|
||||
);
|
||||
clusterK8sRenderBlock(d.k8s_pvcs, kubeErr, document.getElementById("cluster-k8s-pvcs-root"), renderK8sPvcsTable);
|
||||
|
||||
/* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */
|
||||
clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1506,43 +1659,57 @@
|
||||
|
||||
async function loadStats() {
|
||||
const grid = document.getElementById("stats-grid");
|
||||
if (!grid) return;
|
||||
const donutsRoot = document.getElementById("aggregate-donuts");
|
||||
const needGrid = !!grid;
|
||||
const needDonuts = !!donutsRoot;
|
||||
if (!needGrid && !needDonuts) return;
|
||||
try {
|
||||
const s = await api("/stats");
|
||||
revealStatsCardBody();
|
||||
cachedLocalClusterDirsCount = s.local_cluster_dirs_count;
|
||||
paintStatsEnvRow();
|
||||
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
|
||||
updateStatsEngineHint(cliForHint);
|
||||
const rows = [
|
||||
["Кластеров в kind", s.kind_clusters_count],
|
||||
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
|
||||
["Заданий в памяти", s.jobs_total],
|
||||
["Заданий с ошибкой", s.jobs_recent_failed],
|
||||
];
|
||||
grid.replaceChildren();
|
||||
rows.forEach(function (kv) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile";
|
||||
const valEl = document.createElement("span");
|
||||
valEl.className = "stats-tile__value";
|
||||
valEl.textContent = String(kv[1]);
|
||||
const labEl = document.createElement("span");
|
||||
labEl.className = "stats-tile__label";
|
||||
labEl.textContent = kv[0];
|
||||
tile.appendChild(valEl);
|
||||
tile.appendChild(labEl);
|
||||
grid.appendChild(tile);
|
||||
});
|
||||
if (needGrid) {
|
||||
revealStatsCardBody();
|
||||
paintStatsEnvRow();
|
||||
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
|
||||
updateStatsEngineHint(cliForHint);
|
||||
const dirVal =
|
||||
s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined
|
||||
? String(s.local_cluster_dirs_count)
|
||||
: "—";
|
||||
const rows = [
|
||||
["Каталогов", dirVal],
|
||||
["Кластеров в kind", s.kind_clusters_count],
|
||||
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
|
||||
["Заданий в памяти", s.jobs_total],
|
||||
["Заданий с ошибкой", s.jobs_recent_failed],
|
||||
["Аддонов", s.helm_addons_installable_count ?? 0],
|
||||
];
|
||||
grid.replaceChildren();
|
||||
rows.forEach(function (kv) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile";
|
||||
const valEl = document.createElement("span");
|
||||
valEl.className = "stats-tile__value";
|
||||
valEl.textContent = String(kv[1]);
|
||||
const labEl = document.createElement("span");
|
||||
labEl.className = "stats-tile__label";
|
||||
labEl.textContent = kv[0];
|
||||
tile.appendChild(valEl);
|
||||
tile.appendChild(labEl);
|
||||
grid.appendChild(tile);
|
||||
});
|
||||
}
|
||||
cachedClusterResourcesByName = {};
|
||||
renderAggregateDonuts(
|
||||
s.aggregate_cluster_resources,
|
||||
s.cluster_resources_error,
|
||||
s.container_cli || "docker",
|
||||
);
|
||||
if (needDonuts) {
|
||||
renderAggregateDonuts(
|
||||
s.aggregate_cluster_resources,
|
||||
s.cluster_resources_error,
|
||||
s.container_cli || "docker",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
coverStatsCardWithError("Статистика", e.message || String(e));
|
||||
grid.replaceChildren();
|
||||
if (needGrid) {
|
||||
coverStatsCardWithError("Статистика", e.message || String(e));
|
||||
grid.replaceChildren();
|
||||
}
|
||||
cachedClusterResourcesByName = {};
|
||||
const donuts = document.getElementById("aggregate-donuts");
|
||||
if (donuts) {
|
||||
@@ -1692,14 +1859,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function jobBadgeClass(status) {
|
||||
if (status === "success") return "badge badge-ok";
|
||||
if (status === "failed") return "badge badge-err";
|
||||
if (status === "running") return "badge badge-run";
|
||||
if (status === "cancelled") return "badge badge-cancelled";
|
||||
return "badge";
|
||||
}
|
||||
|
||||
async function loadClusters() {
|
||||
const tbody = document.querySelector("#tbl-clusters tbody");
|
||||
const msg = document.getElementById("list-msg");
|
||||
@@ -1768,124 +1927,145 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена).
|
||||
* Формат времени как на странице «Журнал» (ISO → «YYYY-MM-DD HH:MM:SS.sss Z»).
|
||||
* @param {string} iso
|
||||
*/
|
||||
async function clearCompletedJobs() {
|
||||
const ok = await openConfirmModal({
|
||||
title: "Очистить завершённые задания?",
|
||||
message:
|
||||
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
|
||||
confirmLabel: "Очистить",
|
||||
danger: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
function formatJournalUtcDisplay(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } });
|
||||
const text = await r.text();
|
||||
var data = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
if (!r.ok) {
|
||||
showToast(formatApiError(data, text || r.statusText), true);
|
||||
return;
|
||||
}
|
||||
var n = data.removed != null ? data.removed : 0;
|
||||
if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать.";
|
||||
showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false);
|
||||
await loadJobs();
|
||||
await loadStats();
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка: " + e.message;
|
||||
showToast(e.message, true);
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка).
|
||||
* Класс плашки статуса записи журнала (success / failed / cancelled).
|
||||
* @param {string} st
|
||||
*/
|
||||
function bindJobsTableJobLogOpen() {
|
||||
function journalStatusPillClass(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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрытие строки лога — как на /journal (режим заданий по кластеру).
|
||||
* Автор: Сергей Антропов — https://devops.org.ru
|
||||
*/
|
||||
function bindCreateJobsJournalToggle() {
|
||||
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");
|
||||
if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return;
|
||||
tbl.setAttribute("data-create-jobs-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);
|
||||
const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null;
|
||||
if (!btn || !tbl.contains(btn) || btn.disabled) return;
|
||||
const tr = btn.closest("tr.journal-data-row");
|
||||
if (!tr) return;
|
||||
const trDetails = tr.nextElementSibling;
|
||||
if (!trDetails || !trDetails.classList.contains("journal-details-row")) return;
|
||||
const pre = trDetails.querySelector("pre.journal-log-pre");
|
||||
if (!pre) return;
|
||||
const open = trDetails.classList.contains("hidden");
|
||||
trDetails.classList.toggle("hidden", !open);
|
||||
pre.hidden = !open;
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
btn.textContent = open ? "Скрыть лог" : "Показать лог";
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
|
||||
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
|
||||
*/
|
||||
async function loadJobs() {
|
||||
const tbody = document.querySelector("#tbl-jobs tbody");
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
if (!tbody) return;
|
||||
try {
|
||||
const rows = await api("/jobs?limit=30");
|
||||
const data = await api("/journal/recent?limit=30&offset=0");
|
||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
const frag = document.createDocumentFragment();
|
||||
rows.forEach(function (j) {
|
||||
entries.forEach(function (e) {
|
||||
const tr = document.createElement("tr");
|
||||
const st = escapeHtml(j.status || "");
|
||||
var kindTag = "";
|
||||
if (j.kind === "start_cluster") kindTag = "[старт] ";
|
||||
else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] ";
|
||||
else if (j.kind === "stop_containers") kindTag = "[стоп] ";
|
||||
else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
|
||||
var cellMsg = kindTag + (j.message || "").slice(0, 140);
|
||||
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
|
||||
cellMsg =
|
||||
kindTag +
|
||||
j.progress_stage +
|
||||
(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 timeLabel = formatJobTimeUtc(iso);
|
||||
tr.className = "journal-data-row";
|
||||
const cluster = e.source_cluster || e.cluster_name || "—";
|
||||
const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc);
|
||||
const kind = e.kind || "";
|
||||
const message = (e.message || "").trim();
|
||||
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
|
||||
const logText = lines.join("\n");
|
||||
const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
|
||||
|
||||
tr.innerHTML =
|
||||
"<td><time datetime=\"" +
|
||||
escapeHtml(iso) +
|
||||
"\" title=\"" +
|
||||
escapeHtml(iso ? "UTC: " + iso : "") +
|
||||
"\">" +
|
||||
escapeHtml(timeLabel) +
|
||||
"</time></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(j.cluster_name || "—") +
|
||||
"<td class=\"journal-cell-time\">" +
|
||||
escapeHtml(finished) +
|
||||
"</td>" +
|
||||
"<td><code class=\"journal-code\">" +
|
||||
escapeHtml(cluster) +
|
||||
"</code></td>" +
|
||||
"<td><code class=\"journal-code journal-cell-kind\">" +
|
||||
escapeHtml(kind) +
|
||||
"</code></td>" +
|
||||
"<td><span class=\"" +
|
||||
jobBadgeClass(j.status) +
|
||||
escapeHtml(journalStatusPillClass(e.status)) +
|
||||
"\">" +
|
||||
st +
|
||||
escapeHtml(e.status || "") +
|
||||
"</span></td>" +
|
||||
"<td class=\"jobs-msg-cell\">" +
|
||||
msgCol +
|
||||
"<td class=\"journal-cell-msg\">" +
|
||||
escapeHtml(message || "—") +
|
||||
"</td>" +
|
||||
"<td class=\"journal-cell-actions\">" +
|
||||
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
|
||||
escapeHtml(detailsId) +
|
||||
"\">Показать лог</button>" +
|
||||
"</td>";
|
||||
|
||||
frag.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=\"" +
|
||||
escapeHtml(detailsId) +
|
||||
"\" class=\"mono journal-log-pre\" hidden></pre>" +
|
||||
"</td>";
|
||||
const pre = trDetails.querySelector("pre");
|
||||
if (pre) pre.textContent = logText;
|
||||
frag.appendChild(trDetails);
|
||||
} else {
|
||||
const btn = tr.querySelector(".btn-journal-toggle");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Нет строк";
|
||||
}
|
||||
}
|
||||
});
|
||||
tbody.replaceChildren(frag);
|
||||
if (msg) {
|
||||
msg.textContent = rows.length
|
||||
? ""
|
||||
: "Заданий ещё не было (или контейнер перезапускали).";
|
||||
if (!entries.length) {
|
||||
msg.textContent =
|
||||
"Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами).";
|
||||
} else {
|
||||
const total = typeof data.total === "number" ? data.total : entries.length;
|
||||
msg.textContent =
|
||||
total > entries.length
|
||||
? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах."
|
||||
: "";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Задания: " + e.message;
|
||||
if (msg) msg.textContent = "Журнал заданий: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2298,8 +2478,10 @@
|
||||
async function refreshLists() {
|
||||
try {
|
||||
await loadHealth();
|
||||
if (isHomePage()) {
|
||||
if (isHomePage() || isClustersListPage()) {
|
||||
await loadStats();
|
||||
}
|
||||
if (isClustersListPage()) {
|
||||
await loadClusters();
|
||||
}
|
||||
if (isCreatePage()) {
|
||||
@@ -2390,15 +2572,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
const btnClearJobs = document.getElementById("btn-jobs-clear");
|
||||
if (btnClearJobs) {
|
||||
btnClearJobs.addEventListener("click", function () {
|
||||
clearCompletedJobs();
|
||||
const btnJobsRefresh = document.getElementById("btn-jobs-refresh");
|
||||
if (btnJobsRefresh) {
|
||||
btnJobsRefresh.addEventListener("click", function () {
|
||||
loadJobs();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.getElementById("tbl-jobs")) {
|
||||
bindJobsTableJobLogOpen();
|
||||
bindCreateJobsJournalToggle();
|
||||
}
|
||||
|
||||
const mClose = document.getElementById("modal-close");
|
||||
@@ -2486,6 +2668,9 @@
|
||||
await loadHealth();
|
||||
if (isHomePage()) {
|
||||
await loadStats();
|
||||
}
|
||||
if (isClustersListPage()) {
|
||||
await loadStats();
|
||||
await loadClusters();
|
||||
}
|
||||
if (isCreatePage()) {
|
||||
@@ -2502,8 +2687,12 @@
|
||||
if (isHomePage() && lm) {
|
||||
lm.textContent = "Ошибка загрузки панели: " + errText;
|
||||
}
|
||||
if (isClustersListPage() && lm) {
|
||||
lm.textContent = "Ошибка загрузки: " + errText;
|
||||
}
|
||||
} finally {
|
||||
hideHomePageLoadingOverlay();
|
||||
hideClustersPageLoadingOverlay();
|
||||
}
|
||||
})();
|
||||
if (isCreatePage()) {
|
||||
|
||||
+208
-114
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу).
|
||||
* Страница «Журнал»: режимы per_cluster / provision / helm_addons, пагинация по 30.
|
||||
*
|
||||
* Автор: Сергей Антропов
|
||||
* Сайт: https://devops.org.ru
|
||||
@@ -11,15 +11,14 @@
|
||||
if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return;
|
||||
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
/** Размер страницы (синхронно с default limit в API). */
|
||||
var PAGE_SIZE = 30;
|
||||
|
||||
/** Текущая страница с 1 (состояние для пагинации). */
|
||||
/** @type {'per_cluster' | 'provision' | 'helm_addons'} */
|
||||
var viewMode = "per_cluster";
|
||||
var currentPage = 1;
|
||||
/** Выбранный кластер в режиме per_cluster */
|
||||
var selectedCluster = "";
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
*/
|
||||
async function apiGet(path) {
|
||||
const url = path.startsWith("http") ? path : API + path;
|
||||
const r = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
@@ -50,8 +49,11 @@
|
||||
const tableWrap = document.getElementById("journal-table-wrap");
|
||||
const refreshBtn = document.getElementById("journal-refresh-btn");
|
||||
const pagNav = document.getElementById("journal-pagination");
|
||||
const clusterSelect = document.getElementById("journal-cluster-select");
|
||||
const clusterPicker = document.getElementById("journal-cluster-picker");
|
||||
const thKind = document.getElementById("journal-th-kind");
|
||||
const sectionTitle = document.getElementById("journal-table-section-title");
|
||||
|
||||
/** Скрыть полноэкранный спиннер первой загрузки (повторные вызовы безопасны). */
|
||||
function hideJournalPageLoadingOverlay() {
|
||||
const el = document.getElementById("journal-page-loading-overlay");
|
||||
if (!el) return;
|
||||
@@ -86,12 +88,6 @@
|
||||
return "journal-status-pill";
|
||||
}
|
||||
|
||||
/**
|
||||
* Список номеров страниц и многоточий для компактной навигации.
|
||||
* @param {number} current
|
||||
* @param {number} total
|
||||
* @returns {(number|string)[]}
|
||||
*/
|
||||
function visiblePageItems(current, total) {
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
@@ -115,9 +111,6 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} itemCount число строк на текущей странице (для подписи «записи N–M»)
|
||||
*/
|
||||
function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) {
|
||||
if (!pagNav) return;
|
||||
pagNav.innerHTML = "";
|
||||
@@ -141,20 +134,11 @@
|
||||
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);
|
||||
}),
|
||||
mkBtn("« Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }),
|
||||
);
|
||||
wrap.appendChild(
|
||||
mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () {
|
||||
onSelectPage(page - 1);
|
||||
}),
|
||||
mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
|
||||
);
|
||||
|
||||
const pagesEl = document.createElement("div");
|
||||
@@ -180,9 +164,7 @@
|
||||
if (pNum === page) b.setAttribute("aria-current", "page");
|
||||
else b.removeAttribute("aria-current");
|
||||
if (pNum !== page) {
|
||||
b.addEventListener("click", function () {
|
||||
onSelectPage(pNum);
|
||||
});
|
||||
b.addEventListener("click", function () { onSelectPage(pNum); });
|
||||
}
|
||||
pagesEl.appendChild(b);
|
||||
});
|
||||
@@ -190,27 +172,13 @@
|
||||
wrap.appendChild(pagesEl);
|
||||
|
||||
wrap.appendChild(
|
||||
mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () {
|
||||
onSelectPage(page + 1);
|
||||
}),
|
||||
mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
|
||||
);
|
||||
wrap.appendChild(
|
||||
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () {
|
||||
onSelectPage(totalPages);
|
||||
}),
|
||||
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) {
|
||||
@@ -220,15 +188,120 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} resetPage сбросить на 1 (после «Обновить»)
|
||||
* Построить строки таблицы: jobs (log_lines) или dir-лог (lines).
|
||||
*/
|
||||
function renderTableRows(entries, mode) {
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = "";
|
||||
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 kind = e.kind || "";
|
||||
const msg = (e.message || "").trim();
|
||||
let lines = [];
|
||||
if (mode === "per_cluster") {
|
||||
lines = Array.isArray(e.log_lines) ? e.log_lines : [];
|
||||
} else {
|
||||
lines = Array.isArray(e.lines) ? e.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 journal-cell-kind\">" + esc(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 = "Нет строк";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyViewLabels() {
|
||||
if (thKind) {
|
||||
if (viewMode === "per_cluster") thKind.textContent = "Тип задания";
|
||||
else thKind.textContent = "Вид операции";
|
||||
}
|
||||
if (sectionTitle) {
|
||||
if (viewMode === "per_cluster") sectionTitle.textContent = "Задания выбранного кластера";
|
||||
else if (viewMode === "provision") sectionTitle.textContent = "Развёртывание по кластерам";
|
||||
else sectionTitle.textContent = "Helm-аддоны по кластерам";
|
||||
}
|
||||
if (clusterPicker) {
|
||||
clusterPicker.classList.toggle("hidden", viewMode !== "per_cluster");
|
||||
}
|
||||
}
|
||||
|
||||
function buildListUrl() {
|
||||
const off = (currentPage - 1) * PAGE_SIZE;
|
||||
const q =
|
||||
"?limit=" + encodeURIComponent(String(PAGE_SIZE)) +
|
||||
"&offset=" + encodeURIComponent(String(off));
|
||||
if (viewMode === "per_cluster") {
|
||||
return "/journal/recent" + q + "&cluster=" + encodeURIComponent(selectedCluster);
|
||||
}
|
||||
if (viewMode === "provision") return "/journal/provision" + q;
|
||||
return "/journal/helm-addons" + q;
|
||||
}
|
||||
|
||||
async function loadJournal(resetPage) {
|
||||
if (!tbody || !statusEl) {
|
||||
hideJournalPageLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
applyViewLabels();
|
||||
|
||||
if (resetPage) currentPage = 1;
|
||||
const offset = (currentPage - 1) * PAGE_SIZE;
|
||||
|
||||
if (viewMode === "per_cluster" && !selectedCluster) {
|
||||
statusEl.textContent = "Выберите кластер.";
|
||||
tbody.innerHTML = "";
|
||||
if (emptyEl) {
|
||||
emptyEl.classList.remove("hidden");
|
||||
emptyEl.textContent = "Укажите кластер в списке выше.";
|
||||
}
|
||||
if (tableWrap) tableWrap.classList.add("hidden");
|
||||
if (pagNav) pagNav.classList.add("hidden");
|
||||
hideJournalPageLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = "Загрузка…";
|
||||
tbody.innerHTML = "";
|
||||
if (emptyEl) emptyEl.classList.add("hidden");
|
||||
@@ -236,25 +309,19 @@
|
||||
if (pagNav) pagNav.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const data = await apiGet(
|
||||
"/journal/recent?limit=" +
|
||||
encodeURIComponent(String(PAGE_SIZE)) +
|
||||
"&offset=" +
|
||||
encodeURIComponent(String(offset)),
|
||||
);
|
||||
const data = await apiGet(buildListUrl());
|
||||
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 respOffset = typeof data.offset === "number" ? data.offset : (currentPage - 1) * PAGE_SIZE;
|
||||
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) {
|
||||
if (total > 0 && currentPage > totalPages && totalPages > 0) {
|
||||
currentPage = totalPages;
|
||||
await loadJournal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) {
|
||||
currentPage = totalPages;
|
||||
await loadJournal(false);
|
||||
@@ -264,76 +331,40 @@
|
||||
page = currentPage;
|
||||
|
||||
if (!entries.length) {
|
||||
if (emptyEl) emptyEl.classList.remove("hidden");
|
||||
if (emptyEl) {
|
||||
emptyEl.classList.remove("hidden");
|
||||
emptyEl.textContent =
|
||||
viewMode === "per_cluster"
|
||||
? "Нет записей в journal/jobs_history.json для этого кластера."
|
||||
: viewMode === "provision"
|
||||
? "Нет файлов provision_log.json (или каталогов кластеров)."
|
||||
: "Нет файлов helm_addon_log.json — установите аддон со страницы «Аддоны».";
|
||||
}
|
||||
if (tableWrap) tableWrap.classList.add("hidden");
|
||||
statusEl.textContent =
|
||||
total === 0
|
||||
? "Нет записей в журналах."
|
||||
: "Страница пуста (всего записей: " + total + ").";
|
||||
? "Записей нет."
|
||||
: "Страница пуста (всего: " + total + ").";
|
||||
renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (emptyEl) emptyEl.classList.add("hidden");
|
||||
if (tableWrap) tableWrap.classList.remove("hidden");
|
||||
|
||||
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 = "Нет строк";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
renderTableRows(entries, viewMode);
|
||||
renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage);
|
||||
} catch (err) {
|
||||
statusEl.textContent = "Ошибка: " + err.message;
|
||||
if (emptyEl) emptyEl.classList.remove("hidden");
|
||||
if (emptyEl) {
|
||||
emptyEl.classList.remove("hidden");
|
||||
emptyEl.textContent = "Не удалось загрузить данные.";
|
||||
}
|
||||
if (tableWrap) tableWrap.classList.add("hidden");
|
||||
if (pagNav) pagNav.classList.add("hidden");
|
||||
} finally {
|
||||
@@ -341,7 +372,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", function () { loadJournal(true); });
|
||||
async function loadClusterList() {
|
||||
if (!clusterSelect) return;
|
||||
try {
|
||||
const rows = await apiGet("/clusters");
|
||||
clusterSelect.innerHTML = "";
|
||||
const names = rows
|
||||
.map(function (c) { return c.name; })
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
if (!names.length) {
|
||||
const o = document.createElement("option");
|
||||
o.value = "";
|
||||
o.textContent = "Нет каталогов кластеров";
|
||||
clusterSelect.appendChild(o);
|
||||
selectedCluster = "";
|
||||
return;
|
||||
}
|
||||
names.forEach(function (name) {
|
||||
const o = document.createElement("option");
|
||||
o.value = name;
|
||||
o.textContent = name;
|
||||
clusterSelect.appendChild(o);
|
||||
});
|
||||
if (!selectedCluster || names.indexOf(selectedCluster) < 0) {
|
||||
selectedCluster = names[0];
|
||||
}
|
||||
clusterSelect.value = selectedCluster;
|
||||
} catch (e) {
|
||||
clusterSelect.innerHTML = "";
|
||||
const o = document.createElement("option");
|
||||
o.value = "";
|
||||
o.textContent = "Ошибка загрузки списка";
|
||||
clusterSelect.appendChild(o);
|
||||
selectedCluster = "";
|
||||
}
|
||||
}
|
||||
|
||||
loadJournal(false);
|
||||
function bindViewRadios() {
|
||||
document.querySelectorAll('input[name="journal_view"]').forEach(function (radio) {
|
||||
radio.addEventListener("change", function () {
|
||||
if (!radio.checked) return;
|
||||
viewMode = radio.value;
|
||||
currentPage = 1;
|
||||
loadJournal(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (clusterSelect) {
|
||||
clusterSelect.addEventListener("change", function () {
|
||||
selectedCluster = String(clusterSelect.value || "").trim();
|
||||
currentPage = 1;
|
||||
if (viewMode === "per_cluster") loadJournal(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", function () { loadJournal(true); });
|
||||
}
|
||||
|
||||
bindViewRadios();
|
||||
|
||||
(async function init() {
|
||||
await loadClusterList();
|
||||
await loadJournal(false);
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -44,10 +44,59 @@
|
||||
setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует pathname: убирает хвостовые слэши (кроме корня «/»).
|
||||
* Нужен для сопоставления с маршрутами FastAPI без зависимости от редиректов.
|
||||
*/
|
||||
function normalizePathname(path) {
|
||||
var p = path || "/";
|
||||
if (p.length > 1 && p.charAt(p.length - 1) === "/") {
|
||||
p = p.replace(/\/+$/, "");
|
||||
}
|
||||
return p || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Какой пункт шапки (href пилюли) должен быть активен по текущему URL.
|
||||
* Дублирует логику nav_active из шаблонов: страницы кластера и /cluster/…/edit
|
||||
* относятся к разделу «Кластеры», чтобы подсветка не зависела только от SSR
|
||||
* (кэш образа, устаревший контейнер и т.п.).
|
||||
*/
|
||||
function desiredNavHrefFromPath() {
|
||||
var path = normalizePathname(window.location.pathname);
|
||||
if (path === "/") return "/";
|
||||
if (path === "/clusters") return "/clusters";
|
||||
if (/^\/cluster\/[^/]+\/edit$/.test(path)) return "/clusters";
|
||||
if (/^\/cluster\/[^/]+$/.test(path)) return "/clusters";
|
||||
if (path.indexOf("/cluster-addons") === 0) return "/cluster-addons";
|
||||
if (path.indexOf("/journal") === 0) return "/journal";
|
||||
if (path.indexOf("/documentation") === 0) return "/documentation";
|
||||
if (path.indexOf("/cluster-create") === 0) return "/clusters";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Снимает активность со всех пилюль в прокручиваемой полосе и включает одну по href. */
|
||||
function syncNavPillActive() {
|
||||
var want = desiredNavHrefFromPath();
|
||||
if (!want) return;
|
||||
var scroll = document.querySelector(".nav-links-scroll");
|
||||
if (!scroll) return;
|
||||
var pills = scroll.querySelectorAll("a.nav-link.nav-pill[href]");
|
||||
pills.forEach(function (a) {
|
||||
a.classList.remove("nav-pill--active");
|
||||
});
|
||||
pills.forEach(function (a) {
|
||||
if (a.getAttribute("href") === want) {
|
||||
a.classList.add("nav-pill--active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
var btn = document.getElementById("theme-toggle");
|
||||
if (btn) btn.addEventListener("click", toggleTheme);
|
||||
syncToggleButton();
|
||||
syncNavPillActive();
|
||||
}
|
||||
|
||||
/* defer: скрипт в <head> выполняется после разбора документа — кнопка уже есть. */
|
||||
|
||||
+296
-47
@@ -440,6 +440,16 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext)
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* Отдельная карточка на панели: донаты + список кластеров (GET /stats) */
|
||||
.home-resources-summary-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.home-resources-summary-card .donuts-card-title {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Пояснение: узлы kind и метрики идут через Docker или Podman (CONTAINER_CLI) */
|
||||
.stats-engine-hint {
|
||||
margin: 0;
|
||||
@@ -460,10 +470,10 @@ html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext)
|
||||
padding: 0.85rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Среда: Docker API · Kind · Kubectl · Каталогов — один ряд, 4 колонки */
|
||||
/* Среда: Docker API · Kind · Kubectl (3 в ряд); число каталогов — в сетке метрик под ними */
|
||||
.stats-env-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -513,10 +523,10 @@ html[data-theme="light"] .stats-env-card--metric {
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
}
|
||||
|
||||
/* Метрики: по 4 карточки в ряд (4 метрики — один ряд) */
|
||||
/* Метрики панели: по 3 карточки в ряд */
|
||||
.stats-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.65rem;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
@@ -524,7 +534,7 @@ html[data-theme="light"] .stats-env-card--metric {
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stats-metrics-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,6 +919,46 @@ html[data-theme="light"] .cluster-node-res-card__bar-track {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
/* Страница «Кластеры»: заголовок слева, кнопка «Создать кластер» справа */
|
||||
.clusters-page-hero-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem 1.5rem;
|
||||
}
|
||||
.clusters-page-hero-text {
|
||||
flex: 1 1 14rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.clusters-page-hero-lead {
|
||||
margin: 0;
|
||||
}
|
||||
.clusters-page-hero-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.clusters-page-create-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.6rem 1.35rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.14);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.clusters-page-create-btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Прогресс создания кластера */
|
||||
.create-progress-wrap {
|
||||
margin-top: 1rem;
|
||||
@@ -2170,6 +2220,31 @@ html[data-theme="light"] .cluster-kube-hint.cluster-kube-hint--info {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
/* Заголовок карточки K8s + выпадающий фильтр namespace справа */
|
||||
.cluster-k8s-card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
.cluster-k8s-card-header .cluster-detail-section-title {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.cluster-k8s-ns-filter-wrap {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cluster-k8s-ns-select {
|
||||
width: auto;
|
||||
min-width: 10rem;
|
||||
max-width: min(18rem, 100%);
|
||||
margin: 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* Сетка карточек узлов на странице кластера: не более 3 карточек в ряд */
|
||||
.cluster-page-node-cards--wide.cluster-nodes-resources__cards-row {
|
||||
@@ -2769,69 +2844,199 @@ html[data-theme="light"] .cluster-edit-yaml-preview-head {
|
||||
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;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.cluster-addons-status-bar--message .cluster-addons-status-msg {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cluster-addons-status-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.cluster-addons-status-card {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 3.5rem;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
html[data-theme="light"] .cluster-addons-status-card {
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.cluster-addons-status-card--on {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.cluster-addons-status-card--off {
|
||||
border-color: var(--border);
|
||||
opacity: 0.92;
|
||||
}
|
||||
.cluster-addons-status-card__label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
.cluster-addons-status-card__value {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.cluster-addons-status-card--on .cluster-addons-status-card__value {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
.cluster-addons-status-card--off .cluster-addons-status-card__value {
|
||||
color: var(--muted);
|
||||
}
|
||||
.cluster-addons-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@media (min-width: 52rem) {
|
||||
.cluster-addons-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
/* Шапка карточки аддона: заголовок + бейдж «установлен» справа */
|
||||
.cluster-addon-card__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.cluster-addon-card__title {
|
||||
margin: 0 0 0.35rem;
|
||||
.cluster-addon-card__header .cluster-addon-card__title {
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cluster-addon-card__badge {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.28rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cluster-addon-card__badge--on {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
color: rgb(34, 197, 94);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
.cluster-addon-card__badge--off {
|
||||
color: var(--muted);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
html[data-theme="light"] .cluster-addon-card__badge--off {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.cluster-addon-card__values-details {
|
||||
margin: 0.75rem 0 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
html[data-theme="light"] .cluster-addon-card__values-details {
|
||||
background: rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
.cluster-addon-card__values-summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
list-style-position: outside;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
.cluster-addon-card__values-hint {
|
||||
margin: 0.5rem 0 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cluster-addon-card__values-subhead {
|
||||
margin: 0.65rem 0 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.cluster-addon-card__values-details .cluster-addon-card__values-subhead:first-of-type {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.cluster-addon-card__values-textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-height: 6rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
html[data-theme="light"] .cluster-addon-card__values-textarea {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.cluster-addon-card__desc {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cluster-addon-card__actions {
|
||||
.cluster-addon-card__actions--equal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
margin-top: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.cluster-addon-card__actions--equal .btn-addon-install,
|
||||
.cluster-addon-card__actions--equal .btn-addon-remove {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 2.65rem;
|
||||
margin-top: 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.cluster-addon-card--wide {
|
||||
grid-column: 1 / -1;
|
||||
grid-column: 1;
|
||||
}
|
||||
.cluster-addon-istio-grid {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.cluster-addons-log-card {
|
||||
/* Карточка хода Helm на странице аддонов (разметка как у create-progress-wrap + job-log-panel) */
|
||||
.cluster-addons-helm-progress {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.cluster-addons-log-pre {
|
||||
.cluster-addons-helm-progress .job-log-panel {
|
||||
max-height: min(55vh, 420px);
|
||||
}
|
||||
.cluster-addons-bootstrap-msg {
|
||||
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;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Версии чартов на странице аддонов */
|
||||
@@ -2930,8 +3135,58 @@ html[data-theme="light"] .journal-details-cell {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.journal-page-size-hint {
|
||||
/* Три сегмента на странице журнала — шире блок переключателя */
|
||||
.journal-toolbar-card .cluster-edit-segmented {
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.journal-toolbar-card .cluster-edit-segmented {
|
||||
flex-wrap: nowrap;
|
||||
max-width: 56rem;
|
||||
}
|
||||
}
|
||||
.journal-view-fieldset {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.journal-cluster-picker {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.journal-cluster-picker.hidden {
|
||||
display: none;
|
||||
}
|
||||
.journal-cluster-picker-label {
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.journal-cluster-select {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.journal-cluster-picker-hint {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.84rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.journal-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
.journal-cell-kind {
|
||||
word-break: break-word;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
/* Пагинация журнала */
|
||||
@@ -3000,12 +3255,6 @@ html[data-theme="light"] .journal-details-cell {
|
||||
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;
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
{# Прокрутка только у пилюль: overflow-x на nav обрезал бы выпадающее меню API (position: absolute). #}
|
||||
<div class="nav-links-scroll">
|
||||
<a href="/" class="nav-link nav-pill{% if nav_active|default('') == 'panel' %} nav-pill--active{% endif %}">Панель</a>
|
||||
<a href="/clusters" class="nav-link nav-pill{% if nav_active|default('') == 'clusters' %} 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>
|
||||
|
||||
@@ -38,46 +38,66 @@
|
||||
<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">
|
||||
<label for="addons-cluster-select" class="cluster-addons-cluster-label">Выберите кластер для просмотра статуса аддонов.</label>
|
||||
<select id="addons-cluster-select" class="cluster-addons-cluster-select" aria-label="Выберите кластер для просмотра статуса аддонов">
|
||||
<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>
|
||||
<div id="addons-status-bar" class="cluster-addons-status-bar" role="status" aria-live="polite"></div>
|
||||
<p id="addons-bootstrap-msg" class="cluster-addons-bootstrap-msg muted hidden" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<div
|
||||
id="addons-cards-wrap"
|
||||
class="cluster-addons-cards-wrap hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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>
|
||||
<header class="cluster-addon-card__header">
|
||||
<h2 class="cluster-addon-card__title">ingress-nginx</h2>
|
||||
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
|
||||
</header>
|
||||
<p class="muted cluster-addon-card__desc">Контроллер Ingress.</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">
|
||||
<select id="addons-ing-version-select" class="cluster-addons-version-select" aria-label="Версия чарта ingress-nginx">
|
||||
<option value="">Последняя (из репозитория)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cluster-addon-card__actions row">
|
||||
<details class="cluster-addon-card__values-details">
|
||||
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
|
||||
<p class="muted cluster-addon-card__values-hint">
|
||||
Содержимое из <code>helm show values</code> плюс сервис для kind; подставляется при смене версии чарта. После <code>-f</code> Helm применяет <code>--set</code> для NodePort.
|
||||
</p>
|
||||
<label for="addons-values-yaml-ingress-nginx" class="sr-only">Полные YAML values для ingress-nginx</label>
|
||||
<textarea
|
||||
id="addons-values-yaml-ingress-nginx"
|
||||
class="cluster-addon-card__values-textarea mono"
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
aria-label="Полные YAML values чарта ingress-nginx"
|
||||
></textarea>
|
||||
</details>
|
||||
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
|
||||
<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>
|
||||
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="ingress-nginx" hidden>Удалить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-addon-card" data-addon="kube-prometheus-stack">
|
||||
<h2 class="cluster-addon-card__title">kube-prometheus-stack</h2>
|
||||
<header class="cluster-addon-card__header">
|
||||
<h2 class="cluster-addon-card__title">kube-prometheus-stack</h2>
|
||||
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
|
||||
</header>
|
||||
<p class="muted cluster-addon-card__desc">Prometheus Operator, Alertmanager, Grafana.</p>
|
||||
<div class="create-form-field">
|
||||
<label for="addons-grafana-user">Логин администратора Grafana</label>
|
||||
@@ -93,14 +113,31 @@
|
||||
<option value="">Последняя (из репозитория)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cluster-addon-card__actions row">
|
||||
<details class="cluster-addon-card__values-details">
|
||||
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
|
||||
<p class="muted cluster-addon-card__values-hint">
|
||||
Вывод <code>helm show values</code> с подставленными логином и паролем Grafana из полей выше; обновляется при смене версии или учётных данных. Пустое поле при установке — сервер соберёт values сам.
|
||||
</p>
|
||||
<label for="addons-values-yaml-kube-prometheus-stack" class="sr-only">Полные YAML values для kube-prometheus-stack</label>
|
||||
<textarea
|
||||
id="addons-values-yaml-kube-prometheus-stack"
|
||||
class="cluster-addon-card__values-textarea mono"
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
aria-label="Полные YAML values чарта kube-prometheus-stack"
|
||||
></textarea>
|
||||
</details>
|
||||
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
|
||||
<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>
|
||||
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="kube-prometheus-stack" hidden>Удалить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-addon-card" data-addon="metrics-server">
|
||||
<h2 class="cluster-addon-card__title">metrics-server</h2>
|
||||
<header class="cluster-addon-card__header">
|
||||
<h2 class="cluster-addon-card__title">metrics-server</h2>
|
||||
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
|
||||
</header>
|
||||
<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>
|
||||
@@ -108,14 +145,31 @@
|
||||
<option value="">Последняя (из репозитория)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cluster-addon-card__actions row">
|
||||
<details class="cluster-addon-card__values-details">
|
||||
<summary class="cluster-addon-card__values-summary">Values чарта (YAML)</summary>
|
||||
<p class="muted cluster-addon-card__values-hint">
|
||||
<code>helm show values</code> плюс аргументы для kind (<code>--kubelet-insecure-tls</code> и т.д.). Пустое поле — автосборка на сервере.
|
||||
</p>
|
||||
<label for="addons-values-yaml-metrics-server" class="sr-only">Полные YAML values для metrics-server</label>
|
||||
<textarea
|
||||
id="addons-values-yaml-metrics-server"
|
||||
class="cluster-addon-card__values-textarea mono"
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
aria-label="Полные YAML values чарта metrics-server"
|
||||
></textarea>
|
||||
</details>
|
||||
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
|
||||
<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>
|
||||
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="metrics-server" hidden>Удалить</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>
|
||||
<header class="cluster-addon-card__header">
|
||||
<h2 class="cluster-addon-card__title">Istio + Kiali</h2>
|
||||
<span class="cluster-addon-card__badge cluster-addon-card__badge--off" role="status">Не установлен</span>
|
||||
</header>
|
||||
<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> (секрет с логином и паролем).
|
||||
@@ -142,32 +196,83 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cluster-addon-card__actions row">
|
||||
<details class="cluster-addon-card__values-details">
|
||||
<summary class="cluster-addon-card__values-summary">Values чартов Istio и Kiali (YAML)</summary>
|
||||
<p class="muted cluster-addon-card__values-hint">
|
||||
Три блока из <code>helm show values</code> для выбранных версий. Логин и пароль Kiali вносятся в секрет Kubernetes отдельно; для kiali-server дополнительно задаётся <code>auth.strategy=login</code> через <code>--set</code>.
|
||||
</p>
|
||||
<p class="cluster-addon-card__values-subhead">kiali-server</p>
|
||||
<label for="addons-values-yaml-istio-kiali" class="sr-only">YAML values kiali-server</label>
|
||||
<textarea
|
||||
id="addons-values-yaml-istio-kiali"
|
||||
class="cluster-addon-card__values-textarea mono"
|
||||
rows="10"
|
||||
spellcheck="false"
|
||||
aria-label="Полные YAML values чарта kiali-server"
|
||||
></textarea>
|
||||
<p class="cluster-addon-card__values-subhead">istio/base</p>
|
||||
<label for="addons-values-yaml-istio-base" class="sr-only">YAML values istio-base</label>
|
||||
<textarea
|
||||
id="addons-values-yaml-istio-base"
|
||||
class="cluster-addon-card__values-textarea mono"
|
||||
rows="10"
|
||||
spellcheck="false"
|
||||
aria-label="Полные YAML values чарта istio/base"
|
||||
></textarea>
|
||||
<p class="cluster-addon-card__values-subhead">istio/istiod</p>
|
||||
<label for="addons-values-yaml-istio-istiod" class="sr-only">YAML values istiod</label>
|
||||
<textarea
|
||||
id="addons-values-yaml-istio-istiod"
|
||||
class="cluster-addon-card__values-textarea mono"
|
||||
rows="10"
|
||||
spellcheck="false"
|
||||
aria-label="Полные YAML values чарта istio/istiod"
|
||||
></textarea>
|
||||
</details>
|
||||
<div class="cluster-addon-card__actions row cluster-addon-card__actions--equal">
|
||||
<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>
|
||||
<button type="button" class="btn-secondary btn-addon-remove hidden" data-endpoint="istio-kiali" hidden>Удалить</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</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>
|
||||
{# Журнал операции Helm: те же блоки, что на странице создания кластера (прогресс + job-log-panel). Без полноэкранного спиннера. #}
|
||||
<section
|
||||
id="addons-helm-progress-wrap"
|
||||
class="card create-progress-wrap cluster-addons-helm-progress hidden"
|
||||
aria-hidden="true"
|
||||
aria-labelledby="addons-helm-progress-title"
|
||||
>
|
||||
<h2 class="create-cluster-card__title" id="addons-helm-progress-title">Ход операции Helm</h2>
|
||||
<p class="create-progress-hint muted" id="addons-helm-progress-hint">
|
||||
Выполняется команда в контейнере веб-приложения; полный вывод появится по завершении.
|
||||
</p>
|
||||
<div
|
||||
class="progress-track"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
aria-labelledby="addons-helm-progress-label"
|
||||
id="addons-helm-progress-track"
|
||||
>
|
||||
<div class="progress-bar" id="addons-helm-progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
<p class="progress-label" id="addons-helm-progress-label">—</p>
|
||||
<div class="job-log-panel-wrap">
|
||||
<label for="addons-helm-log-panel" class="job-log-label muted">Журнал установки / удаления</label>
|
||||
<pre
|
||||
id="addons-helm-log-panel"
|
||||
class="mono job-log-panel"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
></pre>
|
||||
</div>
|
||||
</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>
|
||||
{% include "partials/dashboard_modals.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
||||
@@ -119,16 +119,18 @@
|
||||
<section class="card jobs-card">
|
||||
<div class="row spread jobs-card-toolbar">
|
||||
<h2 id="jobs-heading" class="jobs-card-title">Последние задания</h2>
|
||||
<button type="button" id="btn-jobs-clear" class="btn-secondary">Очистить завершённые</button>
|
||||
<button type="button" id="btn-jobs-refresh" class="btn-secondary">Обновить</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl-jobs" aria-label="Последние задания создания">
|
||||
<div class="table-wrap journal-table-wrap">
|
||||
<table id="tbl-jobs" class="journal-table" aria-label="Последние задания из журналов кластеров">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата, время <span class="th-hint">(UTC)</span></th>
|
||||
<th>Кластер</th>
|
||||
<th>Статус</th>
|
||||
<th>Сообщение</th>
|
||||
<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></tbody>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
|
||||
<a href="/">Панель</a>
|
||||
<a href="/clusters">Кластеры</a>
|
||||
<span aria-hidden="true"> / </span>
|
||||
<span>Кластер <code>{{ cluster_name | e }}</code></span>
|
||||
</p>
|
||||
@@ -78,7 +78,9 @@
|
||||
<h2 id="cluster-k8s-heading" class="cluster-detail-section-title">Kubernetes (kubectl)</h2>
|
||||
<p class="muted cluster-k8s-scope-hint">
|
||||
Данные по <strong>всему кластеру</strong>: ресурсы в namespace — со всех пространств имён; узлы и список
|
||||
namespace — без ограничения по отдельному ns.
|
||||
namespace — без ограничения по отдельному ns. В карточках ресурсов выпадающий список справа от заголовка
|
||||
содержит все namespace из ответа API и из фактических объектов в таблицах; при выборе «Все namespace» строки
|
||||
таблицы упорядочены по namespace, затем по имени ресурса.
|
||||
</p>
|
||||
<p id="cluster-kube-global-hint" class="muted cluster-kube-hint hidden" role="status"></p>
|
||||
</section>
|
||||
@@ -94,52 +96,182 @@
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pods-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-pods-title">Поды</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-pods" class="sr-only">Поды: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-pods"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="pods"
|
||||
aria-label="Поды: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-pods-root" class="cluster-k8s-root" role="region" aria-label="Поды"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-deploy-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-deploy-title">Deployments</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-deployments" class="sr-only">Deployments: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-deployments"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="deployments"
|
||||
aria-label="Deployments: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-deployments-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-sts-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-sts-title">StatefulSets</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-statefulsets" class="sr-only">StatefulSets: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-statefulsets"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="statefulsets"
|
||||
aria-label="StatefulSets: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-statefulsets-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ds-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-ds-title">DaemonSets</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-daemonsets" class="sr-only">DaemonSets: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-daemonsets"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="daemonsets"
|
||||
aria-label="DaemonSets: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-daemonsets-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-rs-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-rs-title">ReplicaSets</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-replicasets" class="sr-only">ReplicaSets: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-replicasets"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="replicasets"
|
||||
aria-label="ReplicaSets: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-replicasets-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-jobs-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-jobs-title">Jobs</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-jobs" class="sr-only">Jobs: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-jobs"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="jobs"
|
||||
aria-label="Jobs: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-jobs-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-cj-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-cj-title">CronJobs</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-cronjobs" class="sr-only">CronJobs: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-cronjobs"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="cronjobs"
|
||||
aria-label="CronJobs: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-cronjobs-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-svc-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-svc-title">Сервисы</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-services" class="sr-only">Сервисы: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-services"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="services"
|
||||
aria-label="Сервисы: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-services-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-ing-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-ing-title">Ingresses</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-ingresses" class="sr-only">Ingresses: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-ingresses"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="ingresses"
|
||||
aria-label="Ingresses: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-ingresses-root" class="cluster-k8s-root" role="region"></div>
|
||||
</section>
|
||||
|
||||
<section class="card cluster-k8s-resource-card" aria-labelledby="k8s-pvc-title">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2>
|
||||
<div class="cluster-k8s-card-header">
|
||||
<h2 class="cluster-detail-section-title" id="k8s-pvc-title">PVC</h2>
|
||||
<div class="cluster-k8s-ns-filter-wrap">
|
||||
<label for="cluster-k8s-filter-pvcs" class="sr-only">PVC: фильтр по namespace</label>
|
||||
<select
|
||||
id="cluster-k8s-filter-pvcs"
|
||||
class="cluster-k8s-ns-select"
|
||||
data-cluster-k8s-ns-filter="pvcs"
|
||||
aria-label="PVC: фильтр по namespace"
|
||||
>
|
||||
<option value="">Все namespace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cluster-k8s-pvcs-root" class="cluster-k8s-root" role="region" aria-label="PersistentVolumeClaim"></div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
{% block content %}
|
||||
<p class="cluster-detail-breadcrumb muted" aria-label="Навигация">
|
||||
<a href="/">Панель</a>
|
||||
<a href="/clusters">Кластеры</a>
|
||||
<span aria-hidden="true"> / </span>
|
||||
<a href="/cluster/{{ cluster_name }}">Кластер <code>{{ cluster_name | e }}</code></a>
|
||||
<span aria-hidden="true"> / </span>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{# Список кластеров и сводка ресурсов узлов (перенесено с панели).
|
||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_title %}Кластеры{% endblock %}
|
||||
|
||||
{% block body_extra_class %} dashboard-clusters-loading{% endblock %}
|
||||
|
||||
{% block body_attrs %}data-dashboard-mode="clusters"{% 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 %}
|
||||
<div
|
||||
id="clusters-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 clusters-page-hero" aria-labelledby="clusters-page-title">
|
||||
<div class="clusters-page-hero-row">
|
||||
<div class="clusters-page-hero-text">
|
||||
<h1 class="page-title" id="clusters-page-title">Кластеры</h1>
|
||||
<p class="muted page-lead clusters-page-hero-lead">
|
||||
Запущенные узлы kind, kubeconfig и действия.
|
||||
</p>
|
||||
</div>
|
||||
<div class="clusters-page-hero-actions">
|
||||
<a href="/cluster-create" class="clusters-page-create-btn">Создать кластер</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="donuts-heading">
|
||||
<h2 id="donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
|
||||
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="card clusters-card">
|
||||
<h2>Список кластеров</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl-clusters" aria-label="Список кластеров">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-cluster-name">Имя</th>
|
||||
<th class="col-kind">kind</th>
|
||||
<th class="col-kubeconfig">kubeconfig</th>
|
||||
<th class="col-version">Версия</th>
|
||||
<th class="col-workers">Workers</th>
|
||||
<th class="col-state">Состояние</th>
|
||||
<th class="col-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="list-msg" class="msg muted"></p>
|
||||
</section>
|
||||
|
||||
{% include "partials/dashboard_modals.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,4 @@
|
||||
{# Главная: создание (CTA), статистика, донаты, таблица кластеров.
|
||||
{# Главная: CTA, статистика, карточка «Ресурсы узлов» (только донаты); список кластеров на /clusters.
|
||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="cta-hero-inner">
|
||||
<h1 class="page-title" id="cta-hero-title">Создать кластер</h1>
|
||||
<p class="muted page-lead cta-hero-lead">
|
||||
Кластеры kind, kubeconfig и kubectl — ниже на странице. Данные обновляются автоматически.
|
||||
Список кластеров, сводка по узлам и действия — на странице <a href="/clusters">Кластеры</a>. Данные там обновляются автоматически.
|
||||
</p>
|
||||
<a href="/cluster-create" class="btn btn-cta-large">Перейти к форме создания</a>
|
||||
</div>
|
||||
@@ -53,30 +53,15 @@
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section id="aggregate-donuts-section" class="card aggregate-donuts-card" aria-labelledby="donuts-heading">
|
||||
<h2 id="donuts-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
|
||||
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section class="card clusters-card">
|
||||
<h2>Кластеры</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="tbl-clusters" aria-label="Список кластеров">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-cluster-name">Имя</th>
|
||||
<th class="col-kind">kind</th>
|
||||
<th class="col-kubeconfig">kubeconfig</th>
|
||||
<th class="col-version">Версия</th>
|
||||
<th class="col-workers">Workers</th>
|
||||
<th class="col-state">Состояние</th>
|
||||
<th class="col-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<section
|
||||
id="home-resources-summary-card"
|
||||
class="card aggregate-donuts-card home-resources-summary-card"
|
||||
aria-labelledby="home-resources-summary-heading"
|
||||
>
|
||||
<div id="home-resources-summary-wrap" class="home-resources-summary-inner">
|
||||
<h2 id="home-resources-summary-heading" class="donuts-card-title">Ресурсы узлов (сводка)</h2>
|
||||
<div id="aggregate-donuts" class="aggregate-donuts" aria-live="polite"></div>
|
||||
</div>
|
||||
<p id="list-msg" class="msg muted"></p>
|
||||
</section>
|
||||
|
||||
{% include "partials/dashboard_modals.html" %}
|
||||
|
||||
+79
-15
@@ -1,4 +1,5 @@
|
||||
{# Агрегированный журнал завершённых заданий (JSON в каталогах кластеров).
|
||||
{# Журналы: задания по кластеру, развёртывание (provision_log), Helm (helm_addon_log).
|
||||
Переключатель режима — как «Простой / Расширенный» на странице редактирования кластера.
|
||||
Автор: Сергей Антропов — https://devops.org.ru #}
|
||||
{% extends "base.html" %}
|
||||
|
||||
@@ -18,7 +19,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Полноэкранный спиннер до первой загрузки журнала; скрывается в journal.js #}
|
||||
<div
|
||||
id="journal-page-loading-overlay"
|
||||
class="page-loading-overlay"
|
||||
@@ -35,36 +35,100 @@
|
||||
</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>
|
||||
<h1 class="page-title" id="journal-hero-title">Журналы</h1>
|
||||
</section>
|
||||
|
||||
<section class="card create-cluster-card journal-toolbar-card">
|
||||
<h2 class="create-cluster-card__title">Действия</h2>
|
||||
<div class="journal-toolbar-row">
|
||||
<h2 class="create-cluster-card__title">Вид журнала</h2>
|
||||
|
||||
<fieldset class="cluster-edit-mode-fieldset journal-view-fieldset">
|
||||
<legend class="cluster-edit-mode-legend">Режим просмотра</legend>
|
||||
<div
|
||||
class="cluster-edit-segmented"
|
||||
role="radiogroup"
|
||||
aria-label="Какой журнал показывать"
|
||||
>
|
||||
<div class="cluster-edit-segment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="journal_view"
|
||||
id="journal-seg-per-cluster"
|
||||
class="cluster-edit-segment-native"
|
||||
value="per_cluster"
|
||||
checked
|
||||
/>
|
||||
<label for="journal-seg-per-cluster" class="cluster-edit-segment-label">
|
||||
<span class="cluster-edit-segment-title">По кластеру</span>
|
||||
<span class="cluster-edit-segment-sub-wrap">
|
||||
<span class="cluster-edit-segment-sub">История заданий</span>
|
||||
<span class="cluster-edit-segment-sub">create / start / stop</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="cluster-edit-segment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="journal_view"
|
||||
id="journal-seg-provision"
|
||||
class="cluster-edit-segment-native"
|
||||
value="provision"
|
||||
/>
|
||||
<label for="journal-seg-provision" class="cluster-edit-segment-label">
|
||||
<span class="cluster-edit-segment-title">Развёртывание</span>
|
||||
<span class="cluster-edit-segment-sub-wrap">
|
||||
<span class="cluster-edit-segment-sub">Последний provision_log</span>
|
||||
<span class="cluster-edit-segment-sub">по каждому кластеру</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="cluster-edit-segment-item">
|
||||
<input
|
||||
type="radio"
|
||||
name="journal_view"
|
||||
id="journal-seg-helm"
|
||||
class="cluster-edit-segment-native"
|
||||
value="helm_addons"
|
||||
/>
|
||||
<label for="journal-seg-helm" class="cluster-edit-segment-label">
|
||||
<span class="cluster-edit-segment-title">Helm-аддоны</span>
|
||||
<span class="cluster-edit-segment-sub-wrap">
|
||||
<span class="cluster-edit-segment-sub">История операций Helm</span>
|
||||
<span class="cluster-edit-segment-sub">install / delete</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div id="journal-cluster-picker" class="journal-cluster-picker">
|
||||
<label for="journal-cluster-select" class="journal-cluster-picker-label">Кластер</label>
|
||||
<select id="journal-cluster-select" class="journal-cluster-select" aria-describedby="journal-cluster-picker-hint">
|
||||
<option value="">— загрузка —</option>
|
||||
</select>
|
||||
<p id="journal-cluster-picker-hint" class="muted journal-cluster-picker-hint">
|
||||
Журнал заданий только для выбранного каталога в <code>clusters/</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="journal-toolbar-actions">
|
||||
<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>
|
||||
<h2 class="create-cluster-card__title" id="journal-table-section-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" id="journal-th-kind">Тип / операция</th>
|
||||
<th scope="col">Статус</th>
|
||||
<th scope="col">Сообщение</th>
|
||||
<th scope="col">Журнал</th>
|
||||
<th scope="col">Лог</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="journal-tbody"></tbody>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Журнал фонового задания (ошибка / отмена) — страница «Последние задания». #}
|
||||
{# Журнал задания по API GET /jobs/{id} (модалка при необходимости из скриптов). #}
|
||||
<div
|
||||
id="job-log-modal-overlay"
|
||||
class="modal-overlay job-log-modal-overlay hidden"
|
||||
|
||||
Reference in New Issue
Block a user