52538d9816
- PUT/GET конфигурации кластера, страница редактирования и модалка после сохранения - После смены kind-config: флаг в meta и start_cluster_reapply (kind delete + create) - Старт/стоп: полноэкранный спиннер до завершения job; модалки и документация API - Таблица кластеров: колонка Имя 40% при table-layout fixed; чекбоксы без width 100% - Карточки ресурсов узлов на странице кластера: до 3 в ряд; прочие правки стилей и dashboard.js
2430 lines
94 KiB
JavaScript
2430 lines
94 KiB
JavaScript
/**
|
||
* Панель управления кластерами kind (REST /api/v1).
|
||
* Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная
|
||
* замена содержимого блоков (статистика, таблицы, плашка среды) — SPA-поведение.
|
||
*
|
||
* Автор: Сергей Антропов
|
||
* Сайт: https://devops.org.ru
|
||
*/
|
||
(function () {
|
||
"use strict";
|
||
|
||
const body = document.body;
|
||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||
|
||
/** Интервал автообновления таблиц и среды на панели (мс) */
|
||
var AUTO_REFRESH_MS = 3500;
|
||
/** Опрос GET /jobs/{id} для активного задания (мс) — чаще, чтобы журнал обновлялся живее */
|
||
var JOB_POLL_ACTIVE_MS = 650;
|
||
|
||
/** @type {ReturnType<typeof setInterval> | null} */
|
||
var autoTimer = null;
|
||
/** @type {ReturnType<typeof setInterval> | null} */
|
||
var pollTimer = null;
|
||
var createInProgress = false;
|
||
/** Блокировать форму «Создать» только при создании из формы, не при старт/стоп из таблицы. */
|
||
var pollLockCreateForm = false;
|
||
/** @type {string | null} */
|
||
var currentPollJobId = null;
|
||
/** Полноэкранный спиннер старт/стоп (не используется при создании кластера из формы). */
|
||
var clusterLifecycleJobOverlayActive = false;
|
||
/** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */
|
||
var cachedClusterResourcesByName = {};
|
||
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
|
||
var cachedHealthPayload = null;
|
||
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
||
var cachedLocalClusterDirsCount = null;
|
||
|
||
/**
|
||
* Кэш GET /versions для проверки тега перед созданием кластера.
|
||
* null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов.
|
||
*/
|
||
var cachedKindestTags = null;
|
||
/** true, если запрос /versions упал (сеть, 5xx и т.д.). */
|
||
var cachedKindestVersionsLoadError = false;
|
||
/** true, если сервер вернул skipped (например KIND_K8S_SKIP_VERSION_LIST). */
|
||
var cachedKindestTagsSkipped = false;
|
||
/** Режим модалки подтверждения: только кнопка «Понятно» (Escape закрывает как OK). */
|
||
var confirmModalIsAlert = false;
|
||
|
||
function isHomePage() {
|
||
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
|
||
}
|
||
|
||
function isCreatePage() {
|
||
return document.body.getAttribute("data-dashboard-mode") === "create";
|
||
}
|
||
|
||
/** Страница одного кластера: ``/cluster/<имя>``. */
|
||
function isClusterPage() {
|
||
return document.body.getAttribute("data-dashboard-mode") === "cluster";
|
||
}
|
||
|
||
/** Имя кластера с ``body[data-cluster-name]`` (страница кластера). */
|
||
function getClusterPageName() {
|
||
const raw = document.body.getAttribute("data-cluster-name");
|
||
return raw && raw.trim() ? raw.trim() : null;
|
||
}
|
||
|
||
/** Скрыть полноэкранный спиннер первой загрузки страницы кластера. */
|
||
function hideClusterPageLoadingOverlay() {
|
||
const el = document.getElementById("cluster-page-loading-overlay");
|
||
if (!el) return;
|
||
el.classList.add("hidden");
|
||
el.setAttribute("aria-busy", "false");
|
||
document.body.classList.remove("cluster-page-loading");
|
||
}
|
||
|
||
/** Скрыть спиннер первой загрузки главной панели. */
|
||
function hideHomePageLoadingOverlay() {
|
||
const el = document.getElementById("home-page-loading-overlay");
|
||
if (!el) return;
|
||
el.classList.add("hidden");
|
||
el.setAttribute("aria-busy", "false");
|
||
document.body.classList.remove("dashboard-home-loading");
|
||
}
|
||
|
||
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
|
||
function showClusterLifecycleJobOverlay(labelText) {
|
||
const el = document.getElementById("cluster-lifecycle-job-overlay");
|
||
const lab = document.getElementById("cluster-lifecycle-job-label");
|
||
if (!el) return;
|
||
clusterLifecycleJobOverlayActive = true;
|
||
if (lab) lab.textContent = labelText || "Выполняется операция…";
|
||
el.classList.remove("hidden");
|
||
el.setAttribute("aria-busy", "true");
|
||
el.setAttribute("aria-hidden", "false");
|
||
document.body.classList.add("cluster-lifecycle-job-busy");
|
||
}
|
||
|
||
function hideClusterLifecycleJobOverlay() {
|
||
const el = document.getElementById("cluster-lifecycle-job-overlay");
|
||
if (!el) return;
|
||
clusterLifecycleJobOverlayActive = false;
|
||
el.classList.add("hidden");
|
||
el.setAttribute("aria-busy", "false");
|
||
el.setAttribute("aria-hidden", "true");
|
||
document.body.classList.remove("cluster-lifecycle-job-busy");
|
||
}
|
||
|
||
function formatApiError(data, fallback) {
|
||
if (!data) return fallback;
|
||
if (typeof data.detail === "string") return data.detail;
|
||
if (Array.isArray(data.detail)) {
|
||
return data.detail
|
||
.map(function (x) {
|
||
return (x.msg || x) + (x.loc ? " (" + x.loc.join(".") + ")" : "");
|
||
})
|
||
.join("; ");
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/**
|
||
* @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 = formatApiError(data, text || r.statusText);
|
||
const err = new Error(msg);
|
||
err.status = r.status;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
const d = document.createElement("div");
|
||
d.textContent = s;
|
||
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
|
||
*/
|
||
function isProvisionLogModalOpen() {
|
||
const el = document.getElementById("provision-log-modal-overlay");
|
||
return !!(el && !el.classList.contains("hidden"));
|
||
}
|
||
|
||
function closeProvisionLogModal() {
|
||
const el = document.getElementById("provision-log-modal-overlay");
|
||
if (el) el.classList.add("hidden");
|
||
}
|
||
|
||
/**
|
||
* Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке.
|
||
* @param {string} clusterName
|
||
*/
|
||
function openProvisionLogModal(clusterName) {
|
||
const overlay = document.getElementById("provision-log-modal-overlay");
|
||
const sub = document.getElementById("provision-log-modal-sub");
|
||
const pre = document.getElementById("provision-log-body");
|
||
if (!overlay || !pre) return;
|
||
pre.textContent = "Загрузка…";
|
||
if (sub) sub.textContent = "Кластер: " + clusterName;
|
||
overlay.classList.remove("hidden");
|
||
api("/clusters/" + encodeURIComponent(clusterName) + "/provision-log")
|
||
.then(function (data) {
|
||
pre.textContent = JSON.stringify(data, null, 2);
|
||
})
|
||
.catch(function (e) {
|
||
pre.textContent = "Ошибка: " + e.message;
|
||
});
|
||
}
|
||
|
||
function downloadKubeconfig(clusterName) {
|
||
const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig";
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "kubeconfig-" + clusterName + ".yaml";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
}
|
||
|
||
/** SVG-иконки действий (stroke, currentColor). */
|
||
var ICONS = {
|
||
state:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>',
|
||
play:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="7 3 21 12 7 21 7 3"/></svg>',
|
||
stop:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>',
|
||
download:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
||
trash:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
|
||
logFile:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
|
||
restart:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
|
||
edit:
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>',
|
||
};
|
||
|
||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||
var actionTooltipHideTimer = null;
|
||
|
||
function getActionTooltipEl() {
|
||
return document.getElementById("action-tooltip");
|
||
}
|
||
|
||
/** Скрыть плавающую подсказку (#action-tooltip, position: fixed). */
|
||
function hideActionTooltip() {
|
||
if (actionTooltipHideTimer) {
|
||
clearTimeout(actionTooltipHideTimer);
|
||
actionTooltipHideTimer = null;
|
||
}
|
||
const el = getActionTooltipEl();
|
||
if (!el) return;
|
||
el.classList.add("hidden");
|
||
el.textContent = "";
|
||
el.style.visibility = "";
|
||
}
|
||
|
||
/**
|
||
* Показать подсказку у иконки (полный текст, без обрезки overflow таблицы).
|
||
* @param {HTMLElement} host элемент .icon-tooltip-host
|
||
*/
|
||
function showActionTooltip(host) {
|
||
const el = getActionTooltipEl();
|
||
const text = host.getAttribute("data-tooltip");
|
||
if (!el || !text) return;
|
||
if (actionTooltipHideTimer) {
|
||
clearTimeout(actionTooltipHideTimer);
|
||
actionTooltipHideTimer = null;
|
||
}
|
||
el.textContent = text;
|
||
const margin = 10;
|
||
const maxW = Math.min(352, window.innerWidth - margin * 2);
|
||
el.style.maxWidth = maxW + "px";
|
||
el.classList.remove("hidden");
|
||
var w = el.offsetWidth;
|
||
var h = el.offsetHeight;
|
||
var r = host.getBoundingClientRect();
|
||
var left = r.left + r.width / 2 - w / 2;
|
||
if (left < margin) left = margin;
|
||
if (left + w > window.innerWidth - margin) left = Math.max(margin, window.innerWidth - w - margin);
|
||
var top = r.top - h - margin;
|
||
if (top < margin) top = r.bottom + margin;
|
||
if (top + h > window.innerHeight - margin) {
|
||
top = Math.max(margin, window.innerHeight - h - margin);
|
||
}
|
||
el.style.left = left + "px";
|
||
el.style.top = top + "px";
|
||
}
|
||
|
||
/**
|
||
* Подписать .icon-tooltip-host внутри root на hover/focus (повторно безопасно).
|
||
* @param {ParentNode | null} root
|
||
*/
|
||
function bindActionTooltipHosts(root) {
|
||
if (!root) return;
|
||
root.querySelectorAll(".icon-tooltip-host").forEach(function (host) {
|
||
if (host.dataset.actionTooltipBound === "1") return;
|
||
host.dataset.actionTooltipBound = "1";
|
||
host.addEventListener("mouseenter", function () {
|
||
showActionTooltip(host);
|
||
});
|
||
host.addEventListener("mouseleave", function () {
|
||
actionTooltipHideTimer = setTimeout(hideActionTooltip, 120);
|
||
});
|
||
host.addEventListener("focusin", function () {
|
||
showActionTooltip(host);
|
||
});
|
||
host.addEventListener("focusout", function (ev) {
|
||
var rel = ev.relatedTarget;
|
||
if (!rel || !host.contains(rel)) hideActionTooltip();
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Кнопка-иконка в обёртке; подсказка — data-tooltip (см. #action-tooltip в base.html).
|
||
* @param {string} svgHtml
|
||
* @param {string} tooltip
|
||
* @param {string} [classExtra] например icon-btn--danger
|
||
* @param {((ev: Event) => void) | null} onClick
|
||
* @param {boolean} [isDisabled]
|
||
*/
|
||
function iconActionButton(svgHtml, tooltip, classExtra, onClick, isDisabled) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "icon-btn" + (classExtra ? " " + classExtra : "");
|
||
btn.innerHTML = svgHtml;
|
||
btn.setAttribute("aria-label", tooltip);
|
||
if (isDisabled) {
|
||
btn.disabled = true;
|
||
} else if (onClick) {
|
||
btn.addEventListener("click", onClick);
|
||
}
|
||
const host = document.createElement("span");
|
||
host.className = "icon-tooltip-host";
|
||
host.setAttribute("data-tooltip", tooltip);
|
||
host.appendChild(btn);
|
||
return host;
|
||
}
|
||
|
||
function showToast(message, isError) {
|
||
const el = document.getElementById("toast");
|
||
if (!el) return;
|
||
el.textContent = message;
|
||
el.classList.remove("hidden", "toast-error", "toast-ok");
|
||
el.classList.add(isError ? "toast-error" : "toast-ok");
|
||
clearTimeout(el._hideT);
|
||
el._hideT = setTimeout(function () {
|
||
el.classList.add("hidden");
|
||
}, 4500);
|
||
}
|
||
|
||
/**
|
||
* Карточки среды в блоке статистики (как плитки «Кластеров в kind»).
|
||
* @param {string} label подпись снизу (CAPS в стиле stats-tile__label)
|
||
* @param {string} valueText крупный текст сверху
|
||
* @param {boolean} isOk
|
||
* @param {string | null} [detail] доп. строка мелким шрифтом
|
||
*/
|
||
function buildStatsEnvCard(label, valueText, isOk, detail) {
|
||
const tile = document.createElement("div");
|
||
tile.className =
|
||
"stats-tile stats-env-card " + (isOk ? "stats-env-card--ok" : "stats-env-card--bad");
|
||
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);
|
||
if (detail) {
|
||
const dEl = document.createElement("span");
|
||
dEl.className = "stats-env-card__detail muted";
|
||
dEl.textContent = detail.slice(0, 400);
|
||
tile.appendChild(dEl);
|
||
}
|
||
return tile;
|
||
}
|
||
|
||
/** Нормализованное имя CLI для подписей (docker → Docker, podman → Podman). */
|
||
function titleCaseContainerCli(cliRaw) {
|
||
const s = String(cliRaw || "docker").trim() || "docker";
|
||
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||
}
|
||
|
||
/** Текст под заголовком «Статистика»: через какой движок идут узлы и метрики. */
|
||
function updateStatsEngineHint(cliRaw) {
|
||
const el = document.getElementById("stats-engine-hint");
|
||
if (!el) return;
|
||
const name = titleCaseContainerCli(cliRaw);
|
||
el.textContent =
|
||
"Узлы kind (контейнеры control-plane и worker) и метрики ниже собираются через " + name + ".";
|
||
el.classList.remove("hidden");
|
||
}
|
||
|
||
function hideStatsEngineHint() {
|
||
const el = document.getElementById("stats-engine-hint");
|
||
if (!el) return;
|
||
el.classList.add("hidden");
|
||
el.textContent = "";
|
||
}
|
||
|
||
/**
|
||
* Скрыть ряд среды и метрик, показать одну карточку ошибки на всю карточку «Статистика».
|
||
* @param {"Health"|"Статистика"} sourceLabel подпись под «Ошибка»
|
||
*/
|
||
function coverStatsCardWithError(sourceLabel, message) {
|
||
hideStatsEngineHint();
|
||
const failureWrap = document.getElementById("stats-failure-wrap");
|
||
const env = document.getElementById("stats-env");
|
||
const metricsGrid = document.getElementById("stats-grid");
|
||
if (failureWrap) {
|
||
failureWrap.classList.remove("hidden");
|
||
failureWrap.replaceChildren();
|
||
const tile = document.createElement("div");
|
||
tile.className = "stats-tile stats-env-card stats-env-card--bad stats-failure-card";
|
||
const valEl = document.createElement("span");
|
||
valEl.className = "stats-tile__value";
|
||
valEl.textContent = "Ошибка";
|
||
const labEl = document.createElement("span");
|
||
labEl.className = "stats-tile__label";
|
||
labEl.textContent = sourceLabel;
|
||
const dEl = document.createElement("span");
|
||
dEl.className = "stats-env-card__detail muted";
|
||
dEl.textContent = message || "";
|
||
tile.appendChild(valEl);
|
||
tile.appendChild(labEl);
|
||
tile.appendChild(dEl);
|
||
failureWrap.appendChild(tile);
|
||
}
|
||
if (env) env.classList.add("hidden");
|
||
if (metricsGrid) metricsGrid.classList.add("hidden");
|
||
}
|
||
|
||
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
|
||
function revealStatsCardBody() {
|
||
const failureWrap = document.getElementById("stats-failure-wrap");
|
||
const env = document.getElementById("stats-env");
|
||
const metricsGrid = document.getElementById("stats-grid");
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats).
|
||
*/
|
||
function paintStatsEnvRow() {
|
||
const el = document.getElementById("stats-env");
|
||
if (!el) return;
|
||
if (!cachedHealthPayload) {
|
||
return;
|
||
}
|
||
el.className = "stats-env-cards";
|
||
el.replaceChildren();
|
||
const h = cachedHealthPayload;
|
||
const cliRaw = String(h.container_cli || "docker").trim() || "docker";
|
||
const cliTitle = titleCaseContainerCli(cliRaw);
|
||
const apiOk = !!h.container_engine_ok;
|
||
el.appendChild(
|
||
buildStatsEnvCard(
|
||
cliTitle + " API",
|
||
apiOk ? "OK" : "Недоступно",
|
||
apiOk,
|
||
!apiOk && h.container_engine_detail ? String(h.container_engine_detail) : null,
|
||
),
|
||
);
|
||
const kindOk = !!h.kind_in_path;
|
||
el.appendChild(buildStatsEnvCard("Kind", kindOk ? "OK" : "нет в PATH", kindOk, null));
|
||
const kubectlOk = !!h.kubectl_in_path;
|
||
el.appendChild(
|
||
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
|
||
);
|
||
const dirVal =
|
||
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
|
||
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
|
||
}
|
||
|
||
async function loadHealth() {
|
||
const el = document.getElementById("stats-env");
|
||
if (!el) return;
|
||
try {
|
||
const h = await api("/health");
|
||
cachedHealthPayload = h;
|
||
revealStatsCardBody();
|
||
paintStatsEnvRow();
|
||
updateStatsEngineHint(h.container_cli);
|
||
} catch (e) {
|
||
cachedHealthPayload = null;
|
||
el.replaceChildren();
|
||
coverStatsCardWithError("Health", e.message || String(e));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Отрисовка донатов в заданный корень (главная: ``#aggregate-donuts``, страница кластера: ``#cluster-page-aggregate-donuts``).
|
||
* @param {HTMLElement | null} root
|
||
* @param {object | null | undefined} agg
|
||
* @param {string | null | undefined} resourcesError
|
||
* @param {string | undefined} containerCliRaw docker/podman для подписи доната RAM
|
||
*/
|
||
function renderAggregateDonutsToRoot(root, agg, resourcesError, containerCliRaw) {
|
||
if (!root) return;
|
||
|
||
root.replaceChildren();
|
||
if (resourcesError) {
|
||
const p = document.createElement("p");
|
||
p.className = "muted aggregate-donuts-placeholder";
|
||
p.textContent = "Метрики узлов недоступны: " + resourcesError;
|
||
root.appendChild(p);
|
||
return;
|
||
}
|
||
if (!agg || !agg.nodes_count) {
|
||
const p2 = document.createElement("p");
|
||
p2.className = "muted aggregate-donuts-placeholder";
|
||
p2.textContent = "Нет запущенных узлов — диаграммы появятся после старта кластера.";
|
||
root.appendChild(p2);
|
||
return;
|
||
}
|
||
|
||
function donutItem(title, ringPct, centerLabel, subLine) {
|
||
const p = Math.max(0, Math.min(100, Number(ringPct) || 0));
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "donut-item";
|
||
wrap.setAttribute("role", "group");
|
||
wrap.setAttribute("aria-label", title);
|
||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||
svg.setAttribute("viewBox", "0 0 110 110");
|
||
svg.setAttribute("class", "donut-svg");
|
||
svg.setAttribute("aria-hidden", "true");
|
||
const track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||
track.setAttribute("class", "donut-track");
|
||
track.setAttribute("cx", "55");
|
||
track.setAttribute("cy", "55");
|
||
track.setAttribute("r", "40");
|
||
track.setAttribute("fill", "none");
|
||
track.setAttribute("stroke-width", "9");
|
||
track.setAttribute("pathLength", "100");
|
||
const fill = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||
fill.setAttribute("class", "donut-fill");
|
||
fill.setAttribute("cx", "55");
|
||
fill.setAttribute("cy", "55");
|
||
fill.setAttribute("r", "40");
|
||
fill.setAttribute("fill", "none");
|
||
fill.setAttribute("stroke-width", "9");
|
||
fill.setAttribute("pathLength", "100");
|
||
fill.setAttribute("stroke-dasharray", p + " " + (100 - p));
|
||
fill.setAttribute("transform", "rotate(-90 55 55)");
|
||
svg.appendChild(track);
|
||
svg.appendChild(fill);
|
||
wrap.appendChild(svg);
|
||
const meta = document.createElement("div");
|
||
meta.className = "donut-meta";
|
||
const tEl = document.createElement("span");
|
||
tEl.className = "donut-meta__title";
|
||
tEl.textContent = title;
|
||
meta.appendChild(tEl);
|
||
const vEl = document.createElement("span");
|
||
vEl.className = "donut-meta__val";
|
||
vEl.textContent = centerLabel || "—";
|
||
meta.appendChild(vEl);
|
||
if (subLine) {
|
||
const sEl = document.createElement("span");
|
||
sEl.className = "donut-meta__sub muted";
|
||
sEl.textContent = subLine;
|
||
meta.appendChild(sEl);
|
||
}
|
||
wrap.appendChild(meta);
|
||
return wrap;
|
||
}
|
||
|
||
const ramEngine = titleCaseContainerCli(containerCliRaw);
|
||
const grid = document.createElement("div");
|
||
grid.className = "donut-grid";
|
||
grid.appendChild(
|
||
donutItem("CPU (ср.)", agg.cpu_ring, String(agg.cpu_label || "—"), "узлов: " + agg.nodes_count),
|
||
);
|
||
grid.appendChild(
|
||
donutItem("% RAM (" + ramEngine + ")", agg.memory_percent_ring, String(agg.memory_percent_label || "—"), ""),
|
||
);
|
||
grid.appendChild(
|
||
donutItem("RAM used/limit", agg.memory_used_ratio_ring, String(agg.memory_used_ratio_label || "—"), ""),
|
||
);
|
||
grid.appendChild(
|
||
donutItem("Сеть (всего)", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"),
|
||
);
|
||
grid.appendChild(
|
||
donutItem("Диск (всего)", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"),
|
||
);
|
||
root.appendChild(grid);
|
||
}
|
||
|
||
/**
|
||
* SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``).
|
||
*/
|
||
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
|
||
renderAggregateDonutsToRoot(
|
||
document.getElementById("aggregate-donuts"),
|
||
agg,
|
||
resourcesError,
|
||
containerCliRaw,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Доля из строки вида «12.3%» для мини-полосы (0–100).
|
||
* @param {unknown} value
|
||
* @returns {number | null}
|
||
*/
|
||
function parseLoosePercent(value) {
|
||
if (value == null || value === "") return null;
|
||
const m = String(value).match(/([\d.,]+)\s*%/);
|
||
if (!m) return null;
|
||
const x = parseFloat(m[1].replace(",", "."));
|
||
if (Number.isNaN(x)) return null;
|
||
return Math.max(0, Math.min(100, x));
|
||
}
|
||
|
||
/** Множители суффиксов Docker/Podman (как в ``container_resource_stats.py``). */
|
||
var DOCKER_SUFFIX_TO_BYTES = {
|
||
b: 1,
|
||
kb: 1e3,
|
||
kib: 1024,
|
||
mb: 1e6,
|
||
mib: Math.pow(1024, 2),
|
||
gb: 1e9,
|
||
gib: Math.pow(1024, 3),
|
||
tb: 1e12,
|
||
tib: Math.pow(1024, 4),
|
||
pb: 1e15,
|
||
pib: Math.pow(1024, 5),
|
||
};
|
||
|
||
/**
|
||
* Разбор одного значения вроде ``512MiB``, ``800kB`` в байты.
|
||
* @param {string} chunk
|
||
* @returns {number | null}
|
||
*/
|
||
function parseDockerSizeBytes(chunk) {
|
||
const t = String(chunk || "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/\s/g, "");
|
||
if (!t) return null;
|
||
const m = t.match(/^([\d.]+)([a-z]+)$/);
|
||
if (!m) return null;
|
||
const mult = DOCKER_SUFFIX_TO_BYTES[m[2]];
|
||
if (mult == null) return null;
|
||
const v = parseFloat(m[1]);
|
||
if (Number.isNaN(v)) return null;
|
||
return v * mult;
|
||
}
|
||
|
||
/**
|
||
* Сумма байт по NetIO/BlockIO (чтение + запись, обе части до ``/``).
|
||
* @param {string | null | undefined} raw
|
||
* @returns {number | null}
|
||
*/
|
||
function parseIoPairSumBytes(raw) {
|
||
if (raw == null || String(raw).indexOf("/") < 0) return null;
|
||
const parts = String(raw).split("/");
|
||
const ba = parseDockerSizeBytes(parts[0]);
|
||
const bb = parseDockerSizeBytes(parts[1]);
|
||
if (ba == null && bb == null) return null;
|
||
return (ba || 0) + (bb || 0);
|
||
}
|
||
|
||
/**
|
||
* Процент занятой памяти: левая часть MemUsage / правая (лимит контейнера).
|
||
* @param {string | null | undefined} memoryUsage
|
||
* @returns {number | null}
|
||
*/
|
||
function memoryUsedRatioPercent(memoryUsage) {
|
||
if (memoryUsage == null || String(memoryUsage).indexOf("/") < 0) return null;
|
||
const pair = String(memoryUsage).split("/");
|
||
const used = parseDockerSizeBytes(pair[0]);
|
||
const limit = parseDockerSizeBytes(pair[1]);
|
||
if (used == null || limit == null || limit <= 0) return null;
|
||
return Math.min(100, (100 * used) / limit);
|
||
}
|
||
|
||
/**
|
||
* Карточка одного узла: все метрики ``docker stats`` полосами, как CPU/RAM.
|
||
* Сеть и диск нормализуются к максимуму среди узлов этого кластера (сравнение нагрузки).
|
||
* @param {object} n KindNodeResourceStat
|
||
* @param {{ netPct: number, blkPct: number }} rel
|
||
*/
|
||
function buildClusterResourceNodeCard(n, rel) {
|
||
const card = document.createElement("article");
|
||
card.className = "cluster-node-res-card";
|
||
const head = document.createElement("div");
|
||
head.className = "cluster-node-res-card__head";
|
||
const code = document.createElement("code");
|
||
code.textContent = n.container_name || "—";
|
||
head.appendChild(code);
|
||
card.appendChild(head);
|
||
|
||
const bars = document.createElement("div");
|
||
bars.className = "cluster-node-res-card__bars";
|
||
/**
|
||
* @param {string} label
|
||
* @param {number | null | undefined} pct 0–100
|
||
* @param {string} [hint] краткое значение справа
|
||
* @param {string} [rowTitle] подсказка при наведении на строку
|
||
*/
|
||
function addBar(label, pct, hint, rowTitle) {
|
||
const row = document.createElement("div");
|
||
row.className = "cluster-node-res-card__bar-row";
|
||
if (rowTitle) row.title = rowTitle;
|
||
const lab = document.createElement("span");
|
||
lab.className = "cluster-node-res-card__bar-label";
|
||
lab.textContent = label;
|
||
const track = document.createElement("div");
|
||
track.className = "cluster-node-res-card__bar-track";
|
||
const fill = document.createElement("div");
|
||
fill.className = "cluster-node-res-card__bar-fill";
|
||
const p = pct != null && !Number.isNaN(pct) ? pct : 0;
|
||
fill.style.width = Math.min(100, Math.max(0, p)) + "%";
|
||
track.appendChild(fill);
|
||
row.appendChild(lab);
|
||
row.appendChild(track);
|
||
const hi = document.createElement("span");
|
||
hi.className = "cluster-node-res-card__bar-hint muted";
|
||
hi.textContent = hint != null && hint !== "" ? hint : "—";
|
||
row.appendChild(hi);
|
||
bars.appendChild(row);
|
||
}
|
||
|
||
const cpuP = parseLoosePercent(n.cpu_percent);
|
||
addBar("CPU", cpuP, n.cpu_percent || "—", "Загрузка CPU (docker stats CPUPerc)");
|
||
|
||
const ramP = parseLoosePercent(n.memory_percent);
|
||
addBar("RAM %", ramP, n.memory_percent || "—", "Процент памяти от лимита контейнера (MemPerc)");
|
||
|
||
const memRatio = memoryUsedRatioPercent(n.memory_usage);
|
||
addBar(
|
||
"Память",
|
||
memRatio,
|
||
n.memory_usage || "—",
|
||
"Использовано / лимит контейнера (MemUsage): доля в процентах",
|
||
);
|
||
|
||
addBar(
|
||
"Сеть",
|
||
rel.netPct,
|
||
n.net_io || "—",
|
||
"Суммарный сетевой I/O (вход+выход); полоса относительно максимума среди узлов кластера",
|
||
);
|
||
addBar(
|
||
"Диск",
|
||
rel.blkPct,
|
||
n.block_io || "—",
|
||
"Суммарный блочный I/O; полоса относительно максимума среди узлов кластера",
|
||
);
|
||
|
||
card.appendChild(bars);
|
||
return card;
|
||
}
|
||
|
||
/**
|
||
* Карточки узлов в контейнер (страница кластера), без ``<details>``.
|
||
* @param {HTMLElement | null} parent
|
||
* @param {object | null | undefined} cr поле ``cluster_resources`` из overview
|
||
*/
|
||
function appendClusterNodeCardsRow(parent, cr) {
|
||
if (!parent) return;
|
||
parent.replaceChildren();
|
||
if (!cr || !(cr.nodes && cr.nodes.length)) {
|
||
const p = document.createElement("p");
|
||
p.className = "muted cluster-nodes-resources__empty";
|
||
p.textContent = cr && cr.note ? cr.note : "Нет данных: узлы остановлены или метрики недоступны.";
|
||
parent.appendChild(p);
|
||
return;
|
||
}
|
||
const nodes = cr.nodes;
|
||
const netBytes = nodes.map(function (node) {
|
||
return parseIoPairSumBytes(node.net_io) || 0;
|
||
});
|
||
const blkBytes = nodes.map(function (node) {
|
||
return parseIoPairSumBytes(node.block_io) || 0;
|
||
});
|
||
const maxNet = Math.max(1, Math.max.apply(null, netBytes));
|
||
const maxBlk = Math.max(1, Math.max.apply(null, blkBytes));
|
||
nodes.forEach(function (n, i) {
|
||
parent.appendChild(
|
||
buildClusterResourceNodeCard(n, {
|
||
netPct: (100 * netBytes[i]) / maxNet,
|
||
blkPct: (100 * blkBytes[i]) / maxBlk,
|
||
}),
|
||
);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Карточка мета справа: kind, kubeconfig, состояние, версия образа, workers (сетка плиток).
|
||
* @param {HTMLElement | null} el
|
||
* @param {object} d ответ ``GET /clusters/{name}/overview``
|
||
*/
|
||
function paintClusterDetailMeta(el, d) {
|
||
if (!el) return;
|
||
el.replaceChildren();
|
||
const card = document.createElement("div");
|
||
card.className = "cluster-detail-meta-card";
|
||
const grid = document.createElement("div");
|
||
grid.className = "cluster-detail-meta-grid";
|
||
|
||
/**
|
||
* Одна плитка: подпись сверху, значение снизу.
|
||
* @param {string} label
|
||
* @param {HTMLElement} valueEl
|
||
* @param {boolean} [fullRow] на всю ширину сетки (последняя плитка при нечётном числе)
|
||
*/
|
||
function addTile(label, valueEl, fullRow) {
|
||
const tile = document.createElement("div");
|
||
tile.className = "cluster-meta-tile" + (fullRow ? " cluster-meta-tile--wide" : "");
|
||
const lab = document.createElement("span");
|
||
lab.className = "cluster-meta-tile__label muted";
|
||
lab.textContent = label;
|
||
const val = document.createElement("div");
|
||
val.className = "cluster-meta-tile__value";
|
||
val.appendChild(valueEl);
|
||
tile.appendChild(lab);
|
||
tile.appendChild(val);
|
||
grid.appendChild(tile);
|
||
}
|
||
|
||
function badgeYesNo(yes) {
|
||
const s = document.createElement("span");
|
||
s.className = yes ? "badge badge-ok cluster-meta-badge" : "badge cluster-meta-badge";
|
||
s.textContent = yes ? "да" : "нет";
|
||
return s;
|
||
}
|
||
|
||
addTile("kind", badgeYesNo(!!d.registered_in_kind));
|
||
addTile("kubeconfig", badgeYesNo(!!d.has_local_kubeconfig));
|
||
|
||
const stateBadge = document.createElement("span");
|
||
stateBadge.className = "badge cluster-meta-badge";
|
||
if (d.registered_in_kind) {
|
||
if (d.kind_nodes_running) {
|
||
stateBadge.classList.add("badge-ok");
|
||
stateBadge.textContent = "Запущен";
|
||
stateBadge.title = "Контейнеры узлов запущены";
|
||
} else {
|
||
stateBadge.classList.add("badge-stopped");
|
||
stateBadge.textContent = "Остановлен";
|
||
stateBadge.title = "Зарегистрирован в kind, узлы остановлены";
|
||
}
|
||
} else {
|
||
stateBadge.classList.add("cluster-meta-badge--muted");
|
||
stateBadge.textContent = "—";
|
||
stateBadge.title = "Нет в kind — только локальный каталог";
|
||
}
|
||
addTile("Состояние", stateBadge);
|
||
|
||
const ver =
|
||
(d.meta && (d.meta.kubernetes_version_tag || d.meta.node_image)) || "—";
|
||
const codeVer = document.createElement("code");
|
||
codeVer.className = "cluster-meta-version-tag";
|
||
codeVer.textContent = String(ver);
|
||
addTile("Версия образа", codeVer);
|
||
|
||
const workersEl = document.createElement("span");
|
||
workersEl.className = "cluster-meta-workers";
|
||
workersEl.textContent =
|
||
d.meta && d.meta.worker_nodes != null ? String(d.meta.worker_nodes) : "—";
|
||
addTile("Workers", workersEl, false);
|
||
|
||
card.appendChild(grid);
|
||
el.appendChild(card);
|
||
}
|
||
|
||
/**
|
||
* @param {HTMLElement} root
|
||
* @param {string} text
|
||
*/
|
||
function clusterK8sShowMessage(root, text) {
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
const p = document.createElement("p");
|
||
p.className = "cluster-k8s-msg muted";
|
||
p.textContent = text;
|
||
root.appendChild(p);
|
||
}
|
||
|
||
/**
|
||
* Ячейка: текст (через textContent) или готовый DOM-элемент (кнопка).
|
||
* @param {HTMLElement} root
|
||
* @param {string[]} headers
|
||
* @param {Array<Array<string | HTMLElement>>} rows
|
||
*/
|
||
function clusterK8sBuildTable(root, headers, rows) {
|
||
if (!root) return;
|
||
root.replaceChildren();
|
||
if (!rows.length) {
|
||
clusterK8sShowMessage(root, "Нет объектов.");
|
||
return;
|
||
}
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "cluster-k8s-table-scroll";
|
||
const table = document.createElement("table");
|
||
table.className = "cluster-k8s-table";
|
||
const thead = document.createElement("thead");
|
||
const trh = document.createElement("tr");
|
||
headers.forEach(function (h) {
|
||
const th = document.createElement("th");
|
||
th.textContent = h;
|
||
trh.appendChild(th);
|
||
});
|
||
thead.appendChild(trh);
|
||
table.appendChild(thead);
|
||
const tbody = document.createElement("tbody");
|
||
rows.forEach(function (cells) {
|
||
const tr = document.createElement("tr");
|
||
cells.forEach(function (cell) {
|
||
const td = document.createElement("td");
|
||
if (cell != null && typeof cell === "object" && cell.nodeType === 1) {
|
||
td.appendChild(cell);
|
||
} else {
|
||
td.textContent = cell == null ? "" : String(cell);
|
||
}
|
||
tr.appendChild(td);
|
||
});
|
||
tbody.appendChild(tr);
|
||
});
|
||
table.appendChild(tbody);
|
||
wrap.appendChild(table);
|
||
root.appendChild(wrap);
|
||
}
|
||
|
||
/**
|
||
* @param {object | null | undefined} block поле k8s_* из overview
|
||
* @param {string | null} kubeErr
|
||
* @param {HTMLElement | null} root
|
||
* @param {function(HTMLElement, object[]): void} renderRows
|
||
*/
|
||
function clusterK8sRenderBlock(block, kubeErr, root, renderRows) {
|
||
if (!root) return;
|
||
if (kubeErr) {
|
||
clusterK8sShowMessage(root, kubeErr);
|
||
return;
|
||
}
|
||
if (!block || !block.ok) {
|
||
clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны.");
|
||
return;
|
||
}
|
||
const items = block.items || [];
|
||
renderRows(root, items);
|
||
}
|
||
|
||
function k8sNodeReady(item) {
|
||
const cond = (item.status && item.status.conditions) || [];
|
||
for (var i = 0; i < cond.length; i++) {
|
||
if (cond[i].type === "Ready") return cond[i].status === "True" ? "True" : "False";
|
||
}
|
||
return "—";
|
||
}
|
||
|
||
function k8sNodeRoles(item) {
|
||
const labels = (item.metadata && item.metadata.labels) || {};
|
||
if (labels["node-role.kubernetes.io/control-plane"] || labels["node-role.kubernetes.io/master"])
|
||
return "control-plane";
|
||
return "worker";
|
||
}
|
||
|
||
function k8sNodeInternalIP(item) {
|
||
const addrs = (item.status && item.status.addresses) || [];
|
||
for (var i = 0; i < addrs.length; i++) {
|
||
if (addrs[i].type === "InternalIP") return addrs[i].address || "—";
|
||
}
|
||
return "—";
|
||
}
|
||
|
||
function renderK8sNodesTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const name = (it.metadata && it.metadata.name) || "—";
|
||
const ver = (it.status && it.status.nodeInfo && it.status.nodeInfo.kubeletVersion) || "—";
|
||
return [name, k8sNodeReady(it), k8sNodeRoles(it), String(ver), k8sNodeInternalIP(it)];
|
||
});
|
||
clusterK8sBuildTable(root, ["Имя", "Ready", "Роль", "Версия", "Internal IP"], rows);
|
||
}
|
||
|
||
function k8sPodReadySummary(item) {
|
||
const cst = (item.status && item.status.containerStatuses) || [];
|
||
if (!cst.length) return "—";
|
||
var ready = 0;
|
||
cst.forEach(function (c) {
|
||
if (c.ready) ready++;
|
||
});
|
||
return String(ready) + "/" + String(cst.length);
|
||
}
|
||
|
||
/**
|
||
* Рестарт пода: DELETE на API; контроллер пересоздаёт Pod.
|
||
* @param {string} clusterName
|
||
* @param {string} namespace
|
||
* @param {string} podName
|
||
*/
|
||
async function restartClusterPod(clusterName, namespace, podName) {
|
||
const ok = await openConfirmModal({
|
||
title: "Перезапустить под?",
|
||
message:
|
||
"Namespace «" +
|
||
namespace +
|
||
"», под «" +
|
||
podName +
|
||
"»: под будет удалён; при наличии контроллера (Deployment и т.д.) будет создан новый экземпляр.",
|
||
confirmLabel: "Рестарт",
|
||
danger: false,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api("/clusters/" + encodeURIComponent(clusterName) + "/pods/restart", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ namespace: namespace, pod: podName }),
|
||
});
|
||
showToast("Под отправлен на пересоздание", false);
|
||
await loadClusterOverview(clusterName);
|
||
} catch (e) {
|
||
showToast(e.message || "Ошибка рестарта", true);
|
||
}
|
||
}
|
||
|
||
function renderK8sPodsTable(root, items, clusterName) {
|
||
const tip =
|
||
"Перезапуск пода: удаление; при наличии контроллера (Deployment и т.д.) будет создан новый Pod.";
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const ns = meta.namespace || "—";
|
||
const name = meta.name || "—";
|
||
const phase = (it.status && it.status.phase) || "—";
|
||
const node = (it.spec && it.spec.nodeName) || "—";
|
||
const host = document.createElement("span");
|
||
host.className = "icon-tooltip-host";
|
||
host.setAttribute("data-tooltip", tip);
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "icon-btn icon-btn--secondary btn-k8s-restart--solo";
|
||
btn.innerHTML = ICONS.restart;
|
||
btn.setAttribute("aria-label", tip);
|
||
btn.addEventListener("click", function () {
|
||
restartClusterPod(clusterName, ns, name);
|
||
});
|
||
host.appendChild(btn);
|
||
return [ns, name, k8sPodReadySummary(it), String(phase), String(node), host];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Под", "Ready", "Фаза", "Узел", ""], rows);
|
||
bindActionTooltipHosts(root);
|
||
}
|
||
|
||
function renderK8sNamespacesTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const st = it.status || {};
|
||
return [meta.name || "—", String(st.phase || "—")];
|
||
});
|
||
clusterK8sBuildTable(root, ["Имя", "Фаза"], rows);
|
||
}
|
||
|
||
function renderK8sReplicaSetsTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const st = it.status || {};
|
||
const spec = it.spec || {};
|
||
const want = spec.replicas != null ? spec.replicas : "—";
|
||
const cur = st.replicas != null ? st.replicas : 0;
|
||
const ready = st.readyReplicas != null ? st.readyReplicas : 0;
|
||
return [
|
||
meta.namespace || "—",
|
||
meta.name || "—",
|
||
String(ready) + " / " + String(cur) + " / " + String(want),
|
||
];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready / Текущие / Задано"], rows);
|
||
}
|
||
|
||
function renderK8sJobsTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const st = it.status || {};
|
||
const spec = it.spec || {};
|
||
const want = spec.completions != null ? spec.completions : "—";
|
||
const succ = st.succeeded != null ? st.succeeded : 0;
|
||
const act = st.active != null ? st.active : 0;
|
||
const fail = st.failed != null ? st.failed : 0;
|
||
return [
|
||
meta.namespace || "—",
|
||
meta.name || "—",
|
||
String(succ) + " / " + String(want),
|
||
"активн.:" + act + ", неудач:" + fail,
|
||
];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Успешно / всего", "Активные / неудач"], rows);
|
||
}
|
||
|
||
function renderK8sCronJobsTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const spec = it.spec || {};
|
||
const susp = spec.suspend ? "да" : "нет";
|
||
return [meta.namespace || "—", meta.name || "—", String(spec.schedule || "—"), susp];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Schedule", "Suspend"], rows);
|
||
}
|
||
|
||
function renderK8sPvcsTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const st = it.status || {};
|
||
const spec = it.spec || {};
|
||
const req = spec.resources && spec.resources.requests && spec.resources.requests.storage;
|
||
const cap = st.capacity && st.capacity.storage;
|
||
const size = cap || req || "—";
|
||
return [meta.namespace || "—", meta.name || "—", String(st.phase || "—"), String(size)];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Фаза", "Размер"], rows);
|
||
}
|
||
|
||
function renderK8sDeploymentsTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const st = it.status || {};
|
||
const spec = it.spec || {};
|
||
const want = spec.replicas != null ? spec.replicas : "—";
|
||
const ready = st.readyReplicas != null ? st.readyReplicas : 0;
|
||
return [
|
||
meta.namespace || "—",
|
||
meta.name || "—",
|
||
String(ready) + " / " + String(want),
|
||
String(st.updatedReplicas != null ? st.updatedReplicas : "—"),
|
||
];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready", "Up-to-date"], rows);
|
||
}
|
||
|
||
function renderK8sStsDsTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const st = it.status || {};
|
||
const spec = it.spec || {};
|
||
const want = spec.replicas != null ? spec.replicas : "—";
|
||
const ready = st.readyReplicas != null ? st.readyReplicas : 0;
|
||
return [meta.namespace || "—", meta.name || "—", String(ready) + " / " + String(want)];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready"], rows);
|
||
}
|
||
|
||
function renderK8sServicesTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const spec = it.spec || {};
|
||
const st = spec.type || "—";
|
||
const ports = (spec.ports || [])
|
||
.map(function (p) {
|
||
return (p.port || "") + (p.protocol ? "/" + p.protocol : "");
|
||
})
|
||
.join(", ");
|
||
return [meta.namespace || "—", meta.name || "—", String(st), ports || "—"];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Тип", "Порты"], rows);
|
||
}
|
||
|
||
function renderK8sIngressesTable(root, items) {
|
||
const rows = items.map(function (it) {
|
||
const meta = it.metadata || {};
|
||
const rules = (it.spec && it.spec.rules) || [];
|
||
const hosts = rules
|
||
.map(function (r) {
|
||
return r.host || "";
|
||
})
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
return [meta.namespace || "—", meta.name || "—", hosts || "—"];
|
||
});
|
||
clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows);
|
||
}
|
||
|
||
/**
|
||
* Таблицы Kubernetes из полей ``k8s_*`` ответа overview.
|
||
* @param {object} d
|
||
*/
|
||
function fillClusterK8sTablesFromOverview(d) {
|
||
const kubeErr = d.kubeconfig_error || null;
|
||
const cname = d.cluster_name || getClusterPageName() || "";
|
||
clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
|
||
clusterK8sRenderBlock(
|
||
d.k8s_namespaces,
|
||
kubeErr,
|
||
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);
|
||
});
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Кнопки действий над кластером — те же, что в таблице на главной.
|
||
* @param {HTMLElement} host
|
||
* @param {{ name: string, registered_in_kind: boolean, kind_nodes_running: boolean, has_local_kubeconfig: boolean, has_provision_log?: boolean }} c
|
||
*/
|
||
function appendClusterActionButtons(host, c) {
|
||
host.replaceChildren();
|
||
const name = c.name;
|
||
host.appendChild(
|
||
iconActionButton(
|
||
ICONS.state,
|
||
"Состояние: узлы и поды",
|
||
"",
|
||
function () {
|
||
openWorkloadsModal(name);
|
||
},
|
||
false,
|
||
),
|
||
);
|
||
host.appendChild(
|
||
iconActionButton(
|
||
ICONS.edit,
|
||
"Редактировать kind-config.yaml и meta.json",
|
||
"icon-btn--secondary",
|
||
function () {
|
||
window.location.href = "/cluster/" + encodeURIComponent(name) + "/edit";
|
||
},
|
||
false,
|
||
),
|
||
);
|
||
if (c.registered_in_kind && c.kind_nodes_running) {
|
||
host.appendChild(
|
||
iconActionButton(
|
||
ICONS.stop,
|
||
"Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется",
|
||
"icon-btn--secondary",
|
||
function () {
|
||
stopCluster(name);
|
||
},
|
||
false,
|
||
),
|
||
);
|
||
} else {
|
||
host.appendChild(
|
||
iconActionButton(
|
||
ICONS.play,
|
||
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
|
||
"",
|
||
function () {
|
||
startCluster(name);
|
||
},
|
||
false,
|
||
),
|
||
);
|
||
}
|
||
host.appendChild(
|
||
iconActionButton(
|
||
ICONS.download,
|
||
c.has_local_kubeconfig
|
||
? "Скачать kubeconfig для kubectl на хосте"
|
||
: "Kubeconfig ещё нет — появится после создания или подъёма кластера",
|
||
"icon-btn--secondary",
|
||
c.has_local_kubeconfig
|
||
? function () {
|
||
downloadKubeconfig(name);
|
||
}
|
||
: null,
|
||
!c.has_local_kubeconfig,
|
||
),
|
||
);
|
||
if (c.has_provision_log) {
|
||
host.appendChild(
|
||
iconActionButton(
|
||
ICONS.logFile,
|
||
"Журнал развёртывания: полный JSON последнего создания или старта кластера",
|
||
"icon-btn--secondary",
|
||
function () {
|
||
openProvisionLogModal(name);
|
||
},
|
||
false,
|
||
),
|
||
);
|
||
}
|
||
host.appendChild(
|
||
iconActionButton(
|
||
ICONS.trash,
|
||
"Удалить кластер (kind delete) и каталог clusters/<имя>/",
|
||
"icon-btn--danger",
|
||
function () {
|
||
deleteCluster(name);
|
||
},
|
||
false,
|
||
),
|
||
);
|
||
bindActionTooltipHosts(host);
|
||
}
|
||
|
||
/**
|
||
* Загрузка ``GET /clusters/{name}/overview`` и заполнение блоков страницы кластера.
|
||
* @param {string} name
|
||
*/
|
||
async function loadClusterOverview(name) {
|
||
const msg = document.getElementById("cluster-overview-msg");
|
||
const hint = document.getElementById("cluster-kube-global-hint");
|
||
try {
|
||
const d = await api("/clusters/" + encodeURIComponent(name) + "/overview");
|
||
if (msg) msg.textContent = "";
|
||
if (d.cluster_resources && d.cluster_resources.cluster_name) {
|
||
cachedClusterResourcesByName[d.cluster_resources.cluster_name] = d.cluster_resources;
|
||
}
|
||
paintClusterDetailMeta(document.getElementById("cluster-detail-meta"), d);
|
||
appendClusterActionButtons(document.getElementById("cluster-detail-toolbar"), {
|
||
name: d.cluster_name,
|
||
registered_in_kind: !!d.registered_in_kind,
|
||
kind_nodes_running: !!d.kind_nodes_running,
|
||
has_local_kubeconfig: !!d.has_local_kubeconfig,
|
||
has_provision_log: !!d.has_provision_log,
|
||
});
|
||
const cliRaw =
|
||
(cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
|
||
renderAggregateDonutsToRoot(
|
||
document.getElementById("cluster-page-aggregate-donuts"),
|
||
d.aggregate_resources,
|
||
d.resources_error,
|
||
cliRaw,
|
||
);
|
||
appendClusterNodeCardsRow(document.getElementById("cluster-page-node-cards"), d.cluster_resources);
|
||
const kubeErr = d.kubeconfig_error || null;
|
||
const clusterStoppedInKind = !!d.registered_in_kind && !d.kind_nodes_running;
|
||
if (hint) {
|
||
if (kubeErr) {
|
||
hint.textContent = kubeErr;
|
||
hint.classList.remove("hidden");
|
||
if (clusterStoppedInKind) {
|
||
hint.classList.add("cluster-kube-hint--info");
|
||
} else {
|
||
hint.classList.remove("cluster-kube-hint--info");
|
||
}
|
||
} else {
|
||
hint.textContent = "";
|
||
hint.classList.add("hidden");
|
||
hint.classList.remove("cluster-kube-hint--info");
|
||
}
|
||
}
|
||
if (msg && clusterStoppedInKind) {
|
||
msg.textContent =
|
||
"Кластер остановлен. Данные Kubernetes появятся после запуска узлов (кнопка «Старт» в панели).";
|
||
}
|
||
fillClusterK8sTablesFromOverview(d);
|
||
} catch (e) {
|
||
const errText = e && e.message ? e.message : String(e);
|
||
if (msg) {
|
||
msg.textContent =
|
||
"Кластер или API недоступны: " +
|
||
errText +
|
||
". Проверьте сеть, имя кластера и что узлы kind запущены.";
|
||
}
|
||
if (hint) {
|
||
hint.textContent = "";
|
||
hint.classList.add("hidden");
|
||
}
|
||
[
|
||
"cluster-k8s-nodes-root",
|
||
"cluster-k8s-namespaces-root",
|
||
"cluster-k8s-pods-root",
|
||
"cluster-k8s-deployments-root",
|
||
"cluster-k8s-statefulsets-root",
|
||
"cluster-k8s-daemonsets-root",
|
||
"cluster-k8s-replicasets-root",
|
||
"cluster-k8s-jobs-root",
|
||
"cluster-k8s-cronjobs-root",
|
||
"cluster-k8s-services-root",
|
||
"cluster-k8s-ingresses-root",
|
||
"cluster-k8s-pvcs-root",
|
||
].forEach(function (id) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
clusterK8sShowMessage(
|
||
el,
|
||
"Данные Kubernetes недоступны (ошибка загрузки сводки). " + errText,
|
||
);
|
||
}
|
||
});
|
||
} finally {
|
||
hideClusterPageLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
/** После старт/стоп/создания — обновить страницу кластера, если открыта та же запись. */
|
||
async function refreshClusterPageIfNeeded(affectedClusterName) {
|
||
if (!isClusterPage() || !affectedClusterName) return;
|
||
const n = getClusterPageName();
|
||
if (n && n === affectedClusterName) {
|
||
await loadClusterOverview(n);
|
||
}
|
||
}
|
||
|
||
async function loadStats() {
|
||
const grid = document.getElementById("stats-grid");
|
||
if (!grid) 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);
|
||
});
|
||
cachedClusterResourcesByName = {};
|
||
renderAggregateDonuts(
|
||
s.aggregate_cluster_resources,
|
||
s.cluster_resources_error,
|
||
s.container_cli || "docker",
|
||
);
|
||
} catch (e) {
|
||
coverStatsCardWithError("Статистика", e.message || String(e));
|
||
grid.replaceChildren();
|
||
cachedClusterResourcesByName = {};
|
||
const donuts = document.getElementById("aggregate-donuts");
|
||
if (donuts) {
|
||
donuts.replaceChildren();
|
||
const ph = document.createElement("p");
|
||
ph.className = "muted aggregate-donuts-placeholder";
|
||
ph.textContent = "Сводка по узлам недоступна (ошибка загрузки статистики).";
|
||
donuts.appendChild(ph);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadVersions() {
|
||
const sel = document.getElementById("version-select");
|
||
const verInput = document.getElementById("kubernetes_version");
|
||
if (!sel || !verInput) return;
|
||
cachedKindestTags = null;
|
||
cachedKindestVersionsLoadError = false;
|
||
cachedKindestTagsSkipped = false;
|
||
sel.innerHTML = "<option value=\"\">— загрузка —</option>";
|
||
try {
|
||
const data = await api("/versions");
|
||
cachedKindestTagsSkipped = !!data.skipped;
|
||
sel.innerHTML = "";
|
||
if (!data.tags || !data.tags.length) {
|
||
cachedKindestTags = [];
|
||
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
|
||
return;
|
||
}
|
||
cachedKindestTags = data.tags.slice();
|
||
const opt0 = document.createElement("option");
|
||
opt0.value = "";
|
||
opt0.textContent = "— выберите тег —";
|
||
sel.appendChild(opt0);
|
||
/* Весь список с бэкенда: первым идёт latest, далее семверы от новых к старым (раньше slice(0,100) отрезал 1.19 и др.) */
|
||
data.tags.forEach(function (t) {
|
||
const o = document.createElement("option");
|
||
o.value = t;
|
||
o.textContent =
|
||
t === "latest" ? "latest (самый новый образ kindest/node)" : t;
|
||
sel.appendChild(o);
|
||
});
|
||
sel.onchange = function () {
|
||
if (!sel.value) return;
|
||
verInput.value = sel.value === "latest" ? "latest" : sel.value.replace(/^v/, "");
|
||
};
|
||
} catch (e) {
|
||
cachedKindestVersionsLoadError = true;
|
||
cachedKindestTags = null;
|
||
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Имя кластера: DNS-подмножество, как на бэкенде (a-z0-9-, не длиннее 63).
|
||
* @param {string} name
|
||
*/
|
||
function isValidClusterName(name) {
|
||
const s = String(name || "").trim();
|
||
if (!s || s.length > 63) return false;
|
||
return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(s);
|
||
}
|
||
|
||
/**
|
||
* Число worker-нод 0…20; иначе null.
|
||
* @param {unknown} raw
|
||
* @returns {number | null}
|
||
*/
|
||
function parseWorkersBounded(raw) {
|
||
const n = parseInt(String(raw === undefined || raw === null ? "" : raw), 10);
|
||
if (Number.isNaN(n) || n < 0 || n > 20) return null;
|
||
return n;
|
||
}
|
||
|
||
/**
|
||
* Нормализация тега kindest/node для сравнения с каталогом (latest или vX.Y.Z).
|
||
* @param {string} raw
|
||
*/
|
||
function normalizeKindestTagForLookup(raw) {
|
||
let s = String(raw || "").trim().toLowerCase();
|
||
if (!s) return "";
|
||
if (s === "latest" || s === "vlatest") return "latest";
|
||
s = s.replace(/^v/, "");
|
||
return "v" + s;
|
||
}
|
||
|
||
/**
|
||
* Проверка поля версии перед POST /clusters: каталог доступен и тег в списке.
|
||
* @param {string} kubernetesVersionRaw
|
||
* @returns {{ ok: true } | { ok: false, modalTitle: string, modalMessage: string }}
|
||
*/
|
||
function validateCreateFormKubernetesVersion(kubernetesVersionRaw) {
|
||
const normalized = normalizeKindestTagForLookup(kubernetesVersionRaw);
|
||
if (!normalized) {
|
||
return {
|
||
ok: false,
|
||
modalTitle: "Нет версии Kubernetes",
|
||
modalMessage:
|
||
"Заполните поле «Версия Kubernetes / тег node» (например 1.29.4, v1.29.4 или latest) или выберите значение в списке «Тег образа».",
|
||
};
|
||
}
|
||
|
||
if (cachedKindestVersionsLoadError) {
|
||
return {
|
||
ok: false,
|
||
modalTitle: "Каталог тегов недоступен",
|
||
modalMessage:
|
||
"Не удалось загрузить список тегов kindest/node с сервера. Проверьте соединение и обновите страницу. Пока каталог не загружен, нельзя убедиться, что образ существует.",
|
||
};
|
||
}
|
||
|
||
if (cachedKindestTags === null) {
|
||
return {
|
||
ok: false,
|
||
modalTitle: "Список тегов загружается",
|
||
modalMessage:
|
||
"Подождите, пока в поле «Тег образа» завершится загрузка (не должно оставаться «— загрузка —»), и повторите отправку формы.",
|
||
};
|
||
}
|
||
|
||
if (!cachedKindestTags.length) {
|
||
const skipHint = cachedKindestTagsSkipped
|
||
? " На сервере отключена выдача списка (переменная KIND_K8S_SKIP_VERSION_LIST)."
|
||
: "";
|
||
return {
|
||
ok: false,
|
||
modalTitle: "Каталог тегов недоступен",
|
||
modalMessage:
|
||
"Приложение не получило ни одного тега kindest/node для проверки." +
|
||
skipHint +
|
||
" Создание кластера с непроверенным тегом отключено. Включите загрузку каталога или обратитесь к администратору.",
|
||
};
|
||
}
|
||
|
||
const canonSet = new Set(cachedKindestTags.map(normalizeKindestTagForLookup));
|
||
if (canonSet.has(normalized)) {
|
||
return { ok: true };
|
||
}
|
||
|
||
return {
|
||
ok: false,
|
||
modalTitle: "Тег не найден в каталоге",
|
||
modalMessage:
|
||
"Значение «" +
|
||
String(kubernetesVersionRaw).trim() +
|
||
"» отсутствует в списке доступных тегов kindest/node в приложении. Выберите тег из списка «Тег образа» или введите поддерживаемый семвер (см. подпись поля и Docker Hub).",
|
||
};
|
||
}
|
||
|
||
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");
|
||
if (!tbody) return;
|
||
try {
|
||
const rows = await api("/clusters");
|
||
const frag = document.createDocumentFragment();
|
||
rows.forEach(function (c) {
|
||
const tr = document.createElement("tr");
|
||
const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—";
|
||
const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
|
||
const nameEsc = escapeHtml(c.name);
|
||
var stateCell = "";
|
||
if (c.registered_in_kind) {
|
||
if (c.kind_nodes_running) {
|
||
stateCell =
|
||
"<span class=\"badge badge-ok\" title=\"Контейнеры узлов запущены\">Запущен</span>";
|
||
} else {
|
||
stateCell =
|
||
"<span class=\"badge badge-stopped\" title=\"Зарегистрирован в kind, узлы остановлены\">Остановлен</span>";
|
||
}
|
||
} else {
|
||
stateCell =
|
||
"<span class=\"muted\" title=\"Нет в kind — только локальный каталог или кластер удалён из kind\">—</span>";
|
||
}
|
||
tr.innerHTML =
|
||
"<td class=\"col-cluster-name\"><a class=\"cluster-name-link\" href=\"/cluster/" +
|
||
encodeURIComponent(c.name) +
|
||
"\" title=\"Страница кластера: ресурсы и kubectl\"><code class=\"cluster-name\">" +
|
||
nameEsc +
|
||
"</code></a></td>" +
|
||
"<td class=\"col-kind\">" +
|
||
(c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
||
"</td>" +
|
||
"<td class=\"col-kubeconfig\">" +
|
||
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
||
"</td>" +
|
||
"<td class=\"col-version\">" +
|
||
escapeHtml(String(ver)) +
|
||
"</td>" +
|
||
"<td class=\"col-workers\">" +
|
||
escapeHtml(String(wn)) +
|
||
"</td>" +
|
||
"<td class=\"col-state\">" +
|
||
stateCell +
|
||
"</td>" +
|
||
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
|
||
const td = tr.querySelector(".actions-toolbar");
|
||
appendClusterActionButtons(td, {
|
||
name: c.name,
|
||
registered_in_kind: !!c.registered_in_kind,
|
||
kind_nodes_running: !!c.kind_nodes_running,
|
||
has_local_kubeconfig: !!c.has_local_kubeconfig,
|
||
has_provision_log: !!c.has_provision_log,
|
||
});
|
||
frag.appendChild(tr);
|
||
});
|
||
tbody.replaceChildren(frag);
|
||
bindActionTooltipHosts(document.getElementById("tbl-clusters"));
|
||
if (msg) {
|
||
msg.textContent = rows.length ? "" : "Кластеров пока нет.";
|
||
}
|
||
} catch (e) {
|
||
if (msg) msg.textContent = "Ошибка списка: " + e.message;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена).
|
||
*/
|
||
async function clearCompletedJobs() {
|
||
const ok = await openConfirmModal({
|
||
title: "Очистить завершённые задания?",
|
||
message:
|
||
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
|
||
confirmLabel: "Очистить",
|
||
danger: false,
|
||
});
|
||
if (!ok) return;
|
||
const msg = document.getElementById("jobs-msg");
|
||
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();
|
||
} catch (e) {
|
||
if (msg) msg.textContent = "Ошибка: " + e.message;
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
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 frag = document.createDocumentFragment();
|
||
rows.forEach(function (j) {
|
||
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 + "%)" : "");
|
||
}
|
||
const iso = j.created_at_utc || "";
|
||
const timeLabel = formatJobTimeUtc(iso);
|
||
tr.innerHTML =
|
||
"<td><time datetime=\"" +
|
||
escapeHtml(iso) +
|
||
"\" title=\"" +
|
||
escapeHtml(iso ? "UTC: " + iso : "") +
|
||
"\">" +
|
||
escapeHtml(timeLabel) +
|
||
"</time></td>" +
|
||
"<td>" +
|
||
escapeHtml(j.cluster_name || "—") +
|
||
"</td>" +
|
||
"<td><span class=\"" +
|
||
jobBadgeClass(j.status) +
|
||
"\">" +
|
||
st +
|
||
"</span></td>" +
|
||
"<td class=\"jobs-msg-cell\">" +
|
||
escapeHtml(cellMsg) +
|
||
"</td>";
|
||
frag.appendChild(tr);
|
||
});
|
||
tbody.replaceChildren(frag);
|
||
if (msg) {
|
||
msg.textContent = rows.length
|
||
? ""
|
||
: "Заданий ещё не было (или контейнер перезапускали).";
|
||
}
|
||
} catch (e) {
|
||
if (msg) msg.textContent = "Задания: " + e.message;
|
||
}
|
||
}
|
||
|
||
async function openWorkloadsModal(name) {
|
||
const overlay = document.getElementById("modal-overlay");
|
||
const sub = document.getElementById("modal-sub");
|
||
const nodes = document.getElementById("modal-nodes");
|
||
const pods = document.getElementById("modal-pods");
|
||
const spin = document.getElementById("modal-spinner");
|
||
if (!overlay) return;
|
||
hideActionTooltip();
|
||
document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
|
||
sub.textContent = "";
|
||
nodes.textContent = "";
|
||
pods.textContent = "";
|
||
if (spin) spin.classList.remove("hidden");
|
||
overlay.classList.remove("hidden");
|
||
document.body.classList.add("modal-open");
|
||
try {
|
||
const w = await api("/clusters/" + encodeURIComponent(name) + "/workloads");
|
||
if (w.error) {
|
||
sub.textContent = w.error;
|
||
return;
|
||
}
|
||
sub.textContent = "Узлы: код " + w.nodes_rc + " · Поды: код " + w.pods_rc;
|
||
nodes.textContent = w.nodes_output || "(пусто)";
|
||
pods.textContent = w.pods_output || "(пусто)";
|
||
} catch (e) {
|
||
sub.textContent = "Ошибка: " + e.message;
|
||
} finally {
|
||
if (spin) spin.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
/** Колбэк ожидающего Promise от openConfirmModal (одно окно за раз). */
|
||
var confirmModalResolver = null;
|
||
|
||
function isConfirmModalOpen() {
|
||
const el = document.getElementById("confirm-modal-overlay");
|
||
return !!(el && !el.classList.contains("hidden"));
|
||
}
|
||
|
||
/**
|
||
* Закрыть модалку подтверждения и вернуть результат в Promise.
|
||
* @param {boolean} confirmed
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Показать модальное подтверждение (вместо window.confirm).
|
||
* При opts.alert — одна кнопка «Понятно», клик по фону и Escape закрывают как подтверждение.
|
||
*
|
||
* @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;
|
||
}
|
||
/* Снять предыдущее ожидание без логики «alert = всегда true» (иначе Promise подтверждения ломается). */
|
||
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 closeModal() {
|
||
hideActionTooltip();
|
||
const overlay = document.getElementById("modal-overlay");
|
||
if (overlay) overlay.classList.add("hidden");
|
||
document.body.classList.remove("modal-open");
|
||
}
|
||
|
||
async function stopCluster(name) {
|
||
const ok = await openConfirmModal({
|
||
title: "Остановить узлы кластера?",
|
||
message:
|
||
"Кластер «" +
|
||
name +
|
||
"»: узлы будут остановлены. Сведения о кластере сохранятся — позже можно снова нажать «Старт».",
|
||
confirmLabel: "Остановить",
|
||
danger: false,
|
||
});
|
||
if (!ok) return;
|
||
const url = API + "/clusters/" + encodeURIComponent(name) + "/stop";
|
||
try {
|
||
const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
|
||
const text = await r.text();
|
||
var data = {};
|
||
try {
|
||
data = text ? JSON.parse(text) : {};
|
||
} catch (e) {
|
||
data = {};
|
||
}
|
||
if (r.status === 202 && data.job_id) {
|
||
setProgressHint(name, "stop");
|
||
pollJob(data.job_id, {
|
||
lockCreateForm: false,
|
||
lifecycleOverlayLabel: "Останавливаем узлы кластера «" + name + "»…",
|
||
});
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
showToast(formatApiError(data, text || r.statusText), true);
|
||
return;
|
||
}
|
||
showToast(String(data.summary || "Готово"), false);
|
||
await loadClusters();
|
||
await loadStats();
|
||
await loadHealth();
|
||
await refreshClusterPageIfNeeded(name);
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Старт: либо docker start узлов (кластер уже в kind), либо фоновый kind create по kind-config.yaml.
|
||
*/
|
||
async function startCluster(name) {
|
||
const url = API + "/clusters/" + encodeURIComponent(name) + "/start";
|
||
const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
|
||
const text = await r.text();
|
||
var data = {};
|
||
try {
|
||
data = text ? JSON.parse(text) : {};
|
||
} catch (e) {
|
||
data = {};
|
||
}
|
||
if (r.status === 202 && data.job_id) {
|
||
var sm = data.mode || "kind_config";
|
||
if (sm === "containers") setProgressHint(name, "start_containers");
|
||
else if (sm === "kind_config_reapply") setProgressHint(name, "start_reapply");
|
||
else setProgressHint(name, "start_config");
|
||
var startLab =
|
||
sm === "containers"
|
||
? "Запуск узлов кластера «" + name + "»…"
|
||
: sm === "kind_config_reapply"
|
||
? "Пересоздание кластера «" + name + "» в kind…"
|
||
: "Подъём кластера «" + name + "» по конфигурации…";
|
||
pollJob(data.job_id, { lockCreateForm: false, lifecycleOverlayLabel: startLab });
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
showToast(formatApiError(data, text || r.statusText), true);
|
||
return;
|
||
}
|
||
showToast(String(data.summary || "Готово"), false);
|
||
await loadClusters();
|
||
await loadStats();
|
||
await loadHealth();
|
||
await refreshClusterPageIfNeeded(name);
|
||
}
|
||
|
||
async function deleteCluster(name) {
|
||
const ok = await openConfirmModal({
|
||
title: "Удалить кластер?",
|
||
message:
|
||
"Кластер «" +
|
||
name +
|
||
"»: будут выполнены kind delete и удаление каталога clusters/" +
|
||
name +
|
||
"/ на томе данных. Действие необратимо.",
|
||
confirmLabel: "Удалить",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
const msg = document.getElementById("list-msg");
|
||
if (msg) msg.textContent = "Удаление…";
|
||
try {
|
||
const res = await api("/clusters/" + encodeURIComponent(name), { method: "DELETE" });
|
||
if (msg) msg.textContent = res.summary || "Готово.";
|
||
showToast("Кластер «" + name + "» удалён", false);
|
||
if (isClusterPage() && getClusterPageName() === name) {
|
||
window.location.href = "/";
|
||
return;
|
||
}
|
||
await loadClusters();
|
||
await loadStats();
|
||
await loadJobs();
|
||
} catch (e) {
|
||
if (msg) msg.textContent = "Ошибка удаления: " + e.message;
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function setCreateFormDisabled(disabled) {
|
||
const form = document.getElementById("form-create");
|
||
if (!form) return;
|
||
const btn = form.querySelector('[type="submit"]');
|
||
if (btn) {
|
||
btn.disabled = disabled;
|
||
btn.textContent = disabled ? "Создание…" : "Создать кластер";
|
||
}
|
||
form.querySelectorAll("input, select").forEach(function (el) {
|
||
el.disabled = disabled;
|
||
});
|
||
}
|
||
|
||
function showCreateProgress(show) {
|
||
const wrap = document.getElementById("create-progress-wrap");
|
||
if (!wrap) return;
|
||
wrap.classList.toggle("hidden", !show);
|
||
wrap.setAttribute("aria-hidden", show ? "false" : "true");
|
||
if (!show) {
|
||
const logEl = document.getElementById("job-log-panel");
|
||
if (logEl) logEl.textContent = "";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Подсказка над прогресс-баром (без технических имён команд).
|
||
* @param {string | null} clusterName
|
||
* @param {"create"|"start_config"|"start_reapply"|"start_containers"|"stop"} [mode]
|
||
*/
|
||
function setProgressHint(clusterName, mode) {
|
||
const hint = document.getElementById("create-progress-hint");
|
||
if (!hint) return;
|
||
mode = mode || "create";
|
||
var named = clusterName ? "«" + clusterName + "»" : "";
|
||
if (mode === "stop") {
|
||
hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже.";
|
||
} else if (mode === "start_containers") {
|
||
hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже.";
|
||
} else if (mode === "start_reapply") {
|
||
hint.textContent =
|
||
"Пересоздаём кластер " +
|
||
named +
|
||
" в kind по обновлённому kind-config.yaml (сначала удаление записи, затем создание). Журнал ниже.";
|
||
} else if (mode === "start_config") {
|
||
hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже.";
|
||
} else {
|
||
hint.textContent =
|
||
"Создаём кластер" +
|
||
(named ? " " + named : "") +
|
||
". Сначала при необходимости скачивается образ, затем создаются узлы — всё в журнале ниже.";
|
||
}
|
||
}
|
||
|
||
function updateCreateProgressFromJob(j) {
|
||
const bar = document.getElementById("create-progress-bar");
|
||
const track = document.getElementById("create-progress-track");
|
||
const label = document.getElementById("create-progress-label");
|
||
var pct = j.progress_percent != null ? Number(j.progress_percent) : 0;
|
||
if (isNaN(pct)) pct = 0;
|
||
pct = Math.max(0, Math.min(100, pct));
|
||
var stage = j.progress_stage || (j.status === "queued" ? "В очереди…" : "…");
|
||
if (bar) bar.style.width = pct + "%";
|
||
if (track) {
|
||
track.setAttribute("aria-valuenow", String(pct));
|
||
}
|
||
if (label) label.textContent = stage;
|
||
}
|
||
|
||
function stopPollJob() {
|
||
hideClusterLifecycleJobOverlay();
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
currentPollJobId = null;
|
||
createInProgress = false;
|
||
if (pollLockCreateForm) {
|
||
setCreateFormDisabled(false);
|
||
pollLockCreateForm = false;
|
||
}
|
||
showCreateProgress(false);
|
||
}
|
||
|
||
async function requestCancelJob() {
|
||
if (!currentPollJobId) return;
|
||
try {
|
||
await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
|
||
showToast("Запрос на прерывание отправлен", false);
|
||
} catch (e) {
|
||
showToast(e.message || "Не удалось отменить", true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} jobId
|
||
* @param {{ lockCreateForm?: boolean, lifecycleOverlayLabel?: string }} [opts]
|
||
* ``lifecycleOverlayLabel`` — полноэкранный спиннер (старт/стоп с главной или страницы кластера).
|
||
*/
|
||
function pollJob(jobId, opts) {
|
||
opts = opts || {};
|
||
pollLockCreateForm = !!opts.lockCreateForm;
|
||
const details = document.getElementById("job-details");
|
||
const msg = document.getElementById("create-msg");
|
||
const logEl = document.getElementById("job-log-panel");
|
||
if (logEl) logEl.textContent = "";
|
||
if (details) {
|
||
details.classList.remove("hidden");
|
||
details.open = false;
|
||
}
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
createInProgress = true;
|
||
currentPollJobId = jobId;
|
||
if (pollLockCreateForm) {
|
||
setCreateFormDisabled(true);
|
||
}
|
||
if (opts.lifecycleOverlayLabel) {
|
||
showClusterLifecycleJobOverlay(opts.lifecycleOverlayLabel);
|
||
}
|
||
showCreateProgress(true);
|
||
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
|
||
|
||
const tick = async function () {
|
||
try {
|
||
const j = await api("/jobs/" + jobId);
|
||
const preEl = document.getElementById("job-json");
|
||
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
|
||
updateCreateProgressFromJob(j);
|
||
if (clusterLifecycleJobOverlayActive) {
|
||
const lab = document.getElementById("cluster-lifecycle-job-label");
|
||
if (lab) {
|
||
var st = j.progress_stage;
|
||
if (!st && (j.status === "queued" || j.status === "running")) st = "В очереди…";
|
||
if (st) lab.textContent = st;
|
||
}
|
||
}
|
||
if (logEl) {
|
||
if (j.progress_log && j.progress_log.length) {
|
||
logEl.removeAttribute("data-placeholder");
|
||
logEl.textContent = j.progress_log.join("\n");
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
} else if (j.status === "running" || j.status === "queued") {
|
||
logEl.setAttribute("data-placeholder", "1");
|
||
logEl.textContent =
|
||
"Журнал подгружается… Строки скачивания и создания узлов появляются по мере вывода команды.";
|
||
}
|
||
}
|
||
|
||
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
|
||
stopPollJob();
|
||
setProgressHint(null, "create");
|
||
if (msg) {
|
||
if (j.status === "success") {
|
||
if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу.";
|
||
else if (j.kind === "start_cluster_reapply")
|
||
msg.textContent = "Кластер пересоздан в kind по обновлённому конфигу.";
|
||
else if (j.kind === "stop_containers") msg.textContent = "Узлы кластера остановлены.";
|
||
else if (j.kind === "start_containers") msg.textContent = "Узлы кластера запущены.";
|
||
else msg.textContent = "Кластер создан.";
|
||
} else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
|
||
else msg.textContent = "Ошибка: " + (j.message || "");
|
||
}
|
||
if (j.status === "success") {
|
||
if (j.kind === "start_cluster") showToast("Кластер запущен", false);
|
||
else if (j.kind === "start_cluster_reapply") showToast("Кластер пересоздан по новому конфигу", false);
|
||
else if (j.kind === "stop_containers") showToast("Узлы остановлены", false);
|
||
else if (j.kind === "start_containers") showToast("Узлы запущены", false);
|
||
else showToast("Кластер создан", false);
|
||
} else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
|
||
else showToast(j.message || "Ошибка", true);
|
||
await loadClusters();
|
||
await loadStats();
|
||
await loadJobs();
|
||
await loadHealth();
|
||
await refreshClusterPageIfNeeded(j.cluster_name || "");
|
||
}
|
||
} catch (e) {
|
||
if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
|
||
}
|
||
};
|
||
tick();
|
||
pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
|
||
}
|
||
|
||
async function refreshLists() {
|
||
try {
|
||
await loadHealth();
|
||
if (isHomePage()) {
|
||
await loadStats();
|
||
await loadClusters();
|
||
}
|
||
if (isCreatePage()) {
|
||
await loadJobs();
|
||
}
|
||
if (isClusterPage()) {
|
||
const n = getClusterPageName();
|
||
if (n) await loadClusterOverview(n);
|
||
}
|
||
} catch (e) {
|
||
/* loadHealth/loadStats/loadClusters сами сообщают об ошибках в UI */
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
const form = document.getElementById("form-create");
|
||
if (form) {
|
||
form.addEventListener("submit", async function (ev) {
|
||
ev.preventDefault();
|
||
if (createInProgress) return;
|
||
const msg = document.getElementById("create-msg");
|
||
const details = document.getElementById("job-details");
|
||
if (msg) msg.textContent = "";
|
||
if (details) details.classList.add("hidden");
|
||
setProgressHint(null, "create");
|
||
const fd = new FormData(form);
|
||
const nameRaw = String(fd.get("name") || "").trim();
|
||
const k8sVer = String(fd.get("kubernetes_version") || "").trim();
|
||
const workersParsed = parseWorkersBounded(fd.get("workers"));
|
||
|
||
if (!isValidClusterName(nameRaw)) {
|
||
await openConfirmModal({
|
||
alert: true,
|
||
title: "Некорректное имя кластера",
|
||
message:
|
||
"Имя должно состоять из строчных латинских букв, цифр и дефиса (a-z, 0-9, -), начинаться и заканчиваться буквой или цифрой, длина не более 63 символов. Пример: dev, my-cluster-1.",
|
||
confirmLabel: "Понятно",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (workersParsed === null) {
|
||
await openConfirmModal({
|
||
alert: true,
|
||
title: "Некорректное число worker-нод",
|
||
message:
|
||
"Укажите целое число worker-нод от 0 до 20 включительно.",
|
||
confirmLabel: "Понятно",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const verCheck = validateCreateFormKubernetesVersion(k8sVer);
|
||
if (!verCheck.ok) {
|
||
await openConfirmModal({
|
||
alert: true,
|
||
title: verCheck.modalTitle,
|
||
message: verCheck.modalMessage,
|
||
confirmLabel: "Понятно",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const body = {
|
||
name: nameRaw,
|
||
kubernetes_version: k8sVer,
|
||
workers: workersParsed,
|
||
};
|
||
try {
|
||
const res = await api("/clusters", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (msg) msg.textContent = "Задание: " + res.job_id;
|
||
pollJob(res.job_id, { lockCreateForm: true });
|
||
} catch (e) {
|
||
if (msg) msg.textContent = "Ошибка: " + e.message;
|
||
showToast(e.message, true);
|
||
}
|
||
});
|
||
}
|
||
|
||
const btnCancel = document.getElementById("btn-cancel-create");
|
||
if (btnCancel) {
|
||
btnCancel.addEventListener("click", function () {
|
||
requestCancelJob();
|
||
});
|
||
}
|
||
|
||
const btnClearJobs = document.getElementById("btn-jobs-clear");
|
||
if (btnClearJobs) {
|
||
btnClearJobs.addEventListener("click", function () {
|
||
clearCompletedJobs();
|
||
});
|
||
}
|
||
|
||
const mClose = document.getElementById("modal-close");
|
||
if (mClose) mClose.addEventListener("click", closeModal);
|
||
|
||
const overlay = document.getElementById("modal-overlay");
|
||
if (overlay) {
|
||
overlay.addEventListener("click", function (ev) {
|
||
if (ev.target === overlay) closeModal();
|
||
});
|
||
}
|
||
|
||
document.addEventListener("keydown", function (ev) {
|
||
if (ev.key !== "Escape") return;
|
||
if (isConfirmModalOpen()) {
|
||
closeConfirmModal(confirmModalIsAlert ? true : false);
|
||
return;
|
||
}
|
||
if (isProvisionLogModalOpen()) {
|
||
closeProvisionLogModal();
|
||
return;
|
||
}
|
||
hideActionTooltip();
|
||
closeModal();
|
||
});
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
const provLogOv = document.getElementById("provision-log-modal-overlay");
|
||
const provLogClose = document.getElementById("provision-log-modal-close");
|
||
if (provLogClose) {
|
||
provLogClose.addEventListener("click", closeProvisionLogModal);
|
||
}
|
||
if (provLogOv) {
|
||
provLogOv.addEventListener("click", function (ev) {
|
||
if (ev.target === provLogOv) closeProvisionLogModal();
|
||
});
|
||
}
|
||
|
||
window.addEventListener(
|
||
"scroll",
|
||
function () {
|
||
hideActionTooltip();
|
||
},
|
||
true,
|
||
);
|
||
window.addEventListener("resize", hideActionTooltip);
|
||
|
||
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
|
||
|
||
(async function bootstrap() {
|
||
try {
|
||
await loadHealth();
|
||
if (isHomePage()) {
|
||
await loadStats();
|
||
await loadClusters();
|
||
}
|
||
if (isCreatePage()) {
|
||
await loadJobs();
|
||
}
|
||
if (isClusterPage()) {
|
||
const n = getClusterPageName();
|
||
if (n) await loadClusterOverview(n);
|
||
else hideClusterPageLoadingOverlay();
|
||
}
|
||
} catch (e) {
|
||
const errText = e && e.message ? e.message : String(e);
|
||
const lm = document.getElementById("list-msg");
|
||
if (isHomePage() && lm) {
|
||
lm.textContent = "Ошибка загрузки панели: " + errText;
|
||
}
|
||
} finally {
|
||
hideHomePageLoadingOverlay();
|
||
}
|
||
})();
|
||
if (isCreatePage()) {
|
||
loadVersions();
|
||
}
|
||
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
||
bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|