Панель: редактирование конфига кластера, reapply при старте, UI и таблица

- PUT/GET конфигурации кластера, страница редактирования и модалка после сохранения
- После смены kind-config: флаг в meta и start_cluster_reapply (kind delete + create)
- Старт/стоп: полноэкранный спиннер до завершения job; модалки и документация API
- Таблица кластеров: колонка Имя 40% при table-layout fixed; чекбоксы без width 100%
- Карточки ресурсов узлов на странице кластера: до 3 в ряд; прочие правки стилей и dashboard.js
This commit is contained in:
Sergey Antropoff
2026-04-04 10:49:40 +03:00
parent af0d1705cc
commit 52538d9816
14 changed files with 2571 additions and 137 deletions
+93 -28
View File
@@ -26,8 +26,8 @@
var pollLockCreateForm = false;
/** @type {string | null} */
var currentPollJobId = null;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
var currentModalClusterName = null;
/** Полноэкранный спиннер старт/стоп (не используется при создании кластера из формы). */
var clusterLifecycleJobOverlayActive = false;
/** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */
var cachedClusterResourcesByName = {};
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
@@ -84,6 +84,29 @@
document.body.classList.remove("dashboard-home-loading");
}
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
function showClusterLifecycleJobOverlay(labelText) {
const el = document.getElementById("cluster-lifecycle-job-overlay");
const lab = document.getElementById("cluster-lifecycle-job-label");
if (!el) return;
clusterLifecycleJobOverlayActive = true;
if (lab) lab.textContent = labelText || "Выполняется операция…";
el.classList.remove("hidden");
el.setAttribute("aria-busy", "true");
el.setAttribute("aria-hidden", "false");
document.body.classList.add("cluster-lifecycle-job-busy");
}
function hideClusterLifecycleJobOverlay() {
const el = document.getElementById("cluster-lifecycle-job-overlay");
if (!el) return;
clusterLifecycleJobOverlayActive = false;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("cluster-lifecycle-job-busy");
}
function formatApiError(data, fallback) {
if (!data) return fallback;
if (typeof data.detail === "string") return data.detail;
@@ -213,6 +236,8 @@
'<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} */
@@ -1249,7 +1274,7 @@
host.appendChild(
iconActionButton(
ICONS.state,
"Состояние: узлы и поды (kubectl get nodes / pods)",
"Состояние: узлы и поды",
"",
function () {
openWorkloadsModal(name);
@@ -1257,6 +1282,17 @@
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(
@@ -1355,15 +1391,26 @@
);
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);
@@ -1635,24 +1682,24 @@
"<span class=\"muted\" title=\"Нет в kind — только локальный каталог или кластер удалён из kind\">—</span>";
}
tr.innerHTML =
"<td><a class=\"cluster-name-link\" href=\"/cluster/" +
"<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>" +
"<td class=\"col-kind\">" +
(c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
"<td class=\"col-kubeconfig\">" +
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
"</td>" +
"<td>" +
"<td class=\"col-version\">" +
escapeHtml(String(ver)) +
"</td>" +
"<td>" +
"<td class=\"col-workers\">" +
escapeHtml(String(wn)) +
"</td>" +
"<td>" +
"<td class=\"col-state\">" +
stateCell +
"</td>" +
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
@@ -1725,6 +1772,7 @@
const st = escapeHtml(j.status || "");
var kindTag = "";
if (j.kind === "start_cluster") kindTag = "[старт] ";
else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] ";
else if (j.kind === "stop_containers") kindTag = "[стоп] ";
else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
var cellMsg = kindTag + (j.message || "").slice(0, 140);
@@ -1774,15 +1822,12 @@
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");
@@ -1792,10 +1837,9 @@
sub.textContent = w.error;
return;
}
sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc;
sub.textContent = "Узлы: код " + w.nodes_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 {
@@ -1878,9 +1922,6 @@
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");
}
@@ -1908,7 +1949,10 @@
}
if (r.status === 202 && data.job_id) {
setProgressHint(name, "stop");
pollJob(data.job_id, { lockCreateForm: false });
pollJob(data.job_id, {
lockCreateForm: false,
lifecycleOverlayLabel: "Останавливаем узлы кластера «" + name + "»…",
});
return;
}
if (!r.ok) {
@@ -1941,8 +1985,15 @@
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");
pollJob(data.job_id, { lockCreateForm: false });
var startLab =
sm === "containers"
? "Запуск узлов кластера «" + name + "»…"
: sm === "kind_config_reapply"
? "Пересоздание кластера «" + name + "» в kind…"
: "Подъём кластера «" + name + "» по конфигурации…";
pollJob(data.job_id, { lockCreateForm: false, lifecycleOverlayLabel: startLab });
return;
}
if (!r.ok) {
@@ -2015,7 +2066,7 @@
/**
* Подсказка над прогресс-баром (без технических имён команд).
* @param {string | null} clusterName
* @param {"create"|"start_config"|"start_containers"|"stop"} [mode]
* @param {"create"|"start_config"|"start_reapply"|"start_containers"|"stop"} [mode]
*/
function setProgressHint(clusterName, mode) {
const hint = document.getElementById("create-progress-hint");
@@ -2026,6 +2077,11 @@
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 {
@@ -2052,6 +2108,7 @@
}
function stopPollJob() {
hideClusterLifecycleJobOverlay();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
@@ -2077,7 +2134,8 @@
/**
* @param {string} jobId
* @param {{ lockCreateForm?: boolean }} [opts]
* @param {{ lockCreateForm?: boolean, lifecycleOverlayLabel?: string }} [opts]
* ``lifecycleOverlayLabel`` — полноэкранный спиннер (старт/стоп с главной или страницы кластера).
*/
function pollJob(jobId, opts) {
opts = opts || {};
@@ -2096,6 +2154,9 @@
if (pollLockCreateForm) {
setCreateFormDisabled(true);
}
if (opts.lifecycleOverlayLabel) {
showClusterLifecycleJobOverlay(opts.lifecycleOverlayLabel);
}
showCreateProgress(true);
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
@@ -2105,6 +2166,14 @@
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");
@@ -2123,6 +2192,8 @@
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 = "Кластер создан.";
@@ -2131,6 +2202,7 @@
}
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);
@@ -2255,13 +2327,6 @@
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) {