Files
KindClustersDashboard/app/static/js/journal.js
T
Sergey Antropoff eb063aec20 Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
2026-04-04 13:42:21 +03:00

442 lines
15 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.
/**
* Страница «Журнал»: режимы 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 =
"<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;
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);
})();
})();