Files
KindClustersDashboard/app/static/js/journal.js
T
Sergey Antropoff 17f6233fd7 Журнал: пагинация 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 в кластерах) в том же коммите
2026-04-04 11:16:43 +03:00

348 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу).
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Размер страницы (синхронно с default limit в API). */
var PAGE_SIZE = 30;
/** Текущая страница с 1 (состояние для пагинации). */
var currentPage = 1;
/**
* @param {string} path
*/
async function apiGet(path) {
const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, { headers: { Accept: "application/json" } });
const text = await r.text();
var data;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
data = { raw: text };
}
if (!r.ok) {
const msg =
typeof data.detail === "string"
? data.detail
: Array.isArray(data.detail)
? data.detail.map(function (x) { return x.msg || JSON.stringify(x); }).join("; ")
: text || r.statusText;
const err = new Error(msg);
err.status = r.status;
throw err;
}
return data;
}
const tbody = document.getElementById("journal-tbody");
const statusEl = document.getElementById("journal-status");
const emptyEl = document.getElementById("journal-empty");
const tableWrap = document.getElementById("journal-table-wrap");
const refreshBtn = document.getElementById("journal-refresh-btn");
const pagNav = document.getElementById("journal-pagination");
/** Скрыть полноэкранный спиннер первой загрузки (повторные вызовы безопасны). */
function hideJournalPageLoadingOverlay() {
const el = document.getElementById("journal-page-loading-overlay");
if (!el) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("journal-page-loading");
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
function formatUtc(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
} catch (e) {
return iso;
}
}
function statusClass(st) {
const s = (st || "").toLowerCase();
if (s === "success") return "journal-status-pill journal-status-pill--ok";
if (s === "failed") return "journal-status-pill journal-status-pill--err";
if (s === "cancelled") return "journal-status-pill journal-status-pill--warn";
return "journal-status-pill";
}
/**
* Список номеров страниц и многоточий для компактной навигации.
* @param {number} current
* @param {number} total
* @returns {(number|string)[]}
*/
function visiblePageItems(current, total) {
const delta = 2;
const range = [];
let i;
for (i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) {
range.push(i);
}
}
const out = [];
let l = 0;
for (i = 0; i < range.length; i++) {
const p = range[i];
if (l) {
if (p - l === 2) out.push(l + 1);
else if (p - l > 2) out.push("…");
}
out.push(p);
l = p;
}
return out;
}
/**
* @param {number} itemCount число строк на текущей странице (для подписи «записи N–M»)
*/
function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) {
if (!pagNav) return;
pagNav.innerHTML = "";
if (total <= 0 || totalPages <= 0) {
pagNav.classList.add("hidden");
return;
}
pagNav.classList.remove("hidden");
const wrap = document.createElement("div");
wrap.className = "journal-pagination-inner";
function mkBtn(label, disabled, ariaLabel, onClick) {
const b = document.createElement("button");
b.type = "button";
b.className = "journal-pag-btn";
b.textContent = label;
b.setAttribute("aria-label", ariaLabel);
b.disabled = !!disabled;
if (!disabled && onClick) b.addEventListener("click", onClick);
return b;
}
const firstOff = 0;
const prevOff = Math.max(0, offset - limit);
const nextOff = Math.min(Math.max(0, total - 1), offset + limit);
const lastOff = totalPages > 0 ? (totalPages - 1) * limit : 0;
wrap.appendChild(
mkBtn("« Первая", page <= 1, "Первая страница", function () {
onSelectPage(1);
}),
);
wrap.appendChild(
mkBtn(" Назад", page <= 1, "Предыдущая страница", function () {
onSelectPage(page - 1);
}),
);
const pagesEl = document.createElement("div");
pagesEl.className = "journal-pag-pages";
pagesEl.setAttribute("role", "group");
pagesEl.setAttribute("aria-label", "Номера страниц");
visiblePageItems(page, totalPages).forEach(function (item) {
if (item === "…") {
const span = document.createElement("span");
span.className = "journal-pag-ellipsis";
span.setAttribute("aria-hidden", "true");
span.textContent = "…";
pagesEl.appendChild(span);
return;
}
const pNum = item;
const b = document.createElement("button");
b.type = "button";
b.className = "journal-pag-num" + (pNum === page ? " journal-pag-num--active" : "");
b.textContent = String(pNum);
b.setAttribute("aria-label", "Страница " + pNum);
if (pNum === page) b.setAttribute("aria-current", "page");
else b.removeAttribute("aria-current");
if (pNum !== page) {
b.addEventListener("click", function () {
onSelectPage(pNum);
});
}
pagesEl.appendChild(b);
});
wrap.appendChild(pagesEl);
wrap.appendChild(
mkBtn("Вперёд ", page >= totalPages, "Следующая страница", function () {
onSelectPage(page + 1);
}),
);
wrap.appendChild(
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () {
onSelectPage(totalPages);
}),
);
pagNav.appendChild(wrap);
const from = total === 0 || itemCount === 0 ? 0 : offset + 1;
const to = total === 0 || itemCount === 0 ? 0 : offset + itemCount;
const hint = document.createElement("p");
hint.className = "muted journal-pagination-summary";
hint.textContent =
from && to
? "Записи " + from + "" + to + " из " + total + " · страница " + page + " из " + totalPages
: "Всего записей: " + total + (totalPages ? " · страниц: " + totalPages : "");
pagNav.appendChild(hint);
}
function goToPage(p) {
if (p < 1) p = 1;
currentPage = p;
loadJournal(false);
}
/**
* @param {boolean} resetPage сбросить на 1 (после «Обновить»)
*/
async function loadJournal(resetPage) {
if (!tbody || !statusEl) {
hideJournalPageLoadingOverlay();
return;
}
if (resetPage) currentPage = 1;
const offset = (currentPage - 1) * PAGE_SIZE;
statusEl.textContent = "Загрузка…";
tbody.innerHTML = "";
if (emptyEl) emptyEl.classList.add("hidden");
if (tableWrap) tableWrap.classList.remove("hidden");
if (pagNav) pagNav.classList.add("hidden");
try {
const data = await apiGet(
"/journal/recent?limit=" +
encodeURIComponent(String(PAGE_SIZE)) +
"&offset=" +
encodeURIComponent(String(offset)),
);
const entries = data.entries || [];
const total = typeof data.total === "number" ? data.total : entries.length;
const limit = typeof data.limit === "number" ? data.limit : PAGE_SIZE;
const respOffset = typeof data.offset === "number" ? data.offset : offset;
const totalPages = typeof data.total_pages === "number" ? data.total_pages : Math.ceil(total / limit) || 0;
let page = typeof data.page === "number" ? data.page : currentPage;
if (total > 0 && currentPage > totalPages) {
currentPage = totalPages;
await loadJournal(false);
return;
}
if (total > 0 && entries.length === 0 && respOffset >= total && totalPages > 0) {
currentPage = totalPages;
await loadJournal(false);
return;
}
page = currentPage;
if (!entries.length) {
if (emptyEl) emptyEl.classList.remove("hidden");
if (tableWrap) tableWrap.classList.add("hidden");
statusEl.textContent =
total === 0
? "Нет записей в журналах."
: "Страница пуста (всего записей: " + total + ").";
renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage);
return;
}
const fromN = respOffset + 1;
const toN = respOffset + entries.length;
statusEl.textContent =
"Записи " + fromN + "" + toN + " из " + total + " · страница " + page + " из " + (totalPages || 1) + ".";
entries.forEach(function (e) {
const tr = document.createElement("tr");
tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatUtc(e.finished_at_utc || e.created_at_utc);
const msg = (e.message || "").trim();
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
const logText = lines.join("\n");
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML =
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
"<td><code class=\"journal-code\">" + esc(e.kind || "") + "</code></td>" +
"<td><span class=\"" + esc(statusClass(e.status)) + "\">" + esc(e.status || "") + "</span></td>" +
"<td class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" +
"<td class=\"journal-cell-actions\">" +
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
detailsId + "\">Показать лог</button>" +
"</td>";
tbody.appendChild(tr);
if (logText) {
const trDetails = document.createElement("tr");
trDetails.className = "journal-details-row hidden";
trDetails.innerHTML =
"<td colspan=\"6\" class=\"journal-details-cell\">" +
"<pre id=\"" + detailsId + "\" class=\"mono journal-log-pre\" hidden></pre>" +
"</td>";
tbody.appendChild(trDetails);
const pre = trDetails.querySelector("pre");
if (pre) pre.textContent = logText;
const btn = tr.querySelector(".btn-journal-toggle");
if (btn && trDetails && pre) {
btn.addEventListener("click", function () {
const open = trDetails.classList.contains("hidden");
trDetails.classList.toggle("hidden", !open);
pre.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.textContent = open ? "Скрыть лог" : "Показать лог";
});
}
} else {
const btn = tr.querySelector(".btn-journal-toggle");
if (btn) {
btn.disabled = true;
btn.textContent = "Нет строк";
}
}
});
renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage);
} catch (err) {
statusEl.textContent = "Ошибка: " + err.message;
if (emptyEl) emptyEl.classList.remove("hidden");
if (tableWrap) tableWrap.classList.add("hidden");
if (pagNav) pagNav.classList.add("hidden");
} finally {
hideJournalPageLoadingOverlay();
}
}
if (refreshBtn) refreshBtn.addEventListener("click", function () { loadJournal(true); });
loadJournal(false);
})();