Files
KindClustersDashboard/app/static/js/dashboard.js
T
Sergey Antropoff 52538d9816 Панель: редактирование конфига кластера, reapply при старте, UI и таблица
- PUT/GET конфигурации кластера, страница редактирования и модалка после сохранения
- После смены kind-config: флаг в meta и start_cluster_reapply (kind delete + create)
- Старт/стоп: полноэкранный спиннер до завершения job; модалки и документация API
- Таблица кластеров: колонка Имя 40% при table-layout fixed; чекбоксы без width 100%
- Карточки ресурсов узлов на странице кластера: до 3 в ряд; прочие правки стилей и dashboard.js
2026-04-04 10:49:40 +03:00

2430 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Панель управления кластерами 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 0100
* @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();
}
})();