f0b24c8901
- Навигация: выезжающее меню при узком экране (nav-mobile.js) - Журнал: карточки <620px, компактная пагинация, время в две строки <920px - Создание кластера: оверлей загрузки, инкрементальное обновление таблицы заданий - Документация: полноэкранный спиннер при загрузке и навигации - Главная: масштабирование CTA, статистика 2 колонки <520px, донаты перенос <710px - README: env.example, новые фичи UI, автор в конце файла - api_routes: маршрут /cluster-create, спиннеры, шаблоны; автор в конце - env.example: автор перенесён в конец файла
543 lines
19 KiB
JavaScript
543 lines
19 KiB
JavaScript
/**
|
||
* Страница «Журнал»: режимы 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);
|
||
})();
|
||
})();
|