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

- job_journal: collect_recent_journal_entries_page_sync(limit, offset)
- GET /api/v1/journal/recent: limit по умолчанию 30, offset, total, page, total_pages
- journal.html/js: навигация Первая/Назад/номера/Вперёд/Последняя, стили
- app/docs/api_routes.md: описание query и пример ответа
- Прочие изменения UI/API (аддоны, helm, job_journal в кластерах) в том же коммите
This commit is contained in:
Sergey Antropoff
2026-04-04 11:16:43 +03:00
parent 52538d9816
commit 17f6233fd7
22 changed files with 2811 additions and 4 deletions
+267
View File
@@ -0,0 +1,267 @@
"""Helm-аддоны для выбранного кластера: ingress-nginx, kube-prometheus-stack, metrics-server, Istio+Kiali.
Операции синхронные (долгие таймауты — см. ``KIND_K8S_HELM_TIMEOUT_SEC``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Body, HTTPException
from core.cluster_lifecycle import validate_cluster_name
from core.helm_addons import (
HelmAddonError,
addon_status,
require_helm_binary,
install_ingress_nginx,
install_istio_and_kiali,
install_kube_prometheus_stack,
install_metrics_server,
uninstall_ingress_nginx,
uninstall_istio_and_kiali,
uninstall_kube_prometheus_stack,
uninstall_metrics_server,
)
from core.kind_guard import kind_cluster_lock
from models.schemas import (
ClusterAddonsStatusResponse,
HelmAddonApplyResponse,
IngressNginxInstallRequest,
IstioKialiInstallRequest,
KubePrometheusStackInstallRequest,
MetricsServerInstallRequest,
)
logger = logging.getLogger("kind_k8s.api.cluster_addons")
router = APIRouter(tags=["cluster-addons"])
_LOG_MAX = 24000
def _trim_log(s: str) -> str:
if len(s) <= _LOG_MAX:
return s
return s[: _LOG_MAX - 20] + "\n… (обрезано)"
def _map_helm_error(e: HelmAddonError) -> HTTPException:
msg = str(e)
if e.exit_code == 127:
return HTTPException(status_code=503, detail=msg)
return HTTPException(status_code=500, detail=msg)
@router.get(
"/clusters/{name}/addons/status",
response_model=ClusterAddonsStatusResponse,
summary="Статус Helm-аддонов в кластере",
)
async def get_cluster_addons_status(name: str) -> ClusterAddonsStatusResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
try:
require_helm_binary()
except HelmAddonError as e:
raise _map_helm_error(e) from e
try:
st = await asyncio.to_thread(addon_status, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
mesh = bool(st.get("istio_base") and st.get("istiod") and st.get("kiali"))
return ClusterAddonsStatusResponse(
cluster_name=n,
ingress_nginx=bool(st.get("ingress_nginx")),
kube_prometheus_stack=bool(st.get("kube_prometheus_stack")),
metrics_server=bool(st.get("metrics_server")),
istio_base=bool(st.get("istio_base")),
istiod=bool(st.get("istiod")),
kiali_server=bool(st.get("kiali")),
istio_mesh_ready=mesh,
)
@router.post(
"/clusters/{name}/addons/ingress-nginx",
response_model=HelmAddonApplyResponse,
summary="Установить ingress-nginx (Helm)",
)
async def post_install_ingress_nginx(
name: str,
body: IngressNginxInstallRequest | None = Body(default=None),
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_ingress_nginx, n, ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен ingress-nginx для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="ingress-nginx установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/ingress-nginx",
response_model=HelmAddonApplyResponse,
summary="Удалить ingress-nginx",
)
async def delete_ingress_nginx(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_ingress_nginx, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="ingress-nginx удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/kube-prometheus-stack",
response_model=HelmAddonApplyResponse,
summary="Установить kube-prometheus-stack (Prometheus Operator + Grafana)",
)
async def post_install_kube_prometheus_stack(
name: str,
body: KubePrometheusStackInstallRequest,
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
prom_ver: str | None = None
if body.chart_version and body.chart_version.strip():
prom_ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_kube_prometheus_stack,
n,
grafana_admin_user=body.grafana_admin_user.strip(),
grafana_admin_password=body.grafana_admin_password,
chart_version=prom_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен kube-prometheus-stack для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/kube-prometheus-stack",
response_model=HelmAddonApplyResponse,
summary="Удалить kube-prometheus-stack",
)
async def delete_kube_prometheus_stack(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_kube_prometheus_stack, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="kube-prometheus-stack удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/metrics-server",
response_model=HelmAddonApplyResponse,
summary="Установить metrics-server",
)
async def post_install_metrics_server(
name: str,
body: MetricsServerInstallRequest | None = Body(default=None),
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
ms_ver: str | None = None
if body is not None and body.chart_version and body.chart_version.strip():
ms_ver = body.chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(install_metrics_server, n, ms_ver)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлен metrics-server для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="metrics-server установлен.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/metrics-server",
response_model=HelmAddonApplyResponse,
summary="Удалить metrics-server",
)
async def delete_metrics_server(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_metrics_server, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="metrics-server удалён.", log=_trim_log(log))
@router.post(
"/clusters/{name}/addons/istio-kiali",
response_model=HelmAddonApplyResponse,
summary="Установить Istio (base+istiod) и Kiali с логином",
)
async def post_install_istio_kiali(
name: str,
body: IstioKialiInstallRequest,
) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
istio_ver: str | None = None
if body.istio_chart_version and body.istio_chart_version.strip():
istio_ver = body.istio_chart_version.strip()
kiali_ver: str | None = None
if body.kiali_chart_version and body.kiali_chart_version.strip():
kiali_ver = body.kiali_chart_version.strip()
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(
install_istio_and_kiali,
n,
kiali_username=body.kiali_username.strip(),
kiali_password=body.kiali_password,
istio_chart_version=istio_ver,
kiali_chart_version=kiali_ver,
)
except HelmAddonError as e:
raise _map_helm_error(e) from e
logger.info("Установлены Istio+Kiali для кластера «%s»", n)
return HelmAddonApplyResponse(ok=True, message="Istio и Kiali установлены.", log=_trim_log(log))
@router.delete(
"/clusters/{name}/addons/istio-kiali",
response_model=HelmAddonApplyResponse,
summary="Удалить Kiali, istiod и istio-base",
)
async def delete_istio_kiali(name: str) -> HelmAddonApplyResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
async with kind_cluster_lock:
try:
_, log = await asyncio.to_thread(uninstall_istio_and_kiali, n)
except HelmAddonError as e:
raise _map_helm_error(e) from e
return HelmAddonApplyResponse(ok=True, message="Istio/Kiali удалены.", log=_trim_log(log))
+44
View File
@@ -0,0 +1,44 @@
"""Справочник версий Helm-чартов для страницы аддонов (``helm search repo … --versions``).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException
from core.helm_addons import HelmAddonError, helm_addon_chart_versions_all, require_helm_binary
from models.schemas import HelmAddonChartVersionsResponse
logger = logging.getLogger("kind_k8s.api.helm_charts")
router = APIRouter(tags=["helm-charts"])
@router.get(
"/helm/chart-versions",
response_model=HelmAddonChartVersionsResponse,
summary="Версии чартов аддонов (для выпадающих списков)",
)
async def get_helm_addon_chart_versions() -> HelmAddonChartVersionsResponse:
"""
Выполняет ``helm repo update`` и для каждого аддона — ``helm search repo <chart> --versions``.
Результат кэшируется на ``KIND_K8S_HELM_VERSIONS_CACHE_SEC`` (по чарту).
Пустой список — если helm недоступен или репозиторий не ответил.
"""
try:
require_helm_binary()
except HelmAddonError as e:
logger.warning("helm недоступен для списка версий: %s", e)
raise HTTPException(status_code=503, detail=str(e)) from e
try:
data = await asyncio.to_thread(helm_addon_chart_versions_all)
except HelmAddonError as e:
logger.exception("Ошибка получения версий чартов")
raise HTTPException(status_code=500, detail=str(e)) from e
return HelmAddonChartVersionsResponse(**data)
+84
View File
@@ -0,0 +1,84 @@
"""Чтение журнала завершённых заданий из ``clusters/<имя>/journal/jobs_history.json``.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Query
from core.cluster_lifecycle import validate_cluster_name
from core.job_journal import collect_recent_journal_entries_page_sync, read_cluster_journal_sync
from models.schemas import ClusterJournalResponse, JournalEntryModel, JournalRecentResponse
logger = logging.getLogger("kind_k8s.api.journal")
router = APIRouter(tags=["journal"])
def _journal_total_pages(total: int, limit: int) -> int:
if total <= 0 or limit <= 0:
return 0
return (total + limit - 1) // limit
@router.get(
"/journal/recent",
response_model=JournalRecentResponse,
summary="Страница журнала по всем кластерам (пагинация)",
)
async def get_journal_recent(
limit: int = Query(default=30, ge=1, le=100, description="Записей на страницу"),
offset: int = Query(default=0, ge=0, description="Смещение (0 = первая страница)"),
) -> JournalRecentResponse:
"""Сборка из ``clusters/*/journal/jobs_history.json``, новые первыми; ``total`` и ``total_pages`` для UI."""
rows, total = await asyncio.to_thread(
collect_recent_journal_entries_page_sync,
limit=limit,
offset=offset,
)
entries = [JournalEntryModel.model_validate(r) for r in rows]
total_pages = _journal_total_pages(total, limit)
if total <= 0:
page = 1
else:
page = min((offset // limit) + 1, total_pages)
page = max(1, page)
return JournalRecentResponse(
limit=limit,
offset=offset,
total=total,
page=page,
total_pages=total_pages,
entries=entries,
)
@router.get(
"/clusters/{name}/journal",
response_model=ClusterJournalResponse,
summary="Журнал заданий кластера (JSON с диска)",
)
async def get_cluster_journal(name: str) -> ClusterJournalResponse:
n = name.strip()
if not validate_cluster_name(n):
raise HTTPException(status_code=400, detail="Некорректное имя кластера")
raw = await asyncio.to_thread(read_cluster_journal_sync, n)
if raw is None:
return ClusterJournalResponse(cluster_name=n, file_version=None, entries=[])
fv = raw.get("file_version") if isinstance(raw.get("file_version"), int) else None
ent = raw.get("entries")
if not isinstance(ent, list):
return ClusterJournalResponse(cluster_name=n, file_version=fv, entries=[])
entries: list[JournalEntryModel] = []
for item in ent:
if isinstance(item, dict):
try:
entries.append(JournalEntryModel.model_validate(item))
except Exception:
logger.debug("Пропуск некорректной записи журнала кластера «%s»", n)
return ClusterJournalResponse(cluster_name=n, file_version=fv, entries=entries)
+4 -1
View File
@@ -8,10 +8,13 @@ from __future__ import annotations
from fastapi import APIRouter
from api.v1.endpoints import clusters, docs_readme, health, versions
from api.v1.endpoints import cluster_addons, clusters, docs_readme, health, helm_charts, journal, versions
api_router = APIRouter()
api_router.include_router(health.router, prefix="")
api_router.include_router(versions.router, prefix="")
api_router.include_router(docs_readme.router, prefix="")
api_router.include_router(clusters.router, prefix="")
api_router.include_router(cluster_addons.router, prefix="")
api_router.include_router(helm_charts.router, prefix="")
api_router.include_router(journal.router, prefix="")