Веб-интерфейс: страница /clusters, навигация и крошки для кластеров
- Выделена страница списка кластеров, панель упрощена; nav_active и крошки ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL. - Доработки дашборда, аддонов, журнала, стилей и API-документации. - Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
+494
-46
@@ -12,6 +12,115 @@
|
||||
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
|
||||
/** data-endpoint кнопки → поле ответа GET …/addons/status */
|
||||
var ENDPOINT_TO_STATUS_KEY = {
|
||||
"ingress-nginx": "ingress_nginx",
|
||||
"kube-prometheus-stack": "kube_prometheus_stack",
|
||||
"metrics-server": "metrics_server",
|
||||
"istio-kiali": "istio_mesh_ready",
|
||||
};
|
||||
|
||||
var ADDON_HUMAN_LABEL = {
|
||||
"ingress-nginx": "ingress-nginx",
|
||||
"kube-prometheus-stack": "kube-prometheus-stack",
|
||||
"metrics-server": "metrics-server",
|
||||
"istio-kiali": "Istio + Kiali",
|
||||
};
|
||||
|
||||
/** Последний успешный снимок статуса (для подписи «Установить» / «Обновить»). */
|
||||
var lastAddonsStatus = null;
|
||||
|
||||
/** @type {((value: boolean) => void) | null} */
|
||||
var confirmModalResolver = null;
|
||||
var confirmModalIsAlert = false;
|
||||
|
||||
function isConfirmModalOpen() {
|
||||
const el = document.getElementById("confirm-modal-overlay");
|
||||
return !!(el && !el.classList.contains("hidden"));
|
||||
}
|
||||
|
||||
function closeConfirmModal(confirmed) {
|
||||
const ov = document.getElementById("confirm-modal-overlay");
|
||||
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||
if (ov) ov.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
||||
if (confirmModalResolver) {
|
||||
var fn = confirmModalResolver;
|
||||
confirmModalResolver = null;
|
||||
var out = confirmModalIsAlert ? true : !!confirmed;
|
||||
confirmModalIsAlert = false;
|
||||
fn(out);
|
||||
} else {
|
||||
confirmModalIsAlert = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Модальное подтверждение (разметка из partials/dashboard_modals.html).
|
||||
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: boolean }} opts
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function openConfirmModal(opts) {
|
||||
return new Promise(function (resolve) {
|
||||
const ov = document.getElementById("confirm-modal-overlay");
|
||||
const titleEl = document.getElementById("confirm-modal-title");
|
||||
const msgEl = document.getElementById("confirm-modal-message");
|
||||
const okBtn = document.getElementById("confirm-modal-ok");
|
||||
const cancelBtn = document.getElementById("confirm-modal-cancel");
|
||||
if (!ov || !titleEl || !msgEl || !okBtn) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (confirmModalResolver) {
|
||||
const prevFn = confirmModalResolver;
|
||||
confirmModalResolver = null;
|
||||
confirmModalIsAlert = false;
|
||||
prevFn(false);
|
||||
ov.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
||||
}
|
||||
confirmModalResolver = resolve;
|
||||
confirmModalIsAlert = !!opts.alert;
|
||||
titleEl.textContent = opts.title || "Подтвердите действие";
|
||||
msgEl.textContent = opts.message || "";
|
||||
okBtn.textContent = opts.confirmLabel || (confirmModalIsAlert ? "Понятно" : "Подтвердить");
|
||||
okBtn.className = opts.danger ? "btn-danger" : "";
|
||||
if (cancelBtn) {
|
||||
cancelBtn.classList.toggle("hidden", confirmModalIsAlert);
|
||||
}
|
||||
ov.classList.remove("hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
okBtn.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function bindConfirmModalControls() {
|
||||
const confirmOv = document.getElementById("confirm-modal-overlay");
|
||||
const confirmCancel = document.getElementById("confirm-modal-cancel");
|
||||
const confirmOk = document.getElementById("confirm-modal-ok");
|
||||
if (confirmCancel) {
|
||||
confirmCancel.addEventListener("click", function () {
|
||||
closeConfirmModal(false);
|
||||
});
|
||||
}
|
||||
if (confirmOk) {
|
||||
confirmOk.addEventListener("click", function () {
|
||||
closeConfirmModal(true);
|
||||
});
|
||||
}
|
||||
if (confirmOv) {
|
||||
confirmOv.addEventListener("click", function (ev) {
|
||||
if (ev.target === confirmOv) closeConfirmModal(confirmModalIsAlert ? true : false);
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key !== "Escape" || !isConfirmModalOpen()) return;
|
||||
closeConfirmModal(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [opts]
|
||||
@@ -42,22 +151,80 @@
|
||||
|
||||
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");
|
||||
const addonsCardsWrap = document.getElementById("addons-cards-wrap");
|
||||
const helmWrap = document.getElementById("addons-helm-progress-wrap");
|
||||
const helmHint = document.getElementById("addons-helm-progress-hint");
|
||||
const helmBar = document.getElementById("addons-helm-progress-bar");
|
||||
const helmTrack = document.getElementById("addons-helm-progress-track");
|
||||
const helmLabel = document.getElementById("addons-helm-progress-label");
|
||||
const helmLogPanel = document.getElementById("addons-helm-log-panel");
|
||||
const bootstrapMsg = document.getElementById("addons-bootstrap-msg");
|
||||
|
||||
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 updateAddonsCardsVisibility() {
|
||||
if (!addonsCardsWrap) return;
|
||||
var n = getClusterName();
|
||||
addonsCardsWrap.classList.toggle("hidden", !n);
|
||||
addonsCardsWrap.setAttribute("aria-hidden", n ? "false" : "true");
|
||||
}
|
||||
|
||||
/**
|
||||
* Предупреждения загрузки списка кластеров / версий чартов (не путать с журналом Helm).
|
||||
* @param {string} text пустая строка — скрыть блок
|
||||
*/
|
||||
function setBootstrapMsg(text) {
|
||||
if (!bootstrapMsg) return;
|
||||
var s = (text || "").trim();
|
||||
if (!s) {
|
||||
bootstrapMsg.classList.add("hidden");
|
||||
bootstrapMsg.textContent = "";
|
||||
return;
|
||||
}
|
||||
bootstrapMsg.classList.remove("hidden");
|
||||
bootstrapMsg.textContent = s;
|
||||
}
|
||||
|
||||
/** Показать карточку «Ход операции Helm» (как create-progress-wrap на странице создания кластера). */
|
||||
function revealHelmProgressSection() {
|
||||
if (!helmWrap) return;
|
||||
helmWrap.classList.remove("hidden");
|
||||
helmWrap.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function updateHelmProgressBar(pct, stage) {
|
||||
var p = Math.max(0, Math.min(100, Number(pct) || 0));
|
||||
if (helmBar) helmBar.style.width = p + "%";
|
||||
if (helmTrack) helmTrack.setAttribute("aria-valuenow", String(p));
|
||||
if (helmLabel) helmLabel.textContent = stage || "—";
|
||||
}
|
||||
|
||||
/** Полный текст журнала в панели с теми же классами, что «Журнал задания» при создании кластера. */
|
||||
function setHelmLogText(text) {
|
||||
if (!helmLogPanel) return;
|
||||
helmLogPanel.textContent = text || "";
|
||||
helmLogPanel.scrollTop = helmLogPanel.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка форм на время синхронного запроса Helm (без полноэкранного спиннера).
|
||||
* @param {boolean} disabled
|
||||
*/
|
||||
function setAddonFormsDisabled(disabled) {
|
||||
document
|
||||
.querySelectorAll(
|
||||
"#addons-cards-wrap .cluster-addon-card button, #addons-cards-wrap .cluster-addon-card input, #addons-cards-wrap .cluster-addon-card select, #addons-cards-wrap .cluster-addon-card textarea",
|
||||
)
|
||||
.forEach(function (el) {
|
||||
el.disabled = !!disabled;
|
||||
});
|
||||
if (sel) sel.disabled = !!disabled;
|
||||
var refBtn = document.getElementById("addons-refresh-status");
|
||||
if (refBtn) refBtn.disabled = !!disabled;
|
||||
}
|
||||
|
||||
/** Скрыть полноэкранный спиннер первой загрузки страницы (как на панели и странице кластера). */
|
||||
@@ -70,21 +237,177 @@
|
||||
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;
|
||||
/**
|
||||
* Текстовое сообщение в полосе статуса (нет кластера / ошибка).
|
||||
* @param {string} text
|
||||
*/
|
||||
function renderAddonStatusMessage(text) {
|
||||
if (!statusBar) return;
|
||||
statusBar.className = "cluster-addons-status-bar cluster-addons-status-bar--message muted";
|
||||
statusBar.replaceChildren();
|
||||
const p = document.createElement("p");
|
||||
p.className = "cluster-addons-status-msg";
|
||||
p.textContent = text;
|
||||
statusBar.appendChild(p);
|
||||
}
|
||||
|
||||
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(" · ");
|
||||
/** Максимальная длина поля values_yaml на сервере (см. Pydantic max_length). */
|
||||
var VALUES_YAML_MAX_LEN = 131072;
|
||||
|
||||
/**
|
||||
* Полоса под селектором кластера: только сообщения (нет кластера / ошибка), без сетки статусов.
|
||||
*/
|
||||
function clearAddonToolbarStatusBar() {
|
||||
if (!statusBar) return;
|
||||
statusBar.className = "cluster-addons-status-bar";
|
||||
statusBar.replaceChildren();
|
||||
}
|
||||
|
||||
/**
|
||||
* Бейджи «Установлен» / «Не установлен» в правом верхнем углу карточки аддона.
|
||||
* @param {object | null | undefined} st ответ GET …/addons/status или null при сбросе
|
||||
*/
|
||||
function updateAddonCardBadges(st) {
|
||||
document.querySelectorAll(".cluster-addon-card[data-addon]").forEach(function (card) {
|
||||
var da = card.getAttribute("data-addon") || "";
|
||||
var key = ENDPOINT_TO_STATUS_KEY[da];
|
||||
var on = !!(st && key && st[key]);
|
||||
var badge = card.querySelector(".cluster-addon-card__badge");
|
||||
if (!badge) return;
|
||||
badge.textContent = on ? "Установлен" : "Не установлен";
|
||||
badge.className =
|
||||
"cluster-addon-card__badge" + (on ? " cluster-addon-card__badge--on" : " cluster-addon-card__badge--off");
|
||||
var hum = ADDON_HUMAN_LABEL[da] || da;
|
||||
badge.setAttribute("aria-label", hum + ": " + (on ? "установлен" : "не установлен"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепить к телу POST YAML из textarea (полные values чарта). Для istio-kiali — три поля.
|
||||
* Пустой textarea → поле не отправляется (сервер соберёт defaults или пропустит -f).
|
||||
* @param {string} ep data-endpoint
|
||||
* @param {Record<string, unknown>} payload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function attachAddonYamlToPayload(ep, payload) {
|
||||
if (ep === "istio-kiali") {
|
||||
var triple = [
|
||||
{ id: "addons-values-yaml-istio-kiali", key: "values_yaml" },
|
||||
{ id: "addons-values-yaml-istio-base", key: "istio_base_values_yaml" },
|
||||
{ id: "addons-values-yaml-istio-istiod", key: "istio_istiod_values_yaml" },
|
||||
];
|
||||
for (var i = 0; i < triple.length; i++) {
|
||||
var ta = document.getElementById(triple[i].id);
|
||||
if (!ta) continue;
|
||||
var s = String(ta.value || "").trim();
|
||||
if (!s) continue;
|
||||
if (s.length > VALUES_YAML_MAX_LEN) {
|
||||
alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов).");
|
||||
return false;
|
||||
}
|
||||
payload[triple[i].key] = s;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
var id = "addons-values-yaml-" + ep;
|
||||
var ta2 = document.getElementById(id);
|
||||
if (!ta2) return true;
|
||||
var s2 = String(ta2.value || "").trim();
|
||||
if (!s2) return true;
|
||||
if (s2.length > VALUES_YAML_MAX_LEN) {
|
||||
alert("Текст YAML слишком длинный (максимум " + VALUES_YAML_MAX_LEN + " символов).");
|
||||
return false;
|
||||
}
|
||||
payload.values_yaml = s2;
|
||||
return true;
|
||||
}
|
||||
|
||||
var COMPOSE_DEBOUNCE_MS = 600;
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
var composeDebounceTimer = null;
|
||||
|
||||
/**
|
||||
* Заполнить все textarea: один POST **compose-values-batch** (один helm repo update на сервере).
|
||||
*/
|
||||
async function refreshAllComposeValuesFromServer() {
|
||||
setBootstrapMsg("");
|
||||
var si = document.getElementById("addons-ing-version-select");
|
||||
var pv = document.getElementById("addons-prom-version-select");
|
||||
var ms = document.getElementById("addons-ms-version-select");
|
||||
var isel = document.getElementById("addons-istio-version-select");
|
||||
var ksel = document.getElementById("addons-kiali-version-select");
|
||||
var gu = document.getElementById("addons-grafana-user");
|
||||
var gp = document.getElementById("addons-grafana-pass");
|
||||
var batchBody = {
|
||||
ingress_chart_version: si && si.value.trim() ? si.value.trim() : null,
|
||||
kube_prometheus_chart_version: pv && pv.value.trim() ? pv.value.trim() : null,
|
||||
metrics_server_chart_version: ms && ms.value.trim() ? ms.value.trim() : null,
|
||||
istio_chart_version: isel && isel.value.trim() ? isel.value.trim() : null,
|
||||
kiali_chart_version: ksel && ksel.value.trim() ? ksel.value.trim() : null,
|
||||
grafana_admin_user: gu ? gu.value.trim() || null : null,
|
||||
grafana_admin_password: gp ? gp.value || null : null,
|
||||
};
|
||||
try {
|
||||
var r = await api("/helm/addons/compose-values-batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(batchBody),
|
||||
});
|
||||
var ti = document.getElementById("addons-values-yaml-ingress-nginx");
|
||||
var tp = document.getElementById("addons-values-yaml-kube-prometheus-stack");
|
||||
var tm = document.getElementById("addons-values-yaml-metrics-server");
|
||||
var tk = document.getElementById("addons-values-yaml-istio-kiali");
|
||||
var tb = document.getElementById("addons-values-yaml-istio-base");
|
||||
var tiod = document.getElementById("addons-values-yaml-istio-istiod");
|
||||
if (ti) ti.value = r.ingress_nginx_values_yaml || "";
|
||||
if (tp) tp.value = r.kube_prometheus_values_yaml || "";
|
||||
if (tm) tm.value = r.metrics_server_values_yaml || "";
|
||||
if (tk) tk.value = r.kiali_values_yaml || "";
|
||||
if (tb) tb.value = r.istio_base_values_yaml || "";
|
||||
if (tiod) tiod.value = r.istio_istiod_values_yaml || "";
|
||||
} catch (e) {
|
||||
setBootstrapMsg("Не удалось подставить values из чартов (helm show values): " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleComposeValuesRefresh() {
|
||||
if (composeDebounceTimer) clearTimeout(composeDebounceTimer);
|
||||
composeDebounceTimer = setTimeout(function () {
|
||||
composeDebounceTimer = null;
|
||||
refreshAllComposeValuesFromServer();
|
||||
}, COMPOSE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function addonEndpointInstalled(endpoint) {
|
||||
var key = ENDPOINT_TO_STATUS_KEY[endpoint];
|
||||
return !!(lastAddonsStatus && key && lastAddonsStatus[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Текст «Установить»/«Обновить», видимость «Удалить» только при установленном аддоне; равная ширина — в CSS.
|
||||
*/
|
||||
function updateAddonActionButtons() {
|
||||
document.querySelectorAll(".cluster-addon-card__actions").forEach(function (row) {
|
||||
var installBtn = row.querySelector(".btn-addon-install");
|
||||
var removeBtn = row.querySelector(".btn-addon-remove");
|
||||
if (!installBtn) return;
|
||||
var ep = installBtn.getAttribute("data-endpoint") || "";
|
||||
var on = addonEndpointInstalled(ep);
|
||||
installBtn.textContent = on ? "Обновить" : "Установить";
|
||||
var hum = ADDON_HUMAN_LABEL[ep] || ep;
|
||||
installBtn.setAttribute("aria-label", (on ? "Обновить " : "Установить ") + hum);
|
||||
if (removeBtn) {
|
||||
if (on) {
|
||||
removeBtn.classList.remove("hidden");
|
||||
removeBtn.removeAttribute("hidden");
|
||||
removeBtn.setAttribute("aria-label", "Удалить " + hum);
|
||||
} else {
|
||||
removeBtn.classList.add("hidden");
|
||||
removeBtn.setAttribute("hidden", "hidden");
|
||||
removeBtn.removeAttribute("aria-label");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,13 +443,15 @@
|
||||
async function loadChartVersions() {
|
||||
try {
|
||||
const d = await api("/helm/chart-versions");
|
||||
setBootstrapMsg("");
|
||||
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);
|
||||
await refreshAllComposeValuesFromServer();
|
||||
} catch (e) {
|
||||
appendLog("Список версий чартов недоступен: " + e.message);
|
||||
setBootstrapMsg("Список версий чартов недоступен: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +459,7 @@
|
||||
if (!sel) return;
|
||||
try {
|
||||
const rows = await api("/clusters");
|
||||
setBootstrapMsg("");
|
||||
sel.innerHTML = "";
|
||||
const withKube = rows.filter(function (c) { return c.has_local_kubeconfig; });
|
||||
if (!withKube.length) {
|
||||
@@ -159,23 +485,37 @@
|
||||
o.value = "";
|
||||
o.textContent = "Ошибка загрузки списка";
|
||||
sel.appendChild(o);
|
||||
appendLog("Ошибка GET /clusters: " + e.message);
|
||||
setBootstrapMsg("Ошибка GET /clusters: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const n = getClusterName();
|
||||
if (!statusBar) return;
|
||||
if (!statusBar) {
|
||||
updateAddonsCardsVisibility();
|
||||
return;
|
||||
}
|
||||
if (!n) {
|
||||
statusBar.textContent = "Выберите кластер для просмотра статуса аддонов.";
|
||||
lastAddonsStatus = null;
|
||||
clearAddonToolbarStatusBar();
|
||||
updateAddonCardBadges(null);
|
||||
updateAddonActionButtons();
|
||||
updateAddonsCardsVisibility();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const st = await api("/clusters/" + encodeURIComponent(n) + "/addons/status");
|
||||
statusBar.textContent = formatStatus(st);
|
||||
lastAddonsStatus = st;
|
||||
clearAddonToolbarStatusBar();
|
||||
updateAddonCardBadges(st);
|
||||
updateAddonActionButtons();
|
||||
} catch (e) {
|
||||
statusBar.textContent = "Статус недоступен: " + e.message;
|
||||
lastAddonsStatus = null;
|
||||
renderAddonStatusMessage("Статус недоступен: " + e.message);
|
||||
updateAddonCardBadges(null);
|
||||
updateAddonActionButtons();
|
||||
}
|
||||
updateAddonsCardsVisibility();
|
||||
}
|
||||
|
||||
async function runInstall(endpoint, payload) {
|
||||
@@ -184,22 +524,63 @@
|
||||
alert("Выберите кластер.");
|
||||
return;
|
||||
}
|
||||
setBusy(true, "Установка (Helm), подождите…");
|
||||
if (logPre) logPre.textContent = "";
|
||||
appendLog("POST …/addons/" + endpoint + " для «" + n + "»");
|
||||
const isUpdate = addonEndpointInstalled(endpoint);
|
||||
const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
|
||||
revealHelmProgressSection();
|
||||
setAddonFormsDisabled(true);
|
||||
if (helmHint) {
|
||||
helmHint.textContent = isUpdate
|
||||
? "Обновление релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении."
|
||||
: "Установка релиза «" + hum + "» через helm в контейнере; ниже — полный вывод по завершении.";
|
||||
}
|
||||
updateHelmProgressBar(8, isUpdate ? "Обновление Helm…" : "Установка Helm…");
|
||||
setHelmLogText(
|
||||
"Кластер: «" +
|
||||
n +
|
||||
"»\n" +
|
||||
(isUpdate ? "POST (обновление)" : "POST (установка)") +
|
||||
" /api/v1/clusters/…/addons/" +
|
||||
endpoint +
|
||||
"\n\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);
|
||||
var okLines = [
|
||||
"Кластер: «" + n + "»",
|
||||
(isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint,
|
||||
"",
|
||||
r.message || "OK",
|
||||
];
|
||||
if (r.log) {
|
||||
okLines.push("");
|
||||
okLines.push(String(r.log));
|
||||
}
|
||||
setHelmLogText(okLines.join("\n"));
|
||||
updateHelmProgressBar(100, "Готово");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Операция завершена. Полный журнал добавлен в файл helm_addon_log.json в каталоге кластера (история записей).";
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
appendLog("Ошибка: " + e.message);
|
||||
var failLines = [
|
||||
"Кластер: «" + n + "»",
|
||||
(isUpdate ? "POST (обновление)" : "POST (установка)") + " /addons/" + endpoint,
|
||||
"",
|
||||
"Ошибка: " + e.message,
|
||||
];
|
||||
setHelmLogText(failLines.join("\n"));
|
||||
updateHelmProgressBar(100, "Ошибка");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Запрос не удался. При ответе сервера об ошибке Helm в журнале кластера могла появиться запись со статусом failed.";
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setAddonFormsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,22 +590,66 @@
|
||||
alert("Выберите кластер.");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("Удалить аддон «" + endpoint + "» в кластере «" + n + "»?")) return;
|
||||
setBusy(true, "Удаление релиза, подождите…");
|
||||
if (logPre) logPre.textContent = "";
|
||||
appendLog("DELETE …/addons/" + endpoint + " для «" + n + "»");
|
||||
const hum = ADDON_HUMAN_LABEL[endpoint] || endpoint;
|
||||
const ok = await openConfirmModal({
|
||||
title: "Удалить аддон?",
|
||||
message:
|
||||
"Кластер «" +
|
||||
n +
|
||||
"»: снять релиз «" +
|
||||
hum +
|
||||
"» с кластера (helm uninstall). Действие необратимо для установленных компонентов.",
|
||||
confirmLabel: "Удалить",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
revealHelmProgressSection();
|
||||
setAddonFormsDisabled(true);
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Удаление релиза «" + hum + "» (helm uninstall); ниже — полный вывод по завершении.";
|
||||
}
|
||||
updateHelmProgressBar(8, "Удаление Helm…");
|
||||
setHelmLogText(
|
||||
"Кластер: «" + n + "»\nDELETE /api/v1/clusters/…/addons/" + endpoint + "\n\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);
|
||||
var okDel = [
|
||||
"Кластер: «" + n + "»",
|
||||
"DELETE /addons/" + endpoint,
|
||||
"",
|
||||
r.message || "OK",
|
||||
];
|
||||
if (r.log) {
|
||||
okDel.push("");
|
||||
okDel.push(String(r.log));
|
||||
}
|
||||
setHelmLogText(okDel.join("\n"));
|
||||
updateHelmProgressBar(100, "Готово");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Операция завершена. Запись добавлена в helm_addon_log.json в каталоге кластера (история).";
|
||||
}
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
appendLog("Ошибка: " + e.message);
|
||||
var failDel = [
|
||||
"Кластер: «" + n + "»",
|
||||
"DELETE /addons/" + endpoint,
|
||||
"",
|
||||
"Ошибка: " + e.message,
|
||||
];
|
||||
setHelmLogText(failDel.join("\n"));
|
||||
updateHelmProgressBar(100, "Ошибка");
|
||||
if (helmHint) {
|
||||
helmHint.textContent =
|
||||
"Запрос не удался. При ошибке Helm на сервере в журнале кластера могла появиться запись со статусом failed.";
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setAddonFormsDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +659,10 @@
|
||||
if (ep === "ingress-nginx") {
|
||||
const sel = document.getElementById("addons-ing-version-select");
|
||||
const v = sel && sel.value.trim();
|
||||
runInstall(ep, v ? { chart_version: v } : {});
|
||||
const payload = {};
|
||||
if (v) payload.chart_version = v;
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
return;
|
||||
}
|
||||
if (ep === "kube-prometheus-stack") {
|
||||
@@ -249,6 +677,7 @@
|
||||
}
|
||||
const payload = { grafana_admin_user: user, grafana_admin_password: pw };
|
||||
if (pv && pv.value.trim()) payload.chart_version = pv.value.trim();
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
return;
|
||||
}
|
||||
@@ -256,7 +685,8 @@
|
||||
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 : {});
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
return;
|
||||
}
|
||||
if (ep === "istio-kiali") {
|
||||
@@ -273,6 +703,7 @@
|
||||
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();
|
||||
if (!attachAddonYamlToPayload(ep, payload)) return;
|
||||
runInstall(ep, payload);
|
||||
}
|
||||
});
|
||||
@@ -292,6 +723,23 @@
|
||||
sel.addEventListener("change", function () { refreshStatus(); });
|
||||
}
|
||||
|
||||
bindConfirmModalControls();
|
||||
|
||||
[
|
||||
"addons-ing-version-select",
|
||||
"addons-prom-version-select",
|
||||
"addons-ms-version-select",
|
||||
"addons-istio-version-select",
|
||||
"addons-kiali-version-select",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener("change", function () { scheduleComposeValuesRefresh(); });
|
||||
});
|
||||
var grafanaUserEl = document.getElementById("addons-grafana-user");
|
||||
var grafanaPassEl = document.getElementById("addons-grafana-pass");
|
||||
if (grafanaUserEl) grafanaUserEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
|
||||
if (grafanaPassEl) grafanaPassEl.addEventListener("input", function () { scheduleComposeValuesRefresh(); });
|
||||
|
||||
loadClusters()
|
||||
.then(function () {
|
||||
return refreshStatus();
|
||||
|
||||
+413
-224
@@ -32,9 +32,6 @@
|
||||
var cachedClusterResourcesByName = {};
|
||||
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
|
||||
var cachedHealthPayload = null;
|
||||
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
||||
var cachedLocalClusterDirsCount = null;
|
||||
|
||||
/**
|
||||
* Кэш GET /versions для проверки тега перед созданием кластера.
|
||||
* null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов.
|
||||
@@ -51,6 +48,11 @@
|
||||
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
|
||||
}
|
||||
|
||||
/** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */
|
||||
function isClustersListPage() {
|
||||
return document.body.getAttribute("data-dashboard-mode") === "clusters";
|
||||
}
|
||||
|
||||
function isCreatePage() {
|
||||
return document.body.getAttribute("data-dashboard-mode") === "create";
|
||||
}
|
||||
@@ -84,6 +86,16 @@
|
||||
document.body.classList.remove("dashboard-home-loading");
|
||||
}
|
||||
|
||||
/** Скрыть спиннер первой загрузки страницы «Кластеры». */
|
||||
function hideClustersPageLoadingOverlay() {
|
||||
const el = document.getElementById("clusters-page-loading-overlay");
|
||||
if (!el) return;
|
||||
el.classList.add("hidden");
|
||||
el.setAttribute("aria-busy", "false");
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("dashboard-clusters-loading");
|
||||
}
|
||||
|
||||
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
|
||||
function showClusterLifecycleJobOverlay(labelText) {
|
||||
const el = document.getElementById("cluster-lifecycle-job-overlay");
|
||||
@@ -149,32 +161,6 @@
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return n < 10 ? "0" + n : String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC).
|
||||
* @param {string} iso
|
||||
*/
|
||||
function formatJobTimeUtc(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return (
|
||||
pad2(d.getUTCDate()) +
|
||||
"." +
|
||||
pad2(d.getUTCMonth() + 1) +
|
||||
"." +
|
||||
String(d.getUTCFullYear()).slice(-2) +
|
||||
" " +
|
||||
pad2(d.getUTCHours()) +
|
||||
":" +
|
||||
pad2(d.getUTCMinutes()) +
|
||||
" UTC"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
|
||||
* @param {string} clusterName
|
||||
@@ -459,6 +445,7 @@
|
||||
const failureWrap = document.getElementById("stats-failure-wrap");
|
||||
const env = document.getElementById("stats-env");
|
||||
const metricsGrid = document.getElementById("stats-grid");
|
||||
const resCard = document.getElementById("home-resources-summary-card");
|
||||
if (failureWrap) {
|
||||
failureWrap.classList.remove("hidden");
|
||||
failureWrap.replaceChildren();
|
||||
@@ -480,6 +467,7 @@
|
||||
}
|
||||
if (env) env.classList.add("hidden");
|
||||
if (metricsGrid) metricsGrid.classList.add("hidden");
|
||||
if (resCard) resCard.classList.add("hidden");
|
||||
}
|
||||
|
||||
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
|
||||
@@ -487,31 +475,18 @@
|
||||
const failureWrap = document.getElementById("stats-failure-wrap");
|
||||
const env = document.getElementById("stats-env");
|
||||
const metricsGrid = document.getElementById("stats-grid");
|
||||
const resCard = document.getElementById("home-resources-summary-card");
|
||||
if (failureWrap) {
|
||||
failureWrap.classList.add("hidden");
|
||||
failureWrap.replaceChildren();
|
||||
}
|
||||
if (env) env.classList.remove("hidden");
|
||||
if (metricsGrid) metricsGrid.classList.remove("hidden");
|
||||
}
|
||||
|
||||
/** Карточка-метрика в ряду среды (без зелёного/красного статуса), напр. «Каталогов». */
|
||||
function buildStatsEnvMetricCard(label, valueText) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile stats-env-card stats-env-card--metric";
|
||||
const valEl = document.createElement("span");
|
||||
valEl.className = "stats-tile__value";
|
||||
valEl.textContent = valueText;
|
||||
const labEl = document.createElement("span");
|
||||
labEl.className = "stats-tile__label";
|
||||
labEl.textContent = label;
|
||||
tile.appendChild(valEl);
|
||||
tile.appendChild(labEl);
|
||||
return tile;
|
||||
if (resCard) resCard.classList.remove("hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats).
|
||||
* Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` — первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»).
|
||||
*/
|
||||
function paintStatsEnvRow() {
|
||||
const el = document.getElementById("stats-env");
|
||||
@@ -539,9 +514,6 @@
|
||||
el.appendChild(
|
||||
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
|
||||
);
|
||||
const dirVal =
|
||||
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
|
||||
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
@@ -659,7 +631,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``).
|
||||
* SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»).
|
||||
*/
|
||||
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
|
||||
renderAggregateDonutsToRoot(
|
||||
@@ -1016,6 +988,163 @@
|
||||
renderRows(root, items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Блоки K8s на странице кластера с фильтром по namespace (ключ → поле overview → id корня таблицы).
|
||||
* Автор: Сергей Антропов — https://devops.org.ru
|
||||
*/
|
||||
var CLUSTER_K8S_NS_BLOCKS = [
|
||||
{ key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" },
|
||||
{ key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" },
|
||||
{ key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" },
|
||||
{ key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" },
|
||||
{ key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" },
|
||||
{ key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" },
|
||||
{ key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" },
|
||||
{ key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" },
|
||||
{ key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" },
|
||||
{ key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" },
|
||||
];
|
||||
|
||||
/** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */
|
||||
var lastClusterK8sOverview = null;
|
||||
/** Однократная привязка делегирования change на фильтры namespace. */
|
||||
var clusterNsFiltersBound = false;
|
||||
|
||||
/**
|
||||
* Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview).
|
||||
* @param {object|undefined|null} block
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function k8sBlockItemsArray(block) {
|
||||
if (!block || typeof block !== "object") return [];
|
||||
var items = block.items;
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отбор объектов по выбранному namespace (пустая строка — все).
|
||||
* @param {object[]|undefined} items
|
||||
* @param {string} ns
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function k8sFilterItemsByNamespace(items, ns) {
|
||||
if (!ns) return items || [];
|
||||
return (items || []).filter(function (it) {
|
||||
return ((it.metadata && it.metadata.namespace) || "") === ns;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта.
|
||||
* Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк).
|
||||
* @param {object[]|undefined} items
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function k8sSortNamespacedItems(items) {
|
||||
if (!items || !items.length) return items || [];
|
||||
return items.slice().sort(function (a, b) {
|
||||
var nsA = (a.metadata && a.metadata.namespace) || "";
|
||||
var nsB = (b.metadata && b.metadata.namespace) || "";
|
||||
var c = String(nsA).localeCompare(String(nsB), "ru");
|
||||
if (c !== 0) return c;
|
||||
var nA = (a.metadata && a.metadata.name) || "";
|
||||
var nB = (b.metadata && b.metadata.name) || "";
|
||||
return String(nA).localeCompare(String(nB), "ru");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace``
|
||||
* из таблиц ресурсов (pods/deployments/…); без дублей, сортировка по ru.
|
||||
* Автор: Сергей Антропов — https://devops.org.ru
|
||||
* @param {object} d ответ cluster overview
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function k8sNamespaceNamesFromOverview(d) {
|
||||
var seen = Object.create(null);
|
||||
var ordered = [];
|
||||
|
||||
function addName(raw) {
|
||||
if (raw == null) return;
|
||||
var s = String(raw).trim();
|
||||
if (!s || seen[s]) return;
|
||||
seen[s] = true;
|
||||
ordered.push(s);
|
||||
}
|
||||
|
||||
var nsBlock = d.k8s_namespaces;
|
||||
if (nsBlock && nsBlock.ok) {
|
||||
k8sBlockItemsArray(nsBlock).forEach(function (it) {
|
||||
var meta = it && it.metadata;
|
||||
addName(meta && meta.name);
|
||||
});
|
||||
}
|
||||
|
||||
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
|
||||
var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop];
|
||||
if (!blk || !blk.ok) continue;
|
||||
k8sBlockItemsArray(blk).forEach(function (it) {
|
||||
var meta = it && it.metadata;
|
||||
addName(meta && meta.namespace);
|
||||
});
|
||||
}
|
||||
|
||||
ordered.sort(function (a, b) {
|
||||
return a.localeCompare(b, "ru");
|
||||
});
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-<key>.
|
||||
* @param {object | null | undefined} block
|
||||
* @param {string | null} kubeErr
|
||||
* @param {HTMLElement | null} root
|
||||
* @param {string} filterKey
|
||||
* @param {function(HTMLElement, object[]): void} renderFn
|
||||
*/
|
||||
function clusterK8sRenderBlockNs(block, kubeErr, root, filterKey, renderFn) {
|
||||
if (!root) return;
|
||||
var sel = document.getElementById("cluster-k8s-filter-" + filterKey);
|
||||
var ns = sel && sel.value ? sel.value : "";
|
||||
if (kubeErr) {
|
||||
clusterK8sShowMessage(root, kubeErr);
|
||||
return;
|
||||
}
|
||||
if (!block || !block.ok) {
|
||||
clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны.");
|
||||
return;
|
||||
}
|
||||
var items = k8sFilterItemsByNamespace(k8sBlockItemsArray(block), ns);
|
||||
items = k8sSortNamespacedItems(items);
|
||||
renderFn(root, items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заполняет option во всех селектах фильтра; сохраняет прежнее value, если namespace ещё в списке.
|
||||
* @param {string[]} namespaceNames
|
||||
*/
|
||||
function clusterK8sPopulateNsSelects(namespaceNames) {
|
||||
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
|
||||
var sel = document.getElementById("cluster-k8s-filter-" + row.key);
|
||||
if (!sel) return;
|
||||
var prev = (sel.value || "").trim();
|
||||
sel.replaceChildren();
|
||||
var o0 = document.createElement("option");
|
||||
o0.value = "";
|
||||
o0.textContent = "Все namespace";
|
||||
sel.appendChild(o0);
|
||||
namespaceNames.forEach(function (name) {
|
||||
var o = document.createElement("option");
|
||||
o.value = name;
|
||||
o.textContent = name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if (prev && namespaceNames.indexOf(prev) >= 0) sel.value = prev;
|
||||
else sel.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
function k8sNodeReady(item) {
|
||||
const cond = (item.status && item.status.conditions) || [];
|
||||
for (var i = 0; i < cond.length; i++) {
|
||||
@@ -1244,13 +1373,70 @@
|
||||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Фабрика функций рендера по ключу фильтра (для подов нужен clusterName).
|
||||
* @param {string} cname
|
||||
* @returns {Record<string, function(HTMLElement, object[]): void>}
|
||||
*/
|
||||
function buildClusterK8sNsFilterRenderMap(cname) {
|
||||
return {
|
||||
pods: function (root, items) {
|
||||
renderK8sPodsTable(root, items, cname);
|
||||
},
|
||||
deployments: renderK8sDeploymentsTable,
|
||||
statefulsets: renderK8sStsDsTable,
|
||||
daemonsets: renderK8sStsDsTable,
|
||||
replicasets: renderK8sReplicaSetsTable,
|
||||
jobs: renderK8sJobsTable,
|
||||
cronjobs: renderK8sCronJobsTable,
|
||||
services: renderK8sServicesTable,
|
||||
ingresses: renderK8sIngressesTable,
|
||||
pvcs: renderK8sPvcsTable,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Перерисовка одной таблицы после смены select (без нового API-запроса).
|
||||
* @param {string} key ключ data-cluster-k8s-ns-filter
|
||||
*/
|
||||
function refillClusterK8sNsFilter(key) {
|
||||
if (!lastClusterK8sOverview) return;
|
||||
var d = lastClusterK8sOverview;
|
||||
var kubeErr = d.kubeconfig_error || null;
|
||||
var cname = d.cluster_name || getClusterPageName() || "";
|
||||
var renderByKey = buildClusterK8sNsFilterRenderMap(cname);
|
||||
var renderFn = renderByKey[key];
|
||||
if (!renderFn) return;
|
||||
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
|
||||
if (CLUSTER_K8S_NS_BLOCKS[i].key !== key) continue;
|
||||
var row = CLUSTER_K8S_NS_BLOCKS[i];
|
||||
clusterK8sRenderBlockNs(d[row.prop], kubeErr, document.getElementById(row.rootId), key, renderFn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Делегирование: один listener на document для всех .cluster-k8s-ns-select. */
|
||||
function ensureClusterNsFilterListeners() {
|
||||
if (clusterNsFiltersBound) return;
|
||||
document.addEventListener("change", function (ev) {
|
||||
var t = ev.target;
|
||||
if (!t || !t.matches || !t.matches("select.cluster-k8s-ns-select[data-cluster-k8s-ns-filter]")) return;
|
||||
var key = t.getAttribute("data-cluster-k8s-ns-filter");
|
||||
if (key) refillClusterK8sNsFilter(key);
|
||||
});
|
||||
clusterNsFiltersBound = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблицы Kubernetes из полей ``k8s_*`` ответа overview.
|
||||
* @param {object} d
|
||||
*/
|
||||
function fillClusterK8sTablesFromOverview(d) {
|
||||
lastClusterK8sOverview = d;
|
||||
const kubeErr = d.kubeconfig_error || null;
|
||||
const cname = d.cluster_name || getClusterPageName() || "";
|
||||
ensureClusterNsFilterListeners();
|
||||
|
||||
clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_namespaces,
|
||||
@@ -1258,53 +1444,20 @@
|
||||
document.getElementById("cluster-k8s-namespaces-root"),
|
||||
renderK8sNamespacesTable,
|
||||
);
|
||||
clusterK8sRenderBlock(d.k8s_pods, kubeErr, document.getElementById("cluster-k8s-pods-root"), function (root, items) {
|
||||
renderK8sPodsTable(root, items, cname);
|
||||
|
||||
const renderByKey = buildClusterK8sNsFilterRenderMap(cname);
|
||||
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
|
||||
clusterK8sRenderBlockNs(
|
||||
d[row.prop],
|
||||
kubeErr,
|
||||
document.getElementById(row.rootId),
|
||||
row.key,
|
||||
renderByKey[row.key],
|
||||
);
|
||||
});
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_deployments,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-deployments-root"),
|
||||
renderK8sDeploymentsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_statefulsets,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-statefulsets-root"),
|
||||
renderK8sStsDsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_daemonsets,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-daemonsets-root"),
|
||||
renderK8sStsDsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_replicasets,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-replicasets-root"),
|
||||
renderK8sReplicaSetsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(d.k8s_jobs, kubeErr, document.getElementById("cluster-k8s-jobs-root"), renderK8sJobsTable);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_cronjobs,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-cronjobs-root"),
|
||||
renderK8sCronJobsTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_services,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-services-root"),
|
||||
renderK8sServicesTable,
|
||||
);
|
||||
clusterK8sRenderBlock(
|
||||
d.k8s_ingresses,
|
||||
kubeErr,
|
||||
document.getElementById("cluster-k8s-ingresses-root"),
|
||||
renderK8sIngressesTable,
|
||||
);
|
||||
clusterK8sRenderBlock(d.k8s_pvcs, kubeErr, document.getElementById("cluster-k8s-pvcs-root"), renderK8sPvcsTable);
|
||||
|
||||
/* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */
|
||||
clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1506,43 +1659,57 @@
|
||||
|
||||
async function loadStats() {
|
||||
const grid = document.getElementById("stats-grid");
|
||||
if (!grid) return;
|
||||
const donutsRoot = document.getElementById("aggregate-donuts");
|
||||
const needGrid = !!grid;
|
||||
const needDonuts = !!donutsRoot;
|
||||
if (!needGrid && !needDonuts) return;
|
||||
try {
|
||||
const s = await api("/stats");
|
||||
revealStatsCardBody();
|
||||
cachedLocalClusterDirsCount = s.local_cluster_dirs_count;
|
||||
paintStatsEnvRow();
|
||||
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
|
||||
updateStatsEngineHint(cliForHint);
|
||||
const rows = [
|
||||
["Кластеров в kind", s.kind_clusters_count],
|
||||
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
|
||||
["Заданий в памяти", s.jobs_total],
|
||||
["Заданий с ошибкой", s.jobs_recent_failed],
|
||||
];
|
||||
grid.replaceChildren();
|
||||
rows.forEach(function (kv) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile";
|
||||
const valEl = document.createElement("span");
|
||||
valEl.className = "stats-tile__value";
|
||||
valEl.textContent = String(kv[1]);
|
||||
const labEl = document.createElement("span");
|
||||
labEl.className = "stats-tile__label";
|
||||
labEl.textContent = kv[0];
|
||||
tile.appendChild(valEl);
|
||||
tile.appendChild(labEl);
|
||||
grid.appendChild(tile);
|
||||
});
|
||||
if (needGrid) {
|
||||
revealStatsCardBody();
|
||||
paintStatsEnvRow();
|
||||
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
|
||||
updateStatsEngineHint(cliForHint);
|
||||
const dirVal =
|
||||
s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined
|
||||
? String(s.local_cluster_dirs_count)
|
||||
: "—";
|
||||
const rows = [
|
||||
["Каталогов", dirVal],
|
||||
["Кластеров в kind", s.kind_clusters_count],
|
||||
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
|
||||
["Заданий в памяти", s.jobs_total],
|
||||
["Заданий с ошибкой", s.jobs_recent_failed],
|
||||
["Аддонов", s.helm_addons_installable_count ?? 0],
|
||||
];
|
||||
grid.replaceChildren();
|
||||
rows.forEach(function (kv) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stats-tile";
|
||||
const valEl = document.createElement("span");
|
||||
valEl.className = "stats-tile__value";
|
||||
valEl.textContent = String(kv[1]);
|
||||
const labEl = document.createElement("span");
|
||||
labEl.className = "stats-tile__label";
|
||||
labEl.textContent = kv[0];
|
||||
tile.appendChild(valEl);
|
||||
tile.appendChild(labEl);
|
||||
grid.appendChild(tile);
|
||||
});
|
||||
}
|
||||
cachedClusterResourcesByName = {};
|
||||
renderAggregateDonuts(
|
||||
s.aggregate_cluster_resources,
|
||||
s.cluster_resources_error,
|
||||
s.container_cli || "docker",
|
||||
);
|
||||
if (needDonuts) {
|
||||
renderAggregateDonuts(
|
||||
s.aggregate_cluster_resources,
|
||||
s.cluster_resources_error,
|
||||
s.container_cli || "docker",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
coverStatsCardWithError("Статистика", e.message || String(e));
|
||||
grid.replaceChildren();
|
||||
if (needGrid) {
|
||||
coverStatsCardWithError("Статистика", e.message || String(e));
|
||||
grid.replaceChildren();
|
||||
}
|
||||
cachedClusterResourcesByName = {};
|
||||
const donuts = document.getElementById("aggregate-donuts");
|
||||
if (donuts) {
|
||||
@@ -1692,14 +1859,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function jobBadgeClass(status) {
|
||||
if (status === "success") return "badge badge-ok";
|
||||
if (status === "failed") return "badge badge-err";
|
||||
if (status === "running") return "badge badge-run";
|
||||
if (status === "cancelled") return "badge badge-cancelled";
|
||||
return "badge";
|
||||
}
|
||||
|
||||
async function loadClusters() {
|
||||
const tbody = document.querySelector("#tbl-clusters tbody");
|
||||
const msg = document.getElementById("list-msg");
|
||||
@@ -1768,124 +1927,145 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена).
|
||||
* Формат времени как на странице «Журнал» (ISO → «YYYY-MM-DD HH:MM:SS.sss Z»).
|
||||
* @param {string} iso
|
||||
*/
|
||||
async function clearCompletedJobs() {
|
||||
const ok = await openConfirmModal({
|
||||
title: "Очистить завершённые задания?",
|
||||
message:
|
||||
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
|
||||
confirmLabel: "Очистить",
|
||||
danger: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
function formatJournalUtcDisplay(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } });
|
||||
const text = await r.text();
|
||||
var data = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (e) {
|
||||
data = {};
|
||||
}
|
||||
if (!r.ok) {
|
||||
showToast(formatApiError(data, text || r.statusText), true);
|
||||
return;
|
||||
}
|
||||
var n = data.removed != null ? data.removed : 0;
|
||||
if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать.";
|
||||
showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false);
|
||||
await loadJobs();
|
||||
await loadStats();
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка: " + e.message;
|
||||
showToast(e.message, true);
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка).
|
||||
* Класс плашки статуса записи журнала (success / failed / cancelled).
|
||||
* @param {string} st
|
||||
*/
|
||||
function bindJobsTableJobLogOpen() {
|
||||
function journalStatusPillClass(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";
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрытие строки лога — как на /journal (режим заданий по кластеру).
|
||||
* Автор: Сергей Антропов — https://devops.org.ru
|
||||
*/
|
||||
function bindCreateJobsJournalToggle() {
|
||||
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");
|
||||
if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return;
|
||||
tbl.setAttribute("data-create-jobs-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);
|
||||
const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null;
|
||||
if (!btn || !tbl.contains(btn) || btn.disabled) return;
|
||||
const tr = btn.closest("tr.journal-data-row");
|
||||
if (!tr) return;
|
||||
const trDetails = tr.nextElementSibling;
|
||||
if (!trDetails || !trDetails.classList.contains("journal-details-row")) return;
|
||||
const pre = trDetails.querySelector("pre.journal-log-pre");
|
||||
if (!pre) return;
|
||||
const open = trDetails.classList.contains("hidden");
|
||||
trDetails.classList.toggle("hidden", !open);
|
||||
pre.hidden = !open;
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
btn.textContent = open ? "Скрыть лог" : "Показать лог";
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
|
||||
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
|
||||
*/
|
||||
async function loadJobs() {
|
||||
const tbody = document.querySelector("#tbl-jobs tbody");
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
if (!tbody) return;
|
||||
try {
|
||||
const rows = await api("/jobs?limit=30");
|
||||
const data = await api("/journal/recent?limit=30&offset=0");
|
||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
const frag = document.createDocumentFragment();
|
||||
rows.forEach(function (j) {
|
||||
entries.forEach(function (e) {
|
||||
const tr = document.createElement("tr");
|
||||
const st = escapeHtml(j.status || "");
|
||||
var kindTag = "";
|
||||
if (j.kind === "start_cluster") kindTag = "[старт] ";
|
||||
else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] ";
|
||||
else if (j.kind === "stop_containers") kindTag = "[стоп] ";
|
||||
else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
|
||||
var cellMsg = kindTag + (j.message || "").slice(0, 140);
|
||||
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
|
||||
cellMsg =
|
||||
kindTag +
|
||||
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.className = "journal-data-row";
|
||||
const cluster = e.source_cluster || e.cluster_name || "—";
|
||||
const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc);
|
||||
const kind = e.kind || "";
|
||||
const message = (e.message || "").trim();
|
||||
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
|
||||
const logText = lines.join("\n");
|
||||
const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
|
||||
|
||||
tr.innerHTML =
|
||||
"<td><time datetime=\"" +
|
||||
escapeHtml(iso) +
|
||||
"\" title=\"" +
|
||||
escapeHtml(iso ? "UTC: " + iso : "") +
|
||||
"\">" +
|
||||
escapeHtml(timeLabel) +
|
||||
"</time></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(j.cluster_name || "—") +
|
||||
"<td class=\"journal-cell-time\">" +
|
||||
escapeHtml(finished) +
|
||||
"</td>" +
|
||||
"<td><code class=\"journal-code\">" +
|
||||
escapeHtml(cluster) +
|
||||
"</code></td>" +
|
||||
"<td><code class=\"journal-code journal-cell-kind\">" +
|
||||
escapeHtml(kind) +
|
||||
"</code></td>" +
|
||||
"<td><span class=\"" +
|
||||
jobBadgeClass(j.status) +
|
||||
escapeHtml(journalStatusPillClass(e.status)) +
|
||||
"\">" +
|
||||
st +
|
||||
escapeHtml(e.status || "") +
|
||||
"</span></td>" +
|
||||
"<td class=\"jobs-msg-cell\">" +
|
||||
msgCol +
|
||||
"<td class=\"journal-cell-msg\">" +
|
||||
escapeHtml(message || "—") +
|
||||
"</td>" +
|
||||
"<td class=\"journal-cell-actions\">" +
|
||||
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
|
||||
escapeHtml(detailsId) +
|
||||
"\">Показать лог</button>" +
|
||||
"</td>";
|
||||
|
||||
frag.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=\"" +
|
||||
escapeHtml(detailsId) +
|
||||
"\" class=\"mono journal-log-pre\" hidden></pre>" +
|
||||
"</td>";
|
||||
const pre = trDetails.querySelector("pre");
|
||||
if (pre) pre.textContent = logText;
|
||||
frag.appendChild(trDetails);
|
||||
} else {
|
||||
const btn = tr.querySelector(".btn-journal-toggle");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Нет строк";
|
||||
}
|
||||
}
|
||||
});
|
||||
tbody.replaceChildren(frag);
|
||||
if (msg) {
|
||||
msg.textContent = rows.length
|
||||
? ""
|
||||
: "Заданий ещё не было (или контейнер перезапускали).";
|
||||
if (!entries.length) {
|
||||
msg.textContent =
|
||||
"Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами).";
|
||||
} else {
|
||||
const total = typeof data.total === "number" ? data.total : entries.length;
|
||||
msg.textContent =
|
||||
total > entries.length
|
||||
? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах."
|
||||
: "";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Задания: " + e.message;
|
||||
if (msg) msg.textContent = "Журнал заданий: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2298,8 +2478,10 @@
|
||||
async function refreshLists() {
|
||||
try {
|
||||
await loadHealth();
|
||||
if (isHomePage()) {
|
||||
if (isHomePage() || isClustersListPage()) {
|
||||
await loadStats();
|
||||
}
|
||||
if (isClustersListPage()) {
|
||||
await loadClusters();
|
||||
}
|
||||
if (isCreatePage()) {
|
||||
@@ -2390,15 +2572,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
const btnClearJobs = document.getElementById("btn-jobs-clear");
|
||||
if (btnClearJobs) {
|
||||
btnClearJobs.addEventListener("click", function () {
|
||||
clearCompletedJobs();
|
||||
const btnJobsRefresh = document.getElementById("btn-jobs-refresh");
|
||||
if (btnJobsRefresh) {
|
||||
btnJobsRefresh.addEventListener("click", function () {
|
||||
loadJobs();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.getElementById("tbl-jobs")) {
|
||||
bindJobsTableJobLogOpen();
|
||||
bindCreateJobsJournalToggle();
|
||||
}
|
||||
|
||||
const mClose = document.getElementById("modal-close");
|
||||
@@ -2486,6 +2668,9 @@
|
||||
await loadHealth();
|
||||
if (isHomePage()) {
|
||||
await loadStats();
|
||||
}
|
||||
if (isClustersListPage()) {
|
||||
await loadStats();
|
||||
await loadClusters();
|
||||
}
|
||||
if (isCreatePage()) {
|
||||
@@ -2502,8 +2687,12 @@
|
||||
if (isHomePage() && lm) {
|
||||
lm.textContent = "Ошибка загрузки панели: " + errText;
|
||||
}
|
||||
if (isClustersListPage() && lm) {
|
||||
lm.textContent = "Ошибка загрузки: " + errText;
|
||||
}
|
||||
} finally {
|
||||
hideHomePageLoadingOverlay();
|
||||
hideClustersPageLoadingOverlay();
|
||||
}
|
||||
})();
|
||||
if (isCreatePage()) {
|
||||
|
||||
+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);
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -44,10 +44,59 @@
|
||||
setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует pathname: убирает хвостовые слэши (кроме корня «/»).
|
||||
* Нужен для сопоставления с маршрутами FastAPI без зависимости от редиректов.
|
||||
*/
|
||||
function normalizePathname(path) {
|
||||
var p = path || "/";
|
||||
if (p.length > 1 && p.charAt(p.length - 1) === "/") {
|
||||
p = p.replace(/\/+$/, "");
|
||||
}
|
||||
return p || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Какой пункт шапки (href пилюли) должен быть активен по текущему URL.
|
||||
* Дублирует логику nav_active из шаблонов: страницы кластера и /cluster/…/edit
|
||||
* относятся к разделу «Кластеры», чтобы подсветка не зависела только от SSR
|
||||
* (кэш образа, устаревший контейнер и т.п.).
|
||||
*/
|
||||
function desiredNavHrefFromPath() {
|
||||
var path = normalizePathname(window.location.pathname);
|
||||
if (path === "/") return "/";
|
||||
if (path === "/clusters") return "/clusters";
|
||||
if (/^\/cluster\/[^/]+\/edit$/.test(path)) return "/clusters";
|
||||
if (/^\/cluster\/[^/]+$/.test(path)) return "/clusters";
|
||||
if (path.indexOf("/cluster-addons") === 0) return "/cluster-addons";
|
||||
if (path.indexOf("/journal") === 0) return "/journal";
|
||||
if (path.indexOf("/documentation") === 0) return "/documentation";
|
||||
if (path.indexOf("/cluster-create") === 0) return "/clusters";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Снимает активность со всех пилюль в прокручиваемой полосе и включает одну по href. */
|
||||
function syncNavPillActive() {
|
||||
var want = desiredNavHrefFromPath();
|
||||
if (!want) return;
|
||||
var scroll = document.querySelector(".nav-links-scroll");
|
||||
if (!scroll) return;
|
||||
var pills = scroll.querySelectorAll("a.nav-link.nav-pill[href]");
|
||||
pills.forEach(function (a) {
|
||||
a.classList.remove("nav-pill--active");
|
||||
});
|
||||
pills.forEach(function (a) {
|
||||
if (a.getAttribute("href") === want) {
|
||||
a.classList.add("nav-pill--active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
var btn = document.getElementById("theme-toggle");
|
||||
if (btn) btn.addEventListener("click", toggleTheme);
|
||||
syncToggleButton();
|
||||
syncNavPillActive();
|
||||
}
|
||||
|
||||
/* defer: скрипт в <head> выполняется после разбора документа — кнопка уже есть. */
|
||||
|
||||
Reference in New Issue
Block a user