Журнал: пагинация 30 записей на страницу, API offset/total_pages
- 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 в кластерах) в том же коммите
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Страница Helm-аддонов: GET /api/v1/clusters, статус и POST/DELETE …/addons/*.
|
||||
*
|
||||
* Автор: Сергей Антропов
|
||||
* Сайт: https://devops.org.ru
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const body = document.body;
|
||||
if ((body.getAttribute("data-dashboard-mode") || "") !== "addons") return;
|
||||
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [opts]
|
||||
*/
|
||||
async function api(path, opts) {
|
||||
const url = path.startsWith("http") ? path : API + path;
|
||||
const r = await fetch(url, opts);
|
||||
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 sel = document.getElementById("addons-cluster-select");
|
||||
const statusBar = document.getElementById("addons-status-bar");
|
||||
const logPre = document.getElementById("addons-log-pre");
|
||||
const overlay = document.getElementById("addons-busy-overlay");
|
||||
const busyLabel = document.getElementById("addons-busy-label");
|
||||
|
||||
function getClusterName() {
|
||||
if (!sel || !sel.value) return "";
|
||||
return String(sel.value).trim();
|
||||
}
|
||||
|
||||
function setBusy(show, label) {
|
||||
if (!overlay) return;
|
||||
overlay.classList.toggle("hidden", !show);
|
||||
overlay.setAttribute("aria-busy", show ? "true" : "false");
|
||||
overlay.setAttribute("aria-hidden", show ? "false" : "true");
|
||||
document.body.classList.toggle("cluster-addons-busy", !!show);
|
||||
if (busyLabel && label) busyLabel.textContent = label;
|
||||
}
|
||||
|
||||
/** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */
|
||||
function hideAddonsPageLoadingOverlay() {
|
||||
const el = document.getElementById("addons-page-loading-overlay");
|
||||
if (!el) return;
|
||||
el.classList.add("hidden");
|
||||
el.setAttribute("aria-busy", "false");
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("cluster-addons-page-loading");
|
||||
}
|
||||
|
||||
function appendLog(line) {
|
||||
if (!logPre) return;
|
||||
logPre.textContent = (logPre.textContent ? logPre.textContent + "\n" : "") + line;
|
||||
logPre.scrollTop = logPre.scrollHeight;
|
||||
}
|
||||
|
||||
function formatStatus(st) {
|
||||
if (!st) return "";
|
||||
const parts = [
|
||||
"ingress-nginx: " + (st.ingress_nginx ? "да" : "нет"),
|
||||
"prometheus-stack: " + (st.kube_prometheus_stack ? "да" : "нет"),
|
||||
"metrics-server: " + (st.metrics_server ? "да" : "нет"),
|
||||
"Istio+Kiali: " + (st.istio_mesh_ready ? "да" : "нет"),
|
||||
];
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Заполнить выпадающий список версиями чарта; первая опция — «последняя».
|
||||
* @param {HTMLSelectElement | null} selectEl
|
||||
* @param {string[] | undefined} versions
|
||||
*/
|
||||
function fillVersionSelect(selectEl, versions) {
|
||||
if (!selectEl) return;
|
||||
const cur = selectEl.value;
|
||||
selectEl.innerHTML = "";
|
||||
const o0 = document.createElement("option");
|
||||
o0.value = "";
|
||||
o0.textContent = "Последняя (из репозитория)";
|
||||
selectEl.appendChild(o0);
|
||||
(versions || []).forEach(function (v) {
|
||||
if (!v) return;
|
||||
const o = document.createElement("option");
|
||||
o.value = v;
|
||||
o.textContent = v;
|
||||
selectEl.appendChild(o);
|
||||
});
|
||||
if (cur) {
|
||||
for (let i = 0; i < selectEl.options.length; i++) {
|
||||
if (selectEl.options[i].value === cur) {
|
||||
selectEl.value = cur;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChartVersions() {
|
||||
try {
|
||||
const d = await api("/helm/chart-versions");
|
||||
fillVersionSelect(document.getElementById("addons-ing-version-select"), d.ingress_nginx);
|
||||
fillVersionSelect(document.getElementById("addons-prom-version-select"), d.kube_prometheus_stack);
|
||||
fillVersionSelect(document.getElementById("addons-ms-version-select"), d.metrics_server);
|
||||
fillVersionSelect(document.getElementById("addons-istio-version-select"), d.istio);
|
||||
fillVersionSelect(document.getElementById("addons-kiali-version-select"), d.kiali_server);
|
||||
} catch (e) {
|
||||
appendLog("Список версий чартов недоступен: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClusters() {
|
||||
if (!sel) return;
|
||||
try {
|
||||
const rows = await api("/clusters");
|
||||
sel.innerHTML = "";
|
||||
const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; });
|
||||
if (!withKube.length) {
|
||||
const o = document.createElement("option");
|
||||
o.value = "";
|
||||
o.textContent = "Нет кластеров с kubeconfig на диске";
|
||||
sel.appendChild(o);
|
||||
return;
|
||||
}
|
||||
const o0 = document.createElement("option");
|
||||
o0.value = "";
|
||||
o0.textContent = "— выберите кластер —";
|
||||
sel.appendChild(o0);
|
||||
withKube.forEach(function (c) {
|
||||
const o = document.createElement("option");
|
||||
o.value = c.name;
|
||||
o.textContent = c.name + (c.kind_nodes_running ? "" : " (узлы остановлены)");
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch (e) {
|
||||
sel.innerHTML = "";
|
||||
const o = document.createElement("option");
|
||||
o.value = "";
|
||||
o.textContent = "Ошибка загрузки списка";
|
||||
sel.appendChild(o);
|
||||
appendLog("Ошибка GET /clusters: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const n = getClusterName();
|
||||
if (!statusBar) return;
|
||||
if (!n) {
|
||||
statusBar.textContent = "Выберите кластер для просмотра статуса аддонов.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status");
|
||||
statusBar.textContent = formatStatus(st);
|
||||
} catch (e) {
|
||||
statusBar.textContent = "Статус недоступен: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function runInstall(endpoint, payload) {
|
||||
const n = getClusterName();
|
||||
if (!n) {
|
||||
alert("Выберите кластер.");
|
||||
return;
|
||||
}
|
||||
setBusy(true, "Установка (Helm), подождите…");
|
||||
if (logPre) logPre.textContent = "";
|
||||
appendLog("POST …/addons/" + endpoint + " для «" + n + "»");
|
||||
try {
|
||||
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: payload !== undefined ? JSON.stringify(payload) : "{}",
|
||||
});
|
||||
appendLog(r.message || "OK");
|
||||
if (r.log) appendLog(r.log);
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
appendLog("Ошибка: " + e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDelete(endpoint) {
|
||||
const n = getClusterName();
|
||||
if (!n) {
|
||||
alert("Выберите кластер.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return;
|
||||
setBusy(true, "Удаление релиза, подождите…");
|
||||
if (logPre) logPre.textContent = "";
|
||||
appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»");
|
||||
try {
|
||||
const r = await api("/clusters/" + encodeURIComponent(n) + "/addons/" + endpoint, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
appendLog(r.message || "OK");
|
||||
if (r.log) appendLog(r.log);
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
appendLog("Ошибка: " + e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".btn-addon-install").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const ep = btn.getAttribute("data-endpoint") || "";
|
||||
if (ep === "ingress-nginx") {
|
||||
const sel = document.getElementById("addons-ing-version-select");
|
||||
const v = sel && sel.value.trim();
|
||||
runInstall(ep, v ? { chart_version: v } : {});
|
||||
return;
|
||||
}
|
||||
if (ep === "kube-prometheus-stack") {
|
||||
const u = document.getElementById("addons-grafana-user");
|
||||
const p = document.getElementById("addons-grafana-pass");
|
||||
const pv = document.getElementById("addons-prom-version-select");
|
||||
const user = u ? u.value.trim() : "admin";
|
||||
const pw = p ? p.value : "";
|
||||
if (pw.length < 8) {
|
||||
alert("Задайте пароль Grafana не короче 8 символов.");
|
||||
return;
|
||||
}
|
||||
const payload = { grafana_admin_user: user, grafana_admin_password: pw };
|
||||
if (pv && pv.value.trim()) payload.chart_version = pv.value.trim();
|
||||
runInstall(ep, payload);
|
||||
return;
|
||||
}
|
||||
if (ep === "metrics-server") {
|
||||
const ms = document.getElementById("addons-ms-version-select");
|
||||
const payload = {};
|
||||
if (ms && ms.value.trim()) payload.chart_version = ms.value.trim();
|
||||
runInstall(ep, Object.keys(payload).length ? payload : {});
|
||||
return;
|
||||
}
|
||||
if (ep === "istio-kiali") {
|
||||
const u = document.getElementById("addons-kiali-user");
|
||||
const p = document.getElementById("addons-kiali-pass");
|
||||
const isel = document.getElementById("addons-istio-version-select");
|
||||
const ksel = document.getElementById("addons-kiali-version-select");
|
||||
const user = u ? u.value.trim() : "kiali-admin";
|
||||
const pw = p ? p.value : "";
|
||||
if (pw.length < 8) {
|
||||
alert("Задайте пароль Kiali не короче 8 символов.");
|
||||
return;
|
||||
}
|
||||
const payload = { kiali_username: user, kiali_password: pw };
|
||||
if (isel && isel.value.trim()) payload.istio_chart_version = isel.value.trim();
|
||||
if (ksel && ksel.value.trim()) payload.kiali_chart_version = ksel.value.trim();
|
||||
runInstall(ep, payload);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".btn-addon-remove").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const ep = btn.getAttribute("data-endpoint") || "";
|
||||
if (ep) runDelete(ep);
|
||||
});
|
||||
});
|
||||
|
||||
const refreshBtn = document.getElementById("addons-refresh-status");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", function () { refreshStatus(); });
|
||||
|
||||
if (sel) {
|
||||
sel.addEventListener("change", function () { refreshStatus(); });
|
||||
}
|
||||
|
||||
loadClusters()
|
||||
.then(function () {
|
||||
return refreshStatus();
|
||||
})
|
||||
.then(function () {
|
||||
return loadChartVersions();
|
||||
})
|
||||
.finally(function () {
|
||||
hideAddonsPageLoadingOverlay();
|
||||
});
|
||||
})();
|
||||
@@ -189,6 +189,50 @@
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
|
||||
function isJobLogModalOpen() {
|
||||
const el = document.getElementById("job-log-modal-overlay");
|
||||
return !!(el && !el.classList.contains("hidden"));
|
||||
}
|
||||
|
||||
function closeJobLogModal() {
|
||||
const el = document.getElementById("job-log-modal-overlay");
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть модалку с текстовым журналом завершённого задания (GET /jobs/{id}).
|
||||
* @param {string} jobId
|
||||
*/
|
||||
function openJobLogModal(jobId) {
|
||||
const overlay = document.getElementById("job-log-modal-overlay");
|
||||
const sub = document.getElementById("job-log-modal-sub");
|
||||
const pre = document.getElementById("job-log-modal-body");
|
||||
const titleEl = document.getElementById("job-log-modal-title");
|
||||
if (!overlay || !pre) return;
|
||||
pre.textContent = "Загрузка журнала…";
|
||||
if (sub) sub.textContent = "Задание: " + jobId;
|
||||
if (titleEl) titleEl.textContent = "Журнал задания";
|
||||
overlay.classList.remove("hidden");
|
||||
api("/jobs/" + encodeURIComponent(jobId))
|
||||
.then(function (job) {
|
||||
if (titleEl) {
|
||||
titleEl.textContent =
|
||||
"Журнал задания · " + (job.status || "") + (job.kind ? " · " + job.kind : "");
|
||||
}
|
||||
if (sub) {
|
||||
var parts = ["Задание: " + jobId];
|
||||
if (job.cluster_name) parts.push("Кластер: " + job.cluster_name);
|
||||
if (job.message) parts.push(job.message);
|
||||
sub.textContent = parts.join(" · ");
|
||||
}
|
||||
var lines = job.progress_log || [];
|
||||
pre.textContent = lines.length ? lines.join("\n") : job.message || "(журнал пуст)";
|
||||
})
|
||||
.catch(function (e) {
|
||||
pre.textContent = "Не удалось загрузить журнал: " + (e && e.message ? e.message : String(e));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке.
|
||||
* @param {string} clusterName
|
||||
@@ -1760,6 +1804,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка).
|
||||
*/
|
||||
function bindJobsTableJobLogOpen() {
|
||||
const tbl = document.getElementById("tbl-jobs");
|
||||
if (!tbl || tbl.getAttribute("data-jobs-job-log-bound") === "1") return;
|
||||
tbl.setAttribute("data-jobs-job-log-bound", "1");
|
||||
tbl.addEventListener(
|
||||
"click",
|
||||
function (ev) {
|
||||
const btn = ev.target && ev.target.closest ? ev.target.closest("button.jobs-open-log-btn") : null;
|
||||
if (!btn || !tbl.contains(btn)) return;
|
||||
const jobId = btn.getAttribute("data-job-id");
|
||||
if (!jobId) return;
|
||||
ev.preventDefault();
|
||||
openJobLogModal(jobId);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
const tbody = document.querySelector("#tbl-jobs tbody");
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
@@ -1782,6 +1847,14 @@
|
||||
j.progress_stage +
|
||||
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
|
||||
}
|
||||
var msgCol =
|
||||
"<div class=\"jobs-msg-primary\">" + escapeHtml(cellMsg) + "</div>";
|
||||
if (j.status === "failed" || j.status === "cancelled") {
|
||||
msgCol +=
|
||||
"<button type=\"button\" class=\"btn-secondary jobs-open-log-btn\" data-job-id=\"" +
|
||||
escapeHtml(j.job_id || "") +
|
||||
"\">Журнал</button>";
|
||||
}
|
||||
const iso = j.created_at_utc || "";
|
||||
const timeLabel = formatJobTimeUtc(iso);
|
||||
tr.innerHTML =
|
||||
@@ -1801,7 +1874,7 @@
|
||||
st +
|
||||
"</span></td>" +
|
||||
"<td class=\"jobs-msg-cell\">" +
|
||||
escapeHtml(cellMsg) +
|
||||
msgCol +
|
||||
"</td>";
|
||||
frag.appendChild(tr);
|
||||
});
|
||||
@@ -2324,6 +2397,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (document.getElementById("tbl-jobs")) {
|
||||
bindJobsTableJobLogOpen();
|
||||
}
|
||||
|
||||
const mClose = document.getElementById("modal-close");
|
||||
if (mClose) mClose.addEventListener("click", closeModal);
|
||||
|
||||
@@ -2340,6 +2417,10 @@
|
||||
closeConfirmModal(confirmModalIsAlert ? true : false);
|
||||
return;
|
||||
}
|
||||
if (isJobLogModalOpen()) {
|
||||
closeJobLogModal();
|
||||
return;
|
||||
}
|
||||
if (isProvisionLogModalOpen()) {
|
||||
closeProvisionLogModal();
|
||||
return;
|
||||
@@ -2378,6 +2459,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
const jobLogOv = document.getElementById("job-log-modal-overlay");
|
||||
const jobLogClose = document.getElementById("job-log-modal-close");
|
||||
if (jobLogClose) {
|
||||
jobLogClose.addEventListener("click", closeJobLogModal);
|
||||
}
|
||||
if (jobLogOv) {
|
||||
jobLogOv.addEventListener("click", function (ev) {
|
||||
if (ev.target === jobLogOv) closeJobLogModal();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
function () {
|
||||
@@ -2419,6 +2511,7 @@
|
||||
}
|
||||
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
||||
bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
|
||||
bindActionTooltipHosts(document.getElementById("job-log-modal-overlay"));
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Страница «Журнал»: 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);
|
||||
})();
|
||||
+302
-1
@@ -964,7 +964,16 @@ html[data-theme="light"] .cluster-node-res-card__bar-track {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Заголовок блока заданий + кнопка очистки */
|
||||
/* Заголовок блока заданий + кнопка «Журнал» у failed/cancelled */
|
||||
.jobs-msg-cell .jobs-msg-primary {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.jobs-open-log-btn {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.jobs-card-toolbar {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
@@ -1572,6 +1581,25 @@ html[data-theme="light"] .icon-btn--danger {
|
||||
.provision-log-modal-overlay {
|
||||
z-index: 500;
|
||||
}
|
||||
.job-log-modal-overlay {
|
||||
z-index: 500;
|
||||
}
|
||||
.modal-box--job-log .job-log-modal-body {
|
||||
max-height: min(70vh, 32rem);
|
||||
overflow: auto;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.65rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
html[data-theme="light"] .modal-box--job-log .job-log-modal-body {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.modal-box--provision-log .provision-log-body {
|
||||
max-height: min(70vh, 32rem);
|
||||
overflow: auto;
|
||||
@@ -2272,6 +2300,12 @@ body.dashboard-home-loading {
|
||||
body.cluster-page-loading {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Первая загрузка страниц «Аддоны» и «Журнал» */
|
||||
body.cluster-addons-page-loading,
|
||||
body.journal-page-loading {
|
||||
overflow: hidden;
|
||||
}
|
||||
.page-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -2714,3 +2748,270 @@ html[data-theme="light"] .cluster-edit-yaml-preview-head {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Страница «Аддоны»: Helm-чарты по выбранному кластеру */
|
||||
.cluster-addons-toolbar-card .cluster-addons-cluster-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.65rem 1rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.cluster-addons-cluster-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.cluster-addons-cluster-select {
|
||||
min-width: min(100%, 18rem);
|
||||
flex: 1 1 14rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
.cluster-addons-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.cluster-addons-status-bar {
|
||||
margin-top: 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cluster-addons-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@media (min-width: 52rem) {
|
||||
.cluster-addons-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.cluster-addon-card__title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.cluster-addon-card__desc {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.cluster-addon-card__actions {
|
||||
margin-top: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.cluster-addon-card--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.cluster-addon-istio-grid {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.cluster-addons-log-card {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.cluster-addons-log-pre {
|
||||
margin: 0.5rem 0 0;
|
||||
max-height: min(50vh, 22rem);
|
||||
overflow: auto;
|
||||
padding: 0.65rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
html[data-theme="light"] .cluster-addons-log-pre {
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.cluster-addons-busy-overlay {
|
||||
z-index: 9995;
|
||||
}
|
||||
body.cluster-addons-busy {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Версии чартов на странице аддонов */
|
||||
.cluster-addons-version-select {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Страница «Журнал» */
|
||||
.journal-toolbar-card .journal-toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.journal-status {
|
||||
margin: 0.65rem 0 0;
|
||||
}
|
||||
.journal-empty {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.journal-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.journal-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.journal-table th,
|
||||
.journal-table td {
|
||||
padding: 0.45rem 0.55rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.journal-table th {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.journal-code {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.journal-cell-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.journal-cell-msg {
|
||||
max-width: 14rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.journal-cell-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.journal-status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.journal-status-pill--ok {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--text);
|
||||
}
|
||||
.journal-status-pill--err {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
.journal-status-pill--warn {
|
||||
background: rgba(234, 179, 8, 0.22);
|
||||
}
|
||||
.journal-details-cell {
|
||||
padding: 0.5rem 0.55rem 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
html[data-theme="light"] .journal-details-cell {
|
||||
background: rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.journal-log-pre {
|
||||
margin: 0;
|
||||
max-height: min(40vh, 18rem);
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.journal-page-size-hint {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* Пагинация журнала */
|
||||
.journal-pagination {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.journal-pagination-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem 0.5rem;
|
||||
}
|
||||
.journal-pag-btn {
|
||||
padding: 0.4rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.journal-pag-btn:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.45);
|
||||
}
|
||||
.journal-pag-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.journal-pag-pages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0.15rem;
|
||||
}
|
||||
.journal-pag-num {
|
||||
min-width: 2.35rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.journal-pag-num:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
.journal-pag-num--active {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(129, 140, 248, 0.25));
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.journal-pag-ellipsis {
|
||||
padding: 0 0.2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
}
|
||||
.journal-pagination-summary {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 0.85rem 0 0;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.journal-pagination-inner {
|
||||
flex-direction: column;
|
||||
}
|
||||
.journal-pag-pages {
|
||||
order: -1;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user