17f6233fd7
- 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 в кластерах) в том же коммите
348 lines
12 KiB
JavaScript
348 lines
12 KiB
JavaScript
/**
|
||
* Страница «Журнал»: 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);
|
||
})();
|