Files
KindClustersDashboard/app/static/js/dashboard.js
T
Sergey Antropoff d55e69c756 UI: карточка установленных аддонов на странице кластера; API аддонов и правки стилей
- Отдельная section.card после сводки ресурсов узлов, выравнивание бейджа Helm с заголовком
- Расширение эндпоинтов аддонов, схемы, helm_addons, cluster_addons.js/html
- Обновление api_routes.md
2026-04-04 14:13:01 +03:00

2833 lines
110 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;
/**
* Кэш 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";
}
/** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */
function isClustersListPage() {
return document.body.getAttribute("data-dashboard-mode") === "clusters";
}
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");
}
/** Скрыть спиннер первой загрузки страницы «Кластеры». */
function hideClustersPageLoadingOverlay() {
const el = document.getElementById("clusters-page-loading-overlay");
if (!el) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("dashboard-clusters-loading");
}
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
function showClusterLifecycleJobOverlay(labelText) {
const el = document.getElementById("cluster-lifecycle-job-overlay");
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;
}
/**
* Скачать 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");
}
function isJobLogModalOpen() {
const el = document.getElementById("job-log-modal-overlay");
return !!(el && !el.classList.contains("hidden"));
}
function closeJobLogModal() {
const el = document.getElementById("job-log-modal-overlay");
if (el) el.classList.add("hidden");
}
/**
* Открыть модалку с текстовым журналом завершённого задания (GET /jobs/{id}).
* @param {string} jobId
*/
function openJobLogModal(jobId) {
const overlay = document.getElementById("job-log-modal-overlay");
const sub = document.getElementById("job-log-modal-sub");
const pre = document.getElementById("job-log-modal-body");
const titleEl = document.getElementById("job-log-modal-title");
if (!overlay || !pre) return;
pre.textContent = "Загрузка журнала…";
if (sub) sub.textContent = "Задание: " + jobId;
if (titleEl) titleEl.textContent = "Журнал задания";
overlay.classList.remove("hidden");
api("/jobs/" + encodeURIComponent(jobId))
.then(function (job) {
if (titleEl) {
titleEl.textContent =
"Журнал задания · " + (job.status || "") + (job.kind ? " · " + job.kind : "");
}
if (sub) {
var parts = ["Задание: " + jobId];
if (job.cluster_name) parts.push("Кластер: " + job.cluster_name);
if (job.message) parts.push(job.message);
sub.textContent = parts.join(" · ");
}
var lines = job.progress_log || [];
pre.textContent = lines.length ? lines.join("\n") : job.message || "(журнал пуст)";
})
.catch(function (e) {
pre.textContent = "Не удалось загрузить журнал: " + (e && e.message ? e.message : String(e));
});
}
/**
* Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке.
* @param {string} clusterName
*/
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");
const resCard = document.getElementById("home-resources-summary-card");
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");
if (resCard) resCard.classList.add("hidden");
}
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
function revealStatsCardBody() {
const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid");
const resCard = document.getElementById("home-resources-summary-card");
if (failureWrap) {
failureWrap.classList.add("hidden");
failureWrap.replaceChildren();
}
if (env) env.classList.remove("hidden");
if (metricsGrid) metricsGrid.classList.remove("hidden");
if (resCard) resCard.classList.remove("hidden");
}
/**
* Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` — первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»).
*/
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),
);
}
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);
}
/**
* Блоки K8s на странице кластера с фильтром по namespace (ключ → поле overview → id корня таблицы).
* Автор: Сергей Антропов — https://devops.org.ru
*/
var CLUSTER_K8S_NS_BLOCKS = [
{ key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" },
{ key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" },
{ key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" },
{ key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" },
{ key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" },
{ key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" },
{ key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" },
{ key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" },
{ key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" },
{ key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" },
];
/** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */
var lastClusterK8sOverview = null;
/** Однократная привязка делегирования change на фильтры namespace. */
var clusterNsFiltersBound = false;
/**
* Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview).
* @param {object|undefined|null} block
* @returns {object[]}
*/
function k8sBlockItemsArray(block) {
if (!block || typeof block !== "object") return [];
var items = block.items;
return Array.isArray(items) ? items : [];
}
/**
* Отбор объектов по выбранному namespace (пустая строка — все).
* @param {object[]|undefined} items
* @param {string} ns
* @returns {object[]}
*/
function k8sFilterItemsByNamespace(items, ns) {
if (!ns) return items || [];
return (items || []).filter(function (it) {
return ((it.metadata && it.metadata.namespace) || "") === ns;
});
}
/**
* Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта.
* Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк).
* @param {object[]|undefined} items
* @returns {object[]}
*/
function k8sSortNamespacedItems(items) {
if (!items || !items.length) return items || [];
return items.slice().sort(function (a, b) {
var nsA = (a.metadata && a.metadata.namespace) || "";
var nsB = (b.metadata && b.metadata.namespace) || "";
var c = String(nsA).localeCompare(String(nsB), "ru");
if (c !== 0) return c;
var nA = (a.metadata && a.metadata.name) || "";
var nB = (b.metadata && b.metadata.name) || "";
return String(nA).localeCompare(String(nB), "ru");
});
}
/**
* Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace``
* из таблиц ресурсов (pods/deployments/…); без дублей, сортировка по ru.
* Автор: Сергей Антропов — https://devops.org.ru
* @param {object} d ответ cluster overview
* @returns {string[]}
*/
function k8sNamespaceNamesFromOverview(d) {
var seen = Object.create(null);
var ordered = [];
function addName(raw) {
if (raw == null) return;
var s = String(raw).trim();
if (!s || seen[s]) return;
seen[s] = true;
ordered.push(s);
}
var nsBlock = d.k8s_namespaces;
if (nsBlock && nsBlock.ok) {
k8sBlockItemsArray(nsBlock).forEach(function (it) {
var meta = it && it.metadata;
addName(meta && meta.name);
});
}
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop];
if (!blk || !blk.ok) continue;
k8sBlockItemsArray(blk).forEach(function (it) {
var meta = it && it.metadata;
addName(meta && meta.namespace);
});
}
ordered.sort(function (a, b) {
return a.localeCompare(b, "ru");
});
return ordered;
}
/**
* Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-<key>.
* @param {object | null | undefined} block
* @param {string | null} kubeErr
* @param {HTMLElement | null} root
* @param {string} filterKey
* @param {function(HTMLElement, object[]): void} renderFn
*/
function clusterK8sRenderBlockNs(block, kubeErr, root, filterKey, renderFn) {
if (!root) return;
var sel = document.getElementById("cluster-k8s-filter-" + filterKey);
var ns = sel && sel.value ? sel.value : "";
if (kubeErr) {
clusterK8sShowMessage(root, kubeErr);
return;
}
if (!block || !block.ok) {
clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны.");
return;
}
var items = k8sFilterItemsByNamespace(k8sBlockItemsArray(block), ns);
items = k8sSortNamespacedItems(items);
renderFn(root, items);
}
/**
* Заполняет option во всех селектах фильтра; сохраняет прежнее value, если namespace ещё в списке.
* @param {string[]} namespaceNames
*/
function clusterK8sPopulateNsSelects(namespaceNames) {
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
var sel = document.getElementById("cluster-k8s-filter-" + row.key);
if (!sel) return;
var prev = (sel.value || "").trim();
sel.replaceChildren();
var o0 = document.createElement("option");
o0.value = "";
o0.textContent = "Все namespace";
sel.appendChild(o0);
namespaceNames.forEach(function (name) {
var o = document.createElement("option");
o.value = name;
o.textContent = name;
sel.appendChild(o);
});
if (prev && namespaceNames.indexOf(prev) >= 0) sel.value = prev;
else sel.value = "";
});
}
function k8sNodeReady(item) {
const cond = (item.status && item.status.conditions) || [];
for (var i = 0; i < cond.length; i++) {
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);
}
/**
* Фабрика функций рендера по ключу фильтра (для подов нужен clusterName).
* @param {string} cname
* @returns {Record<string, function(HTMLElement, object[]): void>}
*/
function buildClusterK8sNsFilterRenderMap(cname) {
return {
pods: function (root, items) {
renderK8sPodsTable(root, items, cname);
},
deployments: renderK8sDeploymentsTable,
statefulsets: renderK8sStsDsTable,
daemonsets: renderK8sStsDsTable,
replicasets: renderK8sReplicaSetsTable,
jobs: renderK8sJobsTable,
cronjobs: renderK8sCronJobsTable,
services: renderK8sServicesTable,
ingresses: renderK8sIngressesTable,
pvcs: renderK8sPvcsTable,
};
}
/**
* Перерисовка одной таблицы после смены select (без нового API-запроса).
* @param {string} key ключ data-cluster-k8s-ns-filter
*/
function refillClusterK8sNsFilter(key) {
if (!lastClusterK8sOverview) return;
var d = lastClusterK8sOverview;
var kubeErr = d.kubeconfig_error || null;
var cname = d.cluster_name || getClusterPageName() || "";
var renderByKey = buildClusterK8sNsFilterRenderMap(cname);
var renderFn = renderByKey[key];
if (!renderFn) return;
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
if (CLUSTER_K8S_NS_BLOCKS[i].key !== key) continue;
var row = CLUSTER_K8S_NS_BLOCKS[i];
clusterK8sRenderBlockNs(d[row.prop], kubeErr, document.getElementById(row.rootId), key, renderFn);
return;
}
}
/** Делегирование: один listener на document для всех .cluster-k8s-ns-select. */
function ensureClusterNsFilterListeners() {
if (clusterNsFiltersBound) return;
document.addEventListener("change", function (ev) {
var t = ev.target;
if (!t || !t.matches || !t.matches("select.cluster-k8s-ns-select[data-cluster-k8s-ns-filter]")) return;
var key = t.getAttribute("data-cluster-k8s-ns-filter");
if (key) refillClusterK8sNsFilter(key);
});
clusterNsFiltersBound = true;
}
/**
* Таблицы Kubernetes из полей ``k8s_*`` ответа overview.
* @param {object} d
*/
function fillClusterK8sTablesFromOverview(d) {
lastClusterK8sOverview = d;
const kubeErr = d.kubeconfig_error || null;
const cname = d.cluster_name || getClusterPageName() || "";
ensureClusterNsFilterListeners();
clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
clusterK8sRenderBlock(
d.k8s_namespaces,
kubeErr,
document.getElementById("cluster-k8s-namespaces-root"),
renderK8sNamespacesTable,
);
const renderByKey = buildClusterK8sNsFilterRenderMap(cname);
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
clusterK8sRenderBlockNs(
d[row.prop],
kubeErr,
document.getElementById(row.rootId),
row.key,
renderByKey[row.key],
);
});
/* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */
clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d));
}
/**
* Кнопки действий над кластером — те же, что в таблице на главной.
* @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);
}
/**
* Скрыть секцию «Установленные аддоны» (#cluster-detail-addons-section) и очистить сетку.
*/
function clearClusterDetailAddonsStrip() {
var strip = document.getElementById("cluster-detail-addons-strip");
var section = document.getElementById("cluster-detail-addons-section");
if (strip) strip.replaceChildren();
if (section) {
section.classList.add("hidden");
section.setAttribute("hidden", "");
section.setAttribute("aria-hidden", "true");
}
}
/**
* Мини-карточки установленных Helm-аддонов в отдельной section.card под hero.
* @param {string} clusterName
* @param {object} st ответ GET /clusters/…/addons/status
*/
function renderClusterDetailAddonsStrip(clusterName, st) {
var strip = document.getElementById("cluster-detail-addons-strip");
var section = document.getElementById("cluster-detail-addons-section");
if (!strip || !section || !st) {
clearClusterDetailAddonsStrip();
return;
}
var items = [];
if (st.ingress_nginx) {
items.push({
title: "ingress-nginx",
ver: st.ingress_nginx_chart_version,
ns: "ingress-nginx",
});
}
if (st.kube_prometheus_stack) {
items.push({
title: "kube-prometheus-stack",
ver: st.kube_prometheus_stack_chart_version,
ns: "monitoring",
});
}
if (st.metrics_server) {
items.push({
title: "metrics-server",
ver: st.metrics_server_chart_version,
ns: "kube-system",
});
}
if (st.istio_mesh_ready) {
var iv = st.istiod_chart_version || st.istio_base_chart_version;
var kv = st.kiali_server_chart_version;
var parts = [];
if (iv) parts.push("Istio " + iv);
if (kv) parts.push("Kiali " + kv);
items.push({
title: "Istio + Kiali",
verLine: parts.length ? parts.join(" · ") : null,
ns: "istio-system",
});
}
strip.replaceChildren();
if (!items.length) {
section.classList.add("hidden");
section.setAttribute("hidden", "");
section.setAttribute("aria-hidden", "true");
return;
}
section.classList.remove("hidden");
section.removeAttribute("hidden");
section.setAttribute("aria-hidden", "false");
items.forEach(function (it) {
var a = document.createElement("a");
a.className = "cluster-detail-addon-mini";
a.href = "/cluster-addons";
a.setAttribute("role", "listitem");
a.setAttribute(
"title",
"Открыть страницу аддонов Helm; выберите кластер «" + clusterName + "» для настройки.",
);
var t = document.createElement("span");
t.className = "cluster-detail-addon-mini__title";
t.textContent = it.title;
a.appendChild(t);
var v = document.createElement("span");
v.className = "cluster-detail-addon-mini__ver";
if (it.verLine) {
v.textContent = it.verLine;
} else if (it.ver) {
v.textContent = "Чарт " + it.ver;
} else {
v.textContent = "Версия чарта не определена";
}
a.appendChild(v);
var n = document.createElement("span");
n.className = "cluster-detail-addon-mini__ns";
n.textContent = "ns: " + it.ns;
a.appendChild(n);
strip.appendChild(a);
});
}
/**
* Загрузить статус аддонов и обновить карточку «Установленные аддоны» (при наличии kubeconfig на диске).
* @param {string} clusterName
* @param {boolean} hasLocalKubeconfig
*/
async function refreshClusterDetailAddonsStrip(clusterName, hasLocalKubeconfig) {
if (!clusterName || !hasLocalKubeconfig) {
clearClusterDetailAddonsStrip();
return;
}
try {
var st = await api("/clusters/" + encodeURIComponent(clusterName) + "/addons/status");
renderClusterDetailAddonsStrip(clusterName, st);
} catch (e) {
clearClusterDetailAddonsStrip();
}
}
/**
* Загрузка ``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,
});
await refreshClusterDetailAddonsStrip(d.cluster_name, !!d.has_local_kubeconfig);
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);
clearClusterDetailAddonsStrip();
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");
const donutsRoot = document.getElementById("aggregate-donuts");
const needGrid = !!grid;
const needDonuts = !!donutsRoot;
if (!needGrid && !needDonuts) return;
try {
const s = await api("/stats");
if (needGrid) {
revealStatsCardBody();
paintStatsEnvRow();
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
updateStatsEngineHint(cliForHint);
const dirVal =
s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined
? String(s.local_cluster_dirs_count)
: "—";
const rows = [
["Каталогов", dirVal],
["Кластеров в kind", s.kind_clusters_count],
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
["Аддонов", s.helm_addons_installable_count ?? 0],
];
grid.replaceChildren();
rows.forEach(function (kv) {
const tile = document.createElement("div");
tile.className = "stats-tile";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = String(kv[1]);
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = kv[0];
tile.appendChild(valEl);
tile.appendChild(labEl);
grid.appendChild(tile);
});
}
cachedClusterResourcesByName = {};
if (needDonuts) {
renderAggregateDonuts(
s.aggregate_cluster_resources,
s.cluster_resources_error,
s.container_cli || "docker",
);
}
} catch (e) {
if (needGrid) {
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).",
};
}
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;
}
}
/**
* Формат времени как на странице «Журнал» (ISO → «YYYY-MM-DD HH:MM:SS.sss Z»).
* @param {string} iso
*/
function formatJournalUtcDisplay(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
} catch (e) {
return iso;
}
}
/**
* Класс плашки статуса записи журнала (success / failed / cancelled).
* @param {string} st
*/
function journalStatusPillClass(st) {
const s = (st || "").toLowerCase();
if (s === "success") return "journal-status-pill journal-status-pill--ok";
if (s === "failed") return "journal-status-pill journal-status-pill--err";
if (s === "cancelled") return "journal-status-pill journal-status-pill--warn";
return "journal-status-pill";
}
/**
* Раскрытие строки лога — как на /journal (режим заданий по кластеру).
* Автор: Сергей Антропов — https://devops.org.ru
*/
function bindCreateJobsJournalToggle() {
const tbl = document.getElementById("tbl-jobs");
if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return;
tbl.setAttribute("data-create-jobs-log-bound", "1");
tbl.addEventListener(
"click",
function (ev) {
const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null;
if (!btn || !tbl.contains(btn) || btn.disabled) return;
const tr = btn.closest("tr.journal-data-row");
if (!tr) return;
const trDetails = tr.nextElementSibling;
if (!trDetails || !trDetails.classList.contains("journal-details-row")) return;
const pre = trDetails.querySelector("pre.journal-log-pre");
if (!pre) return;
const open = trDetails.classList.contains("hidden");
trDetails.classList.toggle("hidden", !open);
pre.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.textContent = open ? "Скрыть лог" : "Показать лог";
},
false,
);
}
/**
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
*/
async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg");
if (!tbody) return;
try {
const data = await api("/journal/recent?limit=30&offset=0");
const entries = Array.isArray(data.entries) ? data.entries : [];
const frag = document.createDocumentFragment();
entries.forEach(function (e) {
const tr = document.createElement("tr");
tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatJournalUtcDisplay(e.finished_at_utc || e.created_at_utc);
const kind = e.kind || "";
const message = (e.message || "").trim();
const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
const logText = lines.join("\n");
const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
tr.innerHTML =
"<td class=\"journal-cell-time\">" +
escapeHtml(finished) +
"</td>" +
"<td><code class=\"journal-code\">" +
escapeHtml(cluster) +
"</code></td>" +
"<td><code class=\"journal-code journal-cell-kind\">" +
escapeHtml(kind) +
"</code></td>" +
"<td><span class=\"" +
escapeHtml(journalStatusPillClass(e.status)) +
"\">" +
escapeHtml(e.status || "") +
"</span></td>" +
"<td class=\"journal-cell-msg\">" +
escapeHtml(message || "—") +
"</td>" +
"<td class=\"journal-cell-actions\">" +
"<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
escapeHtml(detailsId) +
"\">Показать лог</button>" +
"</td>";
frag.appendChild(tr);
if (logText) {
const trDetails = document.createElement("tr");
trDetails.className = "journal-details-row hidden";
trDetails.innerHTML =
"<td colspan=\"6\" class=\"journal-details-cell\">" +
"<pre id=\"" +
escapeHtml(detailsId) +
"\" class=\"mono journal-log-pre\" hidden></pre>" +
"</td>";
const pre = trDetails.querySelector("pre");
if (pre) pre.textContent = logText;
frag.appendChild(trDetails);
} else {
const btn = tr.querySelector(".btn-journal-toggle");
if (btn) {
btn.disabled = true;
btn.textContent = "Нет строк";
}
}
});
tbody.replaceChildren(frag);
if (msg) {
if (!entries.length) {
msg.textContent =
"Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами).";
} else {
const total = typeof data.total === "number" ? data.total : entries.length;
msg.textContent =
total > entries.length
? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах."
: "";
}
}
} catch (e) {
if (msg) msg.textContent = "Журнал заданий: " + e.message;
}
}
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() || isClustersListPage()) {
await loadStats();
}
if (isClustersListPage()) {
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 btnJobsRefresh = document.getElementById("btn-jobs-refresh");
if (btnJobsRefresh) {
btnJobsRefresh.addEventListener("click", function () {
loadJobs();
});
}
if (document.getElementById("tbl-jobs")) {
bindCreateJobsJournalToggle();
}
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 (isJobLogModalOpen()) {
closeJobLogModal();
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();
});
}
const jobLogOv = document.getElementById("job-log-modal-overlay");
const jobLogClose = document.getElementById("job-log-modal-close");
if (jobLogClose) {
jobLogClose.addEventListener("click", closeJobLogModal);
}
if (jobLogOv) {
jobLogOv.addEventListener("click", function (ev) {
if (ev.target === jobLogOv) closeJobLogModal();
});
}
window.addEventListener(
"scroll",
function () {
hideActionTooltip();
},
true,
);
window.addEventListener("resize", hideActionTooltip);
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
(async function bootstrap() {
try {
await loadHealth();
if (isHomePage()) {
await loadStats();
}
if (isClustersListPage()) {
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;
}
if (isClustersListPage() && lm) {
lm.textContent = "Ошибка загрузки: " + errText;
}
} finally {
hideHomePageLoadingOverlay();
hideClustersPageLoadingOverlay();
}
})();
if (isCreatePage()) {
loadVersions();
}
bindActionTooltipHosts(document.getElementById("modal-overlay"));
bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
bindActionTooltipHosts(document.getElementById("job-log-modal-overlay"));
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();