Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
+208
-114
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Страница «Журнал»: GET /api/v1/journal/recent с пагинацией (30 записей на страницу).
|
||||
* Страница «Журнал»: режимы per_cluster / provision / helm_addons, пагинация по 30.
|
||||
*
|
||||
* Автор: Сергей Антропов
|
||||
* Сайт: https://devops.org.ru
|
||||
@@ -11,15 +11,14 @@
|
||||
if ((body.getAttribute("data-dashboard-mode") || "") !== "journal") return;
|
||||
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
/** Размер страницы (синхронно с default limit в API). */
|
||||
var PAGE_SIZE = 30;
|
||||
|
||||
/** Текущая страница с 1 (состояние для пагинации). */
|
||||
/** @type {'per_cluster' | 'provision' | 'helm_addons'} */
|
||||
var viewMode = "per_cluster";
|
||||
var currentPage = 1;
|
||||
/** Выбранный кластер в режиме per_cluster */
|
||||
var selectedCluster = "";
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
*/
|
||||
async function apiGet(path) {
|
||||
const url = path.startsWith("http") ? path : API + path;
|
||||
const r = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
@@ -50,8 +49,11 @@
|
||||
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;
|
||||
@@ -86,12 +88,6 @@
|
||||
return "journal-status-pill";
|
||||
}
|
||||
|
||||
/**
|
||||
* Список номеров страниц и многоточий для компактной навигации.
|
||||
* @param {number} current
|
||||
* @param {number} total
|
||||
* @returns {(number|string)[]}
|
||||
*/
|
||||
function visiblePageItems(current, total) {
|
||||
const delta = 2;
|
||||
const range = [];
|
||||
@@ -115,9 +111,6 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} itemCount число строк на текущей странице (для подписи «записи N–M»)
|
||||
*/
|
||||
function renderPagination(total, limit, offset, page, totalPages, itemCount, onSelectPage) {
|
||||
if (!pagNav) return;
|
||||
pagNav.innerHTML = "";
|
||||
@@ -141,20 +134,11 @@
|
||||
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);
|
||||
}),
|
||||
mkBtn("« Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }),
|
||||
);
|
||||
wrap.appendChild(
|
||||
mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () {
|
||||
onSelectPage(page - 1);
|
||||
}),
|
||||
mkBtn("‹ Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
|
||||
);
|
||||
|
||||
const pagesEl = document.createElement("div");
|
||||
@@ -180,9 +164,7 @@
|
||||
if (pNum === page) b.setAttribute("aria-current", "page");
|
||||
else b.removeAttribute("aria-current");
|
||||
if (pNum !== page) {
|
||||
b.addEventListener("click", function () {
|
||||
onSelectPage(pNum);
|
||||
});
|
||||
b.addEventListener("click", function () { onSelectPage(pNum); });
|
||||
}
|
||||
pagesEl.appendChild(b);
|
||||
});
|
||||
@@ -190,27 +172,13 @@
|
||||
wrap.appendChild(pagesEl);
|
||||
|
||||
wrap.appendChild(
|
||||
mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () {
|
||||
onSelectPage(page + 1);
|
||||
}),
|
||||
mkBtn("Вперёд ›", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
|
||||
);
|
||||
wrap.appendChild(
|
||||
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () {
|
||||
onSelectPage(totalPages);
|
||||
}),
|
||||
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) {
|
||||
@@ -220,15 +188,120 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} resetPage сбросить на 1 (после «Обновить»)
|
||||
* Построить строки таблицы: 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 =
|
||||
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
|
||||
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
|
||||
"<td><code class=\"journal-code journal-cell-kind\">" + esc(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 = "Нет строк";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
const offset = (currentPage - 1) * PAGE_SIZE;
|
||||
|
||||
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");
|
||||
@@ -236,25 +309,19 @@
|
||||
if (pagNav) pagNav.classList.add("hidden");
|
||||
|
||||
try {
|
||||
const data = await apiGet(
|
||||
"/journal/recent?limit=" +
|
||||
encodeURIComponent(String(PAGE_SIZE)) +
|
||||
"&offset=" +
|
||||
encodeURIComponent(String(offset)),
|
||||
);
|
||||
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 : offset;
|
||||
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) {
|
||||
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);
|
||||
@@ -264,76 +331,40 @@
|
||||
page = currentPage;
|
||||
|
||||
if (!entries.length) {
|
||||
if (emptyEl) emptyEl.classList.remove("hidden");
|
||||
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 + ").";
|
||||
? "Записей нет."
|
||||
: "Страница пуста (всего: " + 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) + ".";
|
||||
|
||||
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 = "Нет строк";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
renderTableRows(entries, viewMode);
|
||||
renderPagination(total, limit, respOffset, page, totalPages, entries.length, goToPage);
|
||||
} catch (err) {
|
||||
statusEl.textContent = "Ошибка: " + err.message;
|
||||
if (emptyEl) emptyEl.classList.remove("hidden");
|
||||
if (emptyEl) {
|
||||
emptyEl.classList.remove("hidden");
|
||||
emptyEl.textContent = "Не удалось загрузить данные.";
|
||||
}
|
||||
if (tableWrap) tableWrap.classList.add("hidden");
|
||||
if (pagNav) pagNav.classList.add("hidden");
|
||||
} finally {
|
||||
@@ -341,7 +372,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", function () { loadJournal(true); });
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
loadJournal(false);
|
||||
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);
|
||||
})();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user