/** * Страница «Журнал»: режимы per_cluster / provision / helm_addons, пагинация по 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(/\/$/, ""); var PAGE_SIZE = 30; /** @type {'per_cluster' | 'provision' | 'helm_addons'} */ var viewMode = "per_cluster"; var currentPage = 1; /** Выбранный кластер в режиме per_cluster */ var selectedCluster = ""; 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"); const clusterSelect = document.getElementById("journal-cluster-select"); const clusterPicker = document.getElementById("journal-cluster-picker"); const thKind = document.getElementById("journal-th-kind"); const sectionTitle = document.getElementById("journal-table-section-title"); 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"; } 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; } 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; } 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); } function goToPage(p) { if (p < 1) p = 1; currentPage = p; loadJournal(false); } /** * Построить строки таблицы: jobs (log_lines) или dir-лог (lines). */ function renderTableRows(entries, mode) { if (!tbody) return; tbody.innerHTML = ""; 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 kind = e.kind || ""; const msg = (e.message || "").trim(); let lines = []; if (mode === "per_cluster") { lines = Array.isArray(e.log_lines) ? e.log_lines : []; } else { lines = Array.isArray(e.lines) ? e.lines : []; } const logText = lines.join("\n"); const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11); tr.innerHTML = "" + esc(finished) + "" + "" + esc(cluster) + "" + "" + esc(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 = "Нет строк"; } } }); } function applyViewLabels() { if (thKind) { if (viewMode === "per_cluster") thKind.textContent = "Тип задания"; else thKind.textContent = "Вид операции"; } if (sectionTitle) { if (viewMode === "per_cluster") sectionTitle.textContent = "Задания выбранного кластера"; else if (viewMode === "provision") sectionTitle.textContent = "Развёртывание по кластерам"; else sectionTitle.textContent = "Helm-аддоны по кластерам"; } if (clusterPicker) { clusterPicker.classList.toggle("hidden", viewMode !== "per_cluster"); } } function buildListUrl() { const off = (currentPage - 1) * PAGE_SIZE; const q = "?limit=" + encodeURIComponent(String(PAGE_SIZE)) + "&offset=" + encodeURIComponent(String(off)); if (viewMode === "per_cluster") { return "/journal/recent" + q + "&cluster=" + encodeURIComponent(selectedCluster); } if (viewMode === "provision") return "/journal/provision" + q; return "/journal/helm-addons" + q; } async function loadJournal(resetPage) { if (!tbody || !statusEl) { hideJournalPageLoadingOverlay(); return; } applyViewLabels(); if (resetPage) currentPage = 1; if (viewMode === "per_cluster" && !selectedCluster) { statusEl.textContent = "Выберите кластер."; tbody.innerHTML = ""; if (emptyEl) { emptyEl.classList.remove("hidden"); emptyEl.textContent = "Укажите кластер в списке выше."; } if (tableWrap) tableWrap.classList.add("hidden"); if (pagNav) pagNav.classList.add("hidden"); hideJournalPageLoadingOverlay(); return; } 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(buildListUrl()); 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 : (currentPage - 1) * PAGE_SIZE; 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 && totalPages > 0) { 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"); emptyEl.textContent = viewMode === "per_cluster" ? "Нет записей в journal/jobs_history.json для этого кластера." : viewMode === "provision" ? "Нет файлов provision_log.json (или каталогов кластеров)." : "Нет файлов helm_addon_log.json — установите аддон со страницы «Аддоны»."; } if (tableWrap) tableWrap.classList.add("hidden"); statusEl.textContent = total === 0 ? "Записей нет." : "Страница пуста (всего: " + total + ")."; renderPagination(total, limit, respOffset, page, totalPages, 0, goToPage); return; } if (emptyEl) emptyEl.classList.add("hidden"); if (tableWrap) tableWrap.classList.remove("hidden"); const fromN = respOffset + 1; const toN = respOffset + entries.length; statusEl.textContent = "Записи " + fromN + "–" + toN + " из " + total + " · страница " + page + " из " + (totalPages || 1) + "."; renderTableRows(entries, viewMode); renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage); } catch (err) { statusEl.textContent = "Ошибка: " + err.message; if (emptyEl) { emptyEl.classList.remove("hidden"); emptyEl.textContent = "Не удалось загрузить данные."; } if (tableWrap) tableWrap.classList.add("hidden"); if (pagNav) pagNav.classList.add("hidden"); } finally { hideJournalPageLoadingOverlay(); } } async function loadClusterList() { if (!clusterSelect) return; try { const rows = await apiGet("/clusters"); clusterSelect.innerHTML = ""; const names = rows .map(function (c) { return c.name; }) .filter(Boolean) .sort(); if (!names.length) { const o = document.createElement("option"); o.value = ""; o.textContent = "Нет каталогов кластеров"; clusterSelect.appendChild(o); selectedCluster = ""; return; } names.forEach(function (name) { const o = document.createElement("option"); o.value = name; o.textContent = name; clusterSelect.appendChild(o); }); if (!selectedCluster || names.indexOf(selectedCluster) < 0) { selectedCluster = names[0]; } clusterSelect.value = selectedCluster; } catch (e) { clusterSelect.innerHTML = ""; const o = document.createElement("option"); o.value = ""; o.textContent = "Ошибка загрузки списка"; clusterSelect.appendChild(o); selectedCluster = ""; } } function bindViewRadios() { document.querySelectorAll('input[name="journal_view"]').forEach(function (radio) { radio.addEventListener("change", function () { if (!radio.checked) return; viewMode = radio.value; currentPage = 1; loadJournal(false); }); }); } if (clusterSelect) { clusterSelect.addEventListener("change", function () { selectedCluster = String(clusterSelect.value || "").trim(); currentPage = 1; if (viewMode === "per_cluster") loadJournal(false); }); } if (refreshBtn) { refreshBtn.addEventListener("click", function () { loadJournal(true); }); } bindViewRadios(); (async function init() { await loadClusterList(); await loadJournal(false); })(); })();