Files
KindClustersDashboard/app/api/v1/endpoints/journal.py
T
Sergey Antropoff eb063aec20 Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
2026-04-04 13:42:21 +03:00

166 lines
5.8 KiB
Python

"""Чтение журналов: задания (jobs_history), развёртывание (provision_log), Helm-аддоны (helm_addon_log).
Автор: Сергей Антропов
Сайт: 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 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")
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
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="Страница журнала заданий (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:
"""Сборка из ``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)
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(
"/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,
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)