Files
KindClustersDashboard/app/static/js/journal.js
T
Sergey Antropoff f0b24c8901 UI: адаптив, журнал, спиннеры; docs: README и api_routes
- Навигация: выезжающее меню при узком экране (nav-mobile.js)
- Журнал: карточки <620px, компактная пагинация, время в две строки <920px
- Создание кластера: оверлей загрузки, инкрементальное обновление таблицы заданий
- Документация: полноэкранный спиннер при загрузке и навигации
- Главная: масштабирование CTA, статистика 2 колонки <520px, донаты перенос <710px
- README: env.example, новые фичи UI, автор в конце файла
- api_routes: маршрут /cluster-create, спиннеры, шаблоны; автор в конце
- env.example: автор перенесён в конец файла
2026-04-05 00:18:19 +03:00

543 lines
19 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;
}
}
/**
* Ячейка «время»: дата и время отдельными span (при ширине до 920px — в две строки, см. CSS .journal-time-stack).
* @param {string} iso
* @returns {string} безопасный HTML
*/
function formatUtcCellHtml(iso) {
if (!iso) {
return esc("—");
}
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return esc(iso);
}
const day = d.toISOString().slice(0, 10);
const clock = d.toISOString().slice(11, 19);
return (
'<span class="journal-time-stack">' +
'<span class="journal-time-date">' +
esc(day) +
"</span>" +
'<span class="journal-time-clock">' +
esc(clock) +
"</span>" +
"</span>"
);
} catch (e) {
return esc(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";
}
/** Суффикс job_kind (после helm_addon_install_|upgrade_|delete_) → id аддона в API. */
function helmJobSlugToAddonId(slug) {
const m = {
ingress_nginx: "ingress-nginx",
kube_prometheus_stack: "kube-prometheus-stack",
metrics_server: "metrics-server",
istio_kiali: "istio-kiali",
};
return m[slug] || String(slug || "").replace(/_/g, "-") || "—";
}
function helmAddonTitle(id) {
const m = {
"ingress-nginx": "ingress-nginx",
"kube-prometheus-stack": "kube-prometheus-stack",
"metrics-server": "metrics-server",
"istio-kiali": "Istio + Kiali",
};
return m[id] || id || "—";
}
function helmOperationTitle(op) {
const o = (op || "").toLowerCase();
if (o === "install") return "Установка";
if (o === "upgrade") return "Обновление";
if (o === "delete") return "Удаление";
return op || "—";
}
/**
* Колонка «Вид операции» для режима Helm: установка / обновление / удаление и имя аддона.
* Старые записи без result.action разбираются по префиксу kind.
*/
function formatHelmJournalKind(e) {
const r = e.result;
if (r && typeof r === "object") {
const verb = helmOperationTitle(r.action);
const ad = r.addon != null ? helmAddonTitle(String(r.addon)) : "—";
if (r.action && r.addon != null && String(r.addon).length) {
return verb + " · " + ad;
}
}
const k = e.kind || "";
if (k.indexOf("helm_addon_delete_") === 0) {
return "Удаление · " + helmAddonTitle(helmJobSlugToAddonId(k.replace(/^helm_addon_delete_/, "")));
}
if (k.indexOf("helm_addon_upgrade_") === 0) {
return "Обновление · " + helmAddonTitle(helmJobSlugToAddonId(k.replace(/^helm_addon_upgrade_/, "")));
}
if (k.indexOf("helm_addon_install_") === 0) {
return "Установка · " + helmAddonTitle(helmJobSlugToAddonId(k.replace(/^helm_addon_install_/, "")));
}
return k || "—";
}
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;
}
/* Короткий текст на кнопках — одна строка пагинации; смысл в aria-label */
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 finishedHtml = formatUtcCellHtml(
e.finished_at_utc || e.created_at_utc,
);
const kind = mode === "helm_addons" ? formatHelmJournalKind(e) : (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);
/* Классы create-jobs-card-td* — карточки на узком экране (<620px), см. .journal-list-card в style.css */
tr.innerHTML =
"<td class=\"journal-cell-time create-jobs-card-td create-jobs-card-td--time\">" +
finishedHtml +
"</td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--cluster\"><code class=\"journal-code\">" +
esc(cluster) +
"</code></td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--kind\"><code class=\"journal-code journal-cell-kind\">" +
esc(kind) +
"</code></td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--status\"><span class=\"" +
esc(statusClass(e.status)) +
"\">" +
esc(e.status || "") +
"</span></td>" +
"<td class=\"journal-cell-msg create-jobs-card-td create-jobs-card-td--msg\"><span class=\"create-jobs-msg-value\">" +
esc(msg || "—") +
"</span></td>" +
"<td class=\"journal-cell-actions create-jobs-card-td create-jobs-card-td--log\">" +
"<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);
})();
})();