Files
KindClustersDashboard/app/static/js/dashboard.js
T
Sergey Antropoff 6f3daa33ec Веб-UI: темы, навигация API, статистика и доработки API
- Шапка: логотип Kubernetes, ссылка на главную, выпадающее меню API (Swagger/ReDoc/Health), переключатель светлой/тёмной темы (localStorage).
- Светлая тема в синей гамме; выравнивание кнопки темы в ряду с пилюлями.
- Дашборд: единая карточка ошибки health/stats, подсказка Docker/Podman, поле container_cli в GET /stats, total_workers_from_meta всегда число (0 без meta).
- Правки кластеров, job_store, compose, документация и частичные шаблоны.
2026-04-04 07:58:19 +03:00

1376 lines
52 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;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
var currentModalClusterName = null;
/** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */
var cachedClusterResourcesByName = {};
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
var cachedHealthPayload = null;
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
var cachedLocalClusterDirsCount = null;
function isHomePage() {
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
}
function isCreatePage() {
return document.body.getAttribute("data-dashboard-mode") === "create";
}
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 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>',
};
/** @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));
}
}
/**
* SVG-донаты по полю aggregate_cluster_resources (главная страница).
* @param {object | null | undefined} agg
* @param {string | null | undefined} resourcesError
* @param {string | undefined} containerCliRaw имя CLI из /stats (docker/podman) для подписи доната RAM
*/
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
const root = document.getElementById("aggregate-donuts");
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);
}
/**
* Раскрывающийся блок метрик узлов под строкой таблицы кластеров.
* @param {string} clusterName
*/
function buildClusterResourcesDetails(clusterName) {
const cr = cachedClusterResourcesByName[clusterName];
const details = document.createElement("details");
details.className = "cluster-nodes-resources";
const summ = document.createElement("summary");
summ.textContent = "Ресурсы узлов (CPU, RAM, сеть, диск)";
details.appendChild(summ);
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 : "Нет данных: узлы остановлены или метрики недоступны.";
details.appendChild(p);
return details;
}
cr.nodes.forEach(function (n) {
const row = document.createElement("div");
row.className = "stats-node-res cluster-row-node-res";
const code = document.createElement("code");
code.textContent = n.container_name;
row.appendChild(code);
const ul = document.createElement("ul");
ul.className = "stats-node-res__metrics";
function addMetric(label, val) {
if (val == null || val === "") return;
const li = document.createElement("li");
const strong = document.createElement("strong");
strong.textContent = label + ": ";
li.appendChild(strong);
li.appendChild(document.createTextNode(String(val)));
ul.appendChild(li);
}
addMetric("CPU", n.cpu_percent);
addMetric("Память", n.memory_usage);
addMetric("% RAM", n.memory_percent);
addMetric("Сеть", n.net_io);
addMetric("Диск", n.block_io);
addMetric("PID", n.pids != null ? String(n.pids) : null);
row.appendChild(ul);
details.appendChild(row);
});
return details;
}
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 = {};
(s.cluster_resources || []).forEach(function (cr) {
cachedClusterResourcesByName[cr.cluster_name] = cr;
});
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;
sel.innerHTML = "<option value=\"\">— загрузка —</option>";
try {
const data = await api("/versions");
sel.innerHTML = "";
if (!data.tags || !data.tags.length) {
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
return;
}
const opt0 = document.createElement("option");
opt0.value = "";
opt0.textContent = "— выберите тег —";
sel.appendChild(opt0);
data.tags.slice(0, 100).forEach(function (t) {
const o = document.createElement("option");
o.value = t;
o.textContent = t;
sel.appendChild(o);
});
sel.onchange = function () {
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
};
} catch (e) {
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
}
}
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><code class=\"cluster-name\">" +
nameEsc +
"</code></td>" +
"<td>" +
(c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
escapeHtml(String(ver)) +
"</td>" +
"<td>" +
escapeHtml(String(wn)) +
"</td>" +
"<td>" +
stateCell +
"</td>" +
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
const td = tr.querySelector(".actions-toolbar");
td.appendChild(
iconActionButton(
ICONS.state,
"Состояние: узлы и поды (kubectl get nodes / pods)",
"",
function () {
openWorkloadsModal(c.name);
},
false,
),
);
if (c.registered_in_kind && c.kind_nodes_running) {
td.appendChild(
iconActionButton(
ICONS.stop,
"Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется",
"icon-btn--secondary",
function () {
stopCluster(c.name);
},
false,
),
);
} else {
td.appendChild(
iconActionButton(
ICONS.play,
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
"",
function () {
startCluster(c.name);
},
false,
),
);
}
td.appendChild(
iconActionButton(
ICONS.download,
c.has_local_kubeconfig
? "Скачать kubeconfig для kubectl на хосте"
: "Kubeconfig ещё нет — появится после создания или подъёма кластера",
"icon-btn--secondary",
c.has_local_kubeconfig
? function () {
downloadKubeconfig(c.name);
}
: null,
!c.has_local_kubeconfig,
),
);
td.appendChild(
iconActionButton(
ICONS.trash,
"Удалить кластер (kind delete) и каталог clusters/<имя>/",
"icon-btn--danger",
function () {
deleteCluster(c.name);
},
false,
),
);
frag.appendChild(tr);
if (isHomePage()) {
const trExp = document.createElement("tr");
trExp.className = "cluster-resources-expand-row";
const tdExp = document.createElement("td");
tdExp.colSpan = 7;
tdExp.className = "cluster-resources-expand-cell";
tdExp.appendChild(buildClusterResourcesDetails(c.name));
trExp.appendChild(tdExp);
frag.appendChild(trExp);
}
});
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 === "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");
const modalDlWrap = document.getElementById("modal-dl-wrap");
if (!overlay) return;
hideActionTooltip();
currentModalClusterName = name;
document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
sub.textContent = "";
nodes.textContent = "";
pods.textContent = "";
if (modalDlWrap) modalDlWrap.classList.add("hidden");
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 = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc;
nodes.textContent = w.nodes_output || "(пусто)";
pods.textContent = w.pods_output || "(пусто)";
if (modalDlWrap) modalDlWrap.classList.remove("hidden");
} 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");
if (ov) ov.classList.add("hidden");
document.body.classList.remove("modal-open");
if (confirmModalResolver) {
var fn = confirmModalResolver;
confirmModalResolver = null;
fn(!!confirmed);
}
}
/**
* Показать модальное подтверждение (вместо window.confirm).
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: 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");
if (!ov || !titleEl || !msgEl || !okBtn) {
resolve(false);
return;
}
if (confirmModalResolver) {
closeConfirmModal(false);
}
confirmModalResolver = resolve;
titleEl.textContent = opts.title || "Подтвердите действие";
msgEl.textContent = opts.message || "";
okBtn.textContent = opts.confirmLabel || "Подтвердить";
okBtn.className = opts.danger ? "btn-danger" : "";
ov.classList.remove("hidden");
document.body.classList.add("modal-open");
okBtn.focus();
});
}
function closeModal() {
hideActionTooltip();
const overlay = document.getElementById("modal-overlay");
const modalDlWrap = document.getElementById("modal-dl-wrap");
if (modalDlWrap) modalDlWrap.classList.add("hidden");
currentModalClusterName = null;
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 });
return;
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
showToast(String(data.summary || "Готово"), false);
await loadClusters();
await loadStats();
await loadHealth();
} 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 setProgressHint(name, "start_config");
pollJob(data.job_id, { lockCreateForm: false });
return;
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
showToast(String(data.summary || "Готово"), false);
await loadClusters();
await loadStats();
await loadHealth();
}
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);
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_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_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() {
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 }} [opts]
*/
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);
}
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 (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 === "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 === "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();
}
} 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();
}
} 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 body = {
name: String(fd.get("name") || "").trim(),
kubernetes_version: String(fd.get("kubernetes_version") || "").trim(),
workers: parseInt(String(fd.get("workers") || "0"), 10),
};
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 modalBtnKube = document.getElementById("modal-btn-kubeconfig");
if (modalBtnKube) {
modalBtnKube.addEventListener("click", function () {
if (currentModalClusterName) downloadKubeconfig(currentModalClusterName);
});
}
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(false);
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(false);
});
}
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();
}
} catch (e) {
/* см. сообщения в блоках */
}
})();
if (isCreatePage()) {
loadVersions();
}
bindActionTooltipHosts(document.getElementById("modal-overlay"));
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();