Журнал: пагинация 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
+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)