/** * Страница «Журнал»: 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 = "" + esc(finished) + "" + "" + esc(cluster) + "" + "" + esc(e.kind || "") + "" + "" + esc(e.status || "") + "" + "" + esc(msg || "—") + "" + "" + "" + ""; tbody.appendChild(tr); if (logText) { const trDetails = document.createElement("tr"); trDetails.className = "journal-details-row hidden"; trDetails.innerHTML = "" + "" + ""; 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); })();