c1e867a01f
- Потоковые логи в job_store и UI; kind create через Popen с построчным выводом
- POST /clusters/{name}/start|stop; create по сохранённому kind-config.yaml
- Страница /documentation: GET /api/v1/docs/readme, marked+DOMPurify из static/vendor
- Иконки действий, плавающие подсказки, модалка подтверждения вместо confirm
- Makefile: make docker|podman rebuild; compose: монтирование README.md
- Dockerfile: COPY README.md; readme_doc: несколько путей к README
Автор: Сергей Антропов — https://devops.org.ru
895 lines
32 KiB
JavaScript
895 lines
32 KiB
JavaScript
/**
|
||
* Панель управления кластерами kind (REST /api/v1).
|
||
* Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная
|
||
* замена содержимого блоков (статистика, таблицы, плашка среды) — SPA-поведение.
|
||
*
|
||
* Автор: Сергей Антропов
|
||
* Сайт: https://devops.org.ru
|
||
*/
|
||
(function () {
|
||
"use strict";
|
||
|
||
const body = document.body;
|
||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||
|
||
/** Интервал опроса списков и среды (мс) */
|
||
var AUTO_REFRESH_MS = 3500;
|
||
/** Интервал опроса задания создания (мс) */
|
||
var JOB_POLL_MS = 1500;
|
||
|
||
/** @type {ReturnType<typeof setInterval> | null} */
|
||
var autoTimer = null;
|
||
/** @type {ReturnType<typeof setInterval> | null} */
|
||
var pollTimer = null;
|
||
var createInProgress = false;
|
||
/** @type {string | null} */
|
||
var currentPollJobId = null;
|
||
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
|
||
var currentModalClusterName = null;
|
||
|
||
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 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);
|
||
}
|
||
|
||
function setStatusBannerClass(ok, degraded) {
|
||
const el = document.getElementById("status-banner");
|
||
if (!el) return;
|
||
var cls = "hero-panel-status muted ";
|
||
if (ok) cls += "ok";
|
||
else if (degraded) cls += "degraded";
|
||
else cls += "err";
|
||
el.className = cls;
|
||
}
|
||
|
||
async function loadHealth() {
|
||
const el = document.getElementById("status-banner");
|
||
if (!el) return;
|
||
try {
|
||
const h = await api("/health");
|
||
const ok =
|
||
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
|
||
setStatusBannerClass(ok, !ok);
|
||
var lines = "<strong>Среда:</strong> ";
|
||
lines += escapeHtml(String(h.container_cli || "?"));
|
||
lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен");
|
||
lines += " · kind: " + (h.kind_in_path ? "да" : "нет");
|
||
lines += " · kubectl: " + (h.kubectl_in_path ? "да" : "нет");
|
||
if (!h.container_engine_ok && h.container_engine_detail) {
|
||
lines +=
|
||
"<br/><span class=\"muted\">" +
|
||
escapeHtml(String(h.container_engine_detail).slice(0, 400)) +
|
||
"</span>";
|
||
}
|
||
el.innerHTML = lines;
|
||
} catch (e) {
|
||
setStatusBannerClass(false, false);
|
||
el.textContent = "Не удалось запросить health: " + e.message;
|
||
}
|
||
}
|
||
|
||
async function loadStats() {
|
||
const dl = document.getElementById("stats-dl");
|
||
const errEl = document.getElementById("stats-err");
|
||
if (!dl) return;
|
||
try {
|
||
const s = await api("/stats");
|
||
errEl.classList.add("hidden");
|
||
const rows = [
|
||
["Кластеров в kind", s.kind_clusters_count],
|
||
["Локальных каталогов", s.local_cluster_dirs_count],
|
||
["Сумма workers (meta)", s.total_workers_from_meta != null ? s.total_workers_from_meta : "—"],
|
||
["Заданий в памяти", s.jobs_total],
|
||
["Заданий с ошибкой", s.jobs_recent_failed],
|
||
];
|
||
const frag = document.createDocumentFragment();
|
||
rows.forEach(function (kv) {
|
||
const dt = document.createElement("dt");
|
||
dt.textContent = kv[0];
|
||
const dd = document.createElement("dd");
|
||
dd.textContent = String(kv[1]);
|
||
frag.appendChild(dt);
|
||
frag.appendChild(dd);
|
||
});
|
||
dl.replaceChildren(frag);
|
||
} catch (e) {
|
||
errEl.textContent = "Статистика: " + e.message;
|
||
errEl.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
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);
|
||
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 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,
|
||
),
|
||
);
|
||
td.appendChild(
|
||
iconActionButton(
|
||
ICONS.play,
|
||
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
|
||
"",
|
||
function () {
|
||
startCluster(c.name);
|
||
},
|
||
false,
|
||
),
|
||
);
|
||
if (c.registered_in_kind) {
|
||
td.appendChild(
|
||
iconActionButton(
|
||
ICONS.stop,
|
||
"Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется",
|
||
"icon-btn--secondary",
|
||
function () {
|
||
stopCluster(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);
|
||
});
|
||
tbody.replaceChildren(frag);
|
||
bindActionTooltipHosts(document.getElementById("tbl-clusters"));
|
||
if (msg) {
|
||
msg.textContent = rows.length ? "" : "Кластеров пока нет.";
|
||
}
|
||
} catch (e) {
|
||
if (msg) msg.textContent = "Ошибка списка: " + e.message;
|
||
}
|
||
}
|
||
|
||
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 = j.kind === "start_cluster" ? "[старт] " : "";
|
||
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 + "%)" : "");
|
||
}
|
||
tr.innerHTML =
|
||
"<td><time datetime=\"" +
|
||
escapeHtml(j.created_at_utc || "") +
|
||
"\">" +
|
||
escapeHtml(j.created_at_utc || "") +
|
||
"</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 +
|
||
"»: контейнеры будут остановлены (docker/podman stop). Запись в kind сохранится — позже можно снова нажать «Старт».",
|
||
confirmLabel: "Остановить",
|
||
danger: false,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const res = await api("/clusters/" + encodeURIComponent(name) + "/stop", { method: "POST" });
|
||
showToast(String(res.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) {
|
||
setProgressHint(name);
|
||
pollJob(data.job_id);
|
||
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
|
||
*/
|
||
function setProgressHint(clusterName) {
|
||
const hint = document.getElementById("create-progress-hint");
|
||
if (!hint) return;
|
||
if (clusterName) {
|
||
hint.innerHTML =
|
||
"Кластер <code>" +
|
||
escapeHtml(clusterName) +
|
||
"</code>: подъём по конфигу или длительный <code>kind create</code> — ниже журнал (pull образов и ноды).";
|
||
} else {
|
||
hint.innerHTML =
|
||
"Создание кластера: шаг <code>kind create</code> может занять несколько минут при первом pull образов. " +
|
||
"Ниже — журнал в реальном времени.";
|
||
}
|
||
}
|
||
|
||
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;
|
||
setCreateFormDisabled(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);
|
||
}
|
||
}
|
||
|
||
function pollJob(jobId) {
|
||
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;
|
||
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 && j.progress_log && j.progress_log.length) {
|
||
logEl.textContent = j.progress_log.join("\n");
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
}
|
||
|
||
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
|
||
stopPollJob();
|
||
setProgressHint(null);
|
||
if (msg) {
|
||
if (j.status === "success") {
|
||
msg.textContent =
|
||
j.kind === "start_cluster" ? "Кластер поднят по сохранённому конфигу." : "Кластер создан.";
|
||
} else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
|
||
else msg.textContent = "Ошибка: " + (j.message || "");
|
||
}
|
||
if (j.status === "success") {
|
||
showToast(j.kind === "start_cluster" ? "Кластер запущен" : "Кластер создан", 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_MS);
|
||
}
|
||
|
||
function refreshLists() {
|
||
loadHealth();
|
||
loadStats();
|
||
loadClusters();
|
||
loadJobs();
|
||
}
|
||
|
||
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);
|
||
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);
|
||
} 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 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);
|
||
|
||
loadHealth();
|
||
loadStats();
|
||
loadVersions();
|
||
loadClusters();
|
||
loadJobs();
|
||
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|