Панель: редактирование конфига кластера, 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
+811
View File
@@ -0,0 +1,811 @@
/**
* Страница редактирования кластера: GET/PUT /api/v1/clusters/{name}/config.
* Расширенный режим — форма по документации kind (networking, nodes, extraPortMappings, kubeadmConfigPatches).
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
const clusterName = (body.getAttribute("data-cluster-name") || "").trim();
if (!clusterName) return;
/** @type {string} */
let initialTag = "";
/** @type {number | null} */
let initialWorkers = null;
/** @type {string} */
let initialDescription = "";
/** @type {string | null} */
let loadedYamlSnapshot = null;
/** @type {string} */
let initialAdvancedSnapshot = "";
/**
* @typedef {object} AdvancedFormState
* @property {string} apiVersion
* @property {string} image
* @property {number} controlPlanes
* @property {number} workers
* @property {string} ipFamily
* @property {string} apiServerAddress
* @property {string} apiServerPort
* @property {string} podSubnet
* @property {string} serviceSubnet
* @property {string} kubeProxyMode
* @property {boolean} disableDefaultCNI
* @property {boolean} portEnabled
* @property {string} hostPort
* @property {number} containerPort
* @property {string} portListenAddress
* @property {string} portProtocol
* @property {string} kubeadmPatch
*/
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;
}
/**
* Собрать содержимое модалки: как на диске сохранилось и как это доходит до kind.
* @param {boolean} updatedYaml
* @param {boolean} registeredInKind
* @returns {DocumentFragment}
*/
function buildSaveInfoModalContent(updatedYaml, registeredInKind) {
const frag = document.createDocumentFragment();
if (!updatedYaml) {
const p = document.createElement("p");
p.textContent =
"Сохранены поля без изменения kind-config.yaml (например, только описание в meta.json). " +
"Топология кластера в kind и уже запущенные узлы от этого не меняются.";
frag.appendChild(p);
return frag;
}
const lead = document.createElement("p");
lead.className = "cluster-edit-save-modal-lead";
lead.textContent = "Файл kind-config.yaml и связанные поля meta.json записаны на диск.";
frag.appendChild(lead);
if (registeredInKind) {
const pIntro = document.createElement("p");
pIntro.textContent = "Кластер уже зарегистрирован в kind. Новый конфиг применяется так:";
frag.appendChild(pIntro);
const ul = document.createElement("ul");
const lines = [
"До нажатия «Старт» продолжают работать текущие узлы (старая топология и образы).",
"При «Старт» на панели или на странице кластера выполняется пересоздание: удаление записи в kind без удаления каталога clusters/" +
clusterName +
"/, затем создание кластера по обновлённому YAML.",
"«Стоп» перед «Старт» не обязателен — можно нажать «Старт» сразу после сохранения.",
];
lines.forEach(function (line) {
const li = document.createElement("li");
li.textContent = line;
ul.appendChild(li);
});
frag.appendChild(ul);
const note = document.createElement("p");
note.style.marginTop = "0.65rem";
note.style.fontSize = "0.88rem";
note.textContent =
"Состояние Kubernetes внутри кластера при пересоздании начинается заново; kubeconfig в каталоге кластера обновится после успешного «Старт».";
frag.appendChild(note);
} else {
const p2 = document.createElement("p");
p2.textContent =
"Кластер сейчас не в списке kind. При «Старт» он будет поднят по сохранённому kind-config.yaml (полный цикл создания, как при восстановлении из файла).";
frag.appendChild(p2);
}
return frag;
}
function openClusterEditSaveModal(updatedYaml, registeredInKind) {
const overlay = document.getElementById("cluster-edit-save-modal-overlay");
const titleEl = document.getElementById("cluster-edit-save-modal-title");
const bodyEl = document.getElementById("cluster-edit-save-modal-body");
if (!overlay || !titleEl || !bodyEl) return;
titleEl.textContent = updatedYaml ? "Конфигурация сохранена" : "Сохранено";
bodyEl.replaceChildren();
bodyEl.appendChild(buildSaveInfoModalContent(updatedYaml, registeredInKind));
overlay.classList.remove("hidden");
overlay.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
const okBtn = document.getElementById("cluster-edit-save-modal-ok");
if (okBtn) okBtn.focus();
}
function closeClusterEditSaveModal() {
const overlay = document.getElementById("cluster-edit-save-modal-overlay");
if (!overlay) return;
overlay.classList.add("hidden");
overlay.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open");
}
/** Закрытие модалки после сохранения и переход на страницу кластера (только кнопка «Понятно»). */
function onSaveInfoModalOk() {
closeClusterEditSaveModal();
window.location.href = "/cluster/" + encodeURIComponent(clusterName);
}
function wireSaveInfoModal() {
const overlay = document.getElementById("cluster-edit-save-modal-overlay");
const ok = document.getElementById("cluster-edit-save-modal-ok");
if (!overlay || !ok) return;
ok.addEventListener("click", onSaveInfoModalOk);
overlay.addEventListener("click", function (ev) {
if (ev.target === overlay) closeClusterEditSaveModal();
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && !overlay.classList.contains("hidden")) {
closeClusterEditSaveModal();
}
});
}
function wireVersionSelect(selId, inputId) {
const sel = document.getElementById(selId);
const verInput = document.getElementById(inputId);
if (!sel || !verInput) return;
sel.onchange = function () {
if (!sel.value) return;
if (inputId === "fld-adv-image") {
verInput.value =
sel.value === "latest" ? "kindest/node:latest" : "kindest/node:" + sel.value.replace(/^v/, "");
} else {
verInput.value = sel.value === "latest" ? "latest" : sel.value.replace(/^v/, "");
}
};
}
async function loadVersions() {
const ids = [
["version-select-edit", "kubernetes_version_edit"],
["version-select-adv", "fld-adv-image"],
];
ids.forEach(function (pair) {
const sel = document.getElementById(pair[0]);
if (sel) sel.innerHTML = "<option value=\"\">— загрузка —</option>";
});
try {
const data = await api("/versions");
ids.forEach(function (pair) {
const sel = document.getElementById(pair[0]);
if (!sel) return;
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.forEach(function (t) {
const o = document.createElement("option");
o.value = t;
o.textContent = t === "latest" ? "latest (kindest/node)" : t;
sel.appendChild(o);
});
});
wireVersionSelect("version-select-edit", "kubernetes_version_edit");
wireVersionSelect("version-select-adv", "fld-adv-image");
} catch (e) {
ids.forEach(function (pair) {
const sel = document.getElementById(pair[0]);
if (sel) sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
});
}
}
/**
* Разбор секции networking: (документация kind — cluster-wide networking).
* @param {string} text
* @returns {Pick<AdvancedFormState, 'ipFamily'|'apiServerAddress'|'apiServerPort'|'podSubnet'|'serviceSubnet'|'kubeProxyMode'|'disableDefaultCNI'>}
*/
function parseNetworkingSection(text) {
const out = {
ipFamily: "",
apiServerAddress: "",
apiServerPort: "",
podSubnet: "",
serviceSubnet: "",
kubeProxyMode: "",
disableDefaultCNI: false,
};
const lines = text.split("\n");
let i = 0;
while (i < lines.length) {
if (/^\s*networking:\s*$/.test(lines[i])) {
i++;
while (i < lines.length) {
const L = lines[i];
const t = L.trim();
if (!t) {
i++;
continue;
}
if (/^[a-zA-Z]/.test(L) && !/^\s/.test(L)) break;
const kv = L.match(/^\s{2}([a-zA-Z0-9]+):\s*(.*)$/);
if (kv) {
const k = kv[1];
let v = kv[2].trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
if (k === "ipFamily") out.ipFamily = v;
else if (k === "apiServerAddress") out.apiServerAddress = v;
else if (k === "apiServerPort") out.apiServerPort = v;
else if (k === "podSubnet") out.podSubnet = v;
else if (k === "serviceSubnet") out.serviceSubnet = v;
else if (k === "kubeProxyMode") out.kubeProxyMode = v.replace(/^["']|["']$/g, "");
else if (k === "disableDefaultCNI") out.disableDefaultCNI = v === "true";
}
i++;
}
break;
}
i++;
}
return out;
}
/**
* @param {AdvancedFormState} f
* @returns {string}
*/
function buildNetworkingYaml(f) {
const rows = [];
if (f.ipFamily && f.ipFamily !== "ipv4") rows.push(" ipFamily: " + f.ipFamily);
const addr = (f.apiServerAddress || "").trim();
if (addr) rows.push(' apiServerAddress: "' + addr.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"');
const ap = String(f.apiServerPort || "").trim();
if (ap) {
const pn = parseInt(ap, 10);
if (pn >= 1 && pn <= 65535) rows.push(" apiServerPort: " + pn);
}
const ps = (f.podSubnet || "").trim();
if (ps) rows.push(' podSubnet: "' + ps.replace(/"/g, '\\"') + '"');
const ss = (f.serviceSubnet || "").trim();
if (ss) rows.push(' serviceSubnet: "' + ss.replace(/"/g, '\\"') + '"');
const kpm = (f.kubeProxyMode || "").trim();
if (kpm) rows.push(' kubeProxyMode: "' + kpm.replace(/"/g, '\\"') + '"');
if (f.disableDefaultCNI) rows.push(" disableDefaultCNI: true");
if (!rows.length) return "";
return "networking:\n" + rows.join("\n") + "\n";
}
/**
* @param {string} text
* @returns {AdvancedFormState}
*/
function parseKindYamlToAdvancedForm(text) {
/** @type {AdvancedFormState} */
const out = {
apiVersion: "kind.x-k8s.io/v1alpha4",
image: "",
controlPlanes: 1,
workers: 0,
ipFamily: "",
apiServerAddress: "",
apiServerPort: "",
podSubnet: "",
serviceSubnet: "",
kubeProxyMode: "",
disableDefaultCNI: false,
portEnabled: false,
hostPort: "",
containerPort: 80,
portListenAddress: "",
portProtocol: "TCP",
kubeadmPatch: "",
};
if (!text || !text.trim()) return out;
const av = text.match(/^\s*apiVersion:\s*(\S+)/m);
if (av) out.apiVersion = av[1].trim().replace(/['"]/g, "");
const net = parseNetworkingSection(text);
Object.assign(out, net);
const lines = text.split("\n");
let inNodes = false;
let cp = 0;
let wk = 0;
/** @type {string | null} */
let pendingRole = null;
const images = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const t = line.trim();
if (t === "nodes:" || t.startsWith("nodes:")) {
inNodes = true;
pendingRole = null;
continue;
}
if (!inNodes) continue;
if (t && !line.startsWith(" ") && !line.startsWith("\t")) break;
const roleM = line.match(/^\s*-\s*role:\s*(\S+)/);
if (roleM) {
pendingRole = roleM[1];
if (pendingRole === "control-plane") cp++;
else if (pendingRole === "worker") wk++;
continue;
}
const imgM = line.match(/^\s*image:\s*(.+)$/);
if (imgM && pendingRole) {
images.push(imgM[1].trim().replace(/['"]/g, ""));
}
}
if (cp > 0) out.controlPlanes = cp;
out.workers = wk;
const uniq = [];
images.forEach(function (im) {
if (uniq.indexOf(im) < 0) uniq.push(im);
});
out.image = uniq[0] || "";
const portSplit = text.split("extraPortMappings:");
if (portSplit.length > 1) {
const portBlock = portSplit[1];
const hh = portBlock.match(/hostPort:\s*(\d+)/);
const cc = portBlock.match(/containerPort:\s*(\d+)/);
const la =
portBlock.match(/listenAddress:\s*"([^"]+)"/) ||
portBlock.match(/listenAddress:\s*'([^']+)'/);
const pr = portBlock.match(/protocol:\s*(\w+)/);
if (hh) {
out.portEnabled = true;
out.hostPort = hh[1];
if (cc) out.containerPort = parseInt(cc[1], 10) || 80;
if (la) out.portListenAddress = la[1];
if (pr && /UDP|SCTP|TCP/i.test(pr[1])) out.portProtocol = pr[1].toUpperCase();
}
}
const kubStart = text.indexOf("kubeadmConfigPatches:");
if (kubStart >= 0) {
const sub = text.slice(kubStart);
const pipeM = sub.match(/kubeadmConfigPatches:\s*\n\s*-\s*\|\s*\n([\s\S]+)$/);
if (pipeM) {
const raw = pipeM[1];
const linesPatch = raw.split("\n");
const outLines = [];
for (let j = 0; j < linesPatch.length; j++) {
const ln = linesPatch[j];
if (/^\s*-\s*role:\s*/.test(ln)) break;
outLines.push(ln.replace(/^ /, ""));
}
out.kubeadmPatch = outLines.join("\n").replace(/\n+$/, "");
}
}
return out;
}
function ensureApiVersionOption(apiVersion) {
const sel = document.getElementById("fld-adv-api-version");
if (!sel || !apiVersion) return;
let found = false;
sel.querySelectorAll("option").forEach(function (o) {
if (o.value === apiVersion) found = true;
});
if (!found) {
const o = document.createElement("option");
o.value = apiVersion;
o.textContent = apiVersion;
sel.appendChild(o);
}
}
/**
* @param {AdvancedFormState} f
* @returns {string}
*/
function serializeAdvancedKindCluster(f) {
const api = f.apiVersion || "kind.x-k8s.io/v1alpha4";
const img = (f.image || "").trim();
if (!img) throw new Error("Укажите образ узла (kindest/node:…).");
const cp = Math.min(5, Math.max(1, parseInt(String(f.controlPlanes), 10) || 1));
const wk = Math.min(20, Math.max(0, parseInt(String(f.workers), 10) || 0));
const lines = ["kind: Cluster", "apiVersion: " + api];
const netBlock = buildNetworkingYaml(f);
if (netBlock) lines.push(netBlock.trimEnd());
lines.push("nodes:");
const firstExtras = [];
if (f.portEnabled && f.hostPort) {
const hp = parseInt(String(f.hostPort), 10);
const ctp = parseInt(String(f.containerPort), 10) || 80;
if (hp >= 1 && hp <= 65535) {
firstExtras.push(" extraPortMappings:");
firstExtras.push(" - containerPort: " + ctp);
firstExtras.push(" hostPort: " + hp);
const listen = (f.portListenAddress || "").trim();
if (listen) {
firstExtras.push(' listenAddress: "' + listen.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"');
}
const proto = (f.portProtocol || "TCP").toUpperCase();
if (proto === "TCP" || proto === "UDP" || proto === "SCTP") {
firstExtras.push(" protocol: " + proto);
}
}
}
const patch = (f.kubeadmPatch || "").trim();
if (patch) {
firstExtras.push(" kubeadmConfigPatches:");
firstExtras.push(" - |");
patch.split("\n").forEach(function (ln) {
firstExtras.push(" " + ln);
});
}
let i;
for (i = 0; i < cp; i++) {
lines.push(" - role: control-plane");
lines.push(" image: " + img);
if (i === 0 && firstExtras.length) {
firstExtras.forEach(function (x) {
lines.push(x);
});
}
}
for (i = 0; i < wk; i++) {
lines.push(" - role: worker");
lines.push(" image: " + img);
}
return lines.join("\n") + "\n";
}
/**
* @returns {AdvancedFormState}
*/
function readAdvancedFormFromDom() {
const pick = function (id) {
const el = document.getElementById(id);
return el ? el.value : "";
};
const pickNum = function (id, def) {
const el = document.getElementById(id);
if (!el || !el.value.trim()) return def;
const n = parseInt(el.value, 10);
return Number.isNaN(n) ? def : n;
};
const pickChk = function (id) {
const el = document.getElementById(id);
return !!(el && el.checked);
};
return {
apiVersion: pick("fld-adv-api-version") || "kind.x-k8s.io/v1alpha4",
image: pick("fld-adv-image").trim(),
controlPlanes: pickNum("fld-adv-cp", 1),
workers: pickNum("fld-adv-workers", 0),
ipFamily: pick("fld-adv-ip-family"),
apiServerAddress: pick("fld-adv-api-addr"),
apiServerPort: pick("fld-adv-api-port"),
podSubnet: pick("fld-adv-pod-subnet"),
serviceSubnet: pick("fld-adv-svc-subnet"),
kubeProxyMode: pick("fld-adv-kube-proxy"),
disableDefaultCNI: pickChk("fld-adv-disable-cni"),
portEnabled: pickChk("fld-adv-port-enable"),
hostPort: pick("fld-adv-host-port"),
containerPort: pickNum("fld-adv-container-port", 80),
portListenAddress: pick("fld-adv-listen-addr"),
portProtocol: pick("fld-adv-port-proto") || "TCP",
kubeadmPatch: (function () {
const el = document.getElementById("fld-adv-kubeadm");
return el ? el.value : "";
})(),
};
}
function advancedFormSnapshotString() {
try {
return JSON.stringify(readAdvancedFormFromDom());
} catch (e) {
return "";
}
}
/**
* @param {AdvancedFormState} f
*/
function applyAdvancedFormToDom(f) {
const set = function (id, v) {
const el = document.getElementById(id);
if (el) el.value = v != null ? String(v) : "";
};
ensureApiVersionOption(f.apiVersion || "kind.x-k8s.io/v1alpha4");
set("fld-adv-api-version", f.apiVersion || "kind.x-k8s.io/v1alpha4");
set("fld-adv-ip-family", f.ipFamily || "");
set("fld-adv-api-addr", f.apiServerAddress || "");
set("fld-adv-api-port", f.apiServerPort || "");
set("fld-adv-pod-subnet", f.podSubnet || "");
set("fld-adv-svc-subnet", f.serviceSubnet || "");
set("fld-adv-kube-proxy", f.kubeProxyMode || "");
const cni = document.getElementById("fld-adv-disable-cni");
if (cni) cni.checked = !!f.disableDefaultCNI;
set("fld-adv-image", f.image || "");
set("fld-adv-cp", String(Math.min(5, Math.max(1, f.controlPlanes || 1))));
set("fld-adv-workers", String(Math.min(20, Math.max(0, f.workers || 0))));
const pe = document.getElementById("fld-adv-port-enable");
if (pe) pe.checked = !!f.portEnabled;
set("fld-adv-host-port", f.hostPort || "");
set("fld-adv-container-port", String(f.containerPort || 80));
set("fld-adv-listen-addr", f.portListenAddress || "");
set("fld-adv-port-proto", f.portProtocol || "TCP");
const kub = document.getElementById("fld-adv-kubeadm");
if (kub) kub.value = f.kubeadmPatch || "";
togglePortRow();
}
function togglePortRow() {
const pe = document.getElementById("fld-adv-port-enable");
const row = document.getElementById("adv-port-fields-row");
const on = !!(pe && pe.checked);
if (row) {
row.classList.toggle("hidden", !on);
row.setAttribute("aria-hidden", on ? "false" : "true");
}
}
function updateAdvancedPreview() {
const pre = document.getElementById("adv-yaml-preview");
if (!pre) return;
try {
pre.textContent = serializeAdvancedKindCluster(readAdvancedFormFromDom());
} catch (e) {
pre.textContent = "# " + (e.message || "Заполните образ узла");
}
}
function setModeUi(isAdvanced) {
const simple = document.getElementById("edit-simple-block");
const adv = document.getElementById("edit-advanced-block");
const hint = document.getElementById("cluster-edit-mode-hint");
const radios = document.querySelectorAll('input[name="save_mode"]');
radios.forEach(function (r) {
r.checked = (isAdvanced && r.value === "yaml") || (!isAdvanced && r.value === "simple");
});
if (simple) {
simple.classList.toggle("hidden", isAdvanced);
simple.setAttribute("aria-hidden", isAdvanced ? "true" : "false");
}
if (adv) {
adv.classList.toggle("hidden", !isAdvanced);
adv.setAttribute("aria-hidden", !isAdvanced ? "true" : "false");
}
if (hint) {
hint.textContent = isAdvanced
? "Секции соответствуют kind Cluster: networking, nodes, extraPortMappings и kubeadmConfigPatches (к первой control-plane)."
: "Только тег kindest/node и число worker-нод; конфиг пересобирается как при создании кластера.";
}
if (isAdvanced) updateAdvancedPreview();
}
function getSaveMode() {
const c = document.querySelector('input[name="save_mode"]:checked');
return c && c.value === "yaml" ? "yaml" : "simple";
}
function normTag(s) {
return String(s || "")
.trim()
.toLowerCase()
.replace(/^v/, "");
}
async function loadConfig() {
const noteEl = document.getElementById("edit-kind-note");
const msg = document.getElementById("edit-msg");
const verInput = document.getElementById("kubernetes_version_edit");
const workersEl = document.getElementById("fld-workers-edit");
const descEl = document.getElementById("fld-description");
try {
const d = await api("/clusters/" + encodeURIComponent(clusterName) + "/config");
if (noteEl) {
noteEl.textContent = d.kind_note || "";
}
const meta = d.meta || {};
initialTag = meta.kubernetes_version_tag != null ? String(meta.kubernetes_version_tag) : "";
if (verInput) {
const tag = initialTag;
verInput.value = tag ? tag.replace(/^v/i, "") : "";
}
if (meta.worker_nodes != null) {
const wn = Number(meta.worker_nodes);
initialWorkers = Number.isNaN(wn) ? 0 : wn;
} else {
initialWorkers = null;
}
if (workersEl) {
workersEl.value = String(initialWorkers !== null ? initialWorkers : 0);
}
initialDescription = meta.description != null ? String(meta.description) : "";
if (descEl) {
descEl.value = initialDescription;
}
const yamlText = d.kind_config_yaml != null ? String(d.kind_config_yaml) : "";
loadedYamlSnapshot = yamlText;
const advParsed = parseKindYamlToAdvancedForm(yamlText);
if (!advParsed.image && meta.node_image) {
advParsed.image = String(meta.node_image);
}
if (advParsed.controlPlanes < 1) advParsed.controlPlanes = 1;
applyAdvancedFormToDom(advParsed);
initialAdvancedSnapshot = advancedFormSnapshotString();
updateAdvancedPreview();
if (msg) msg.textContent = "";
} catch (e) {
if (noteEl) noteEl.textContent = "";
if (msg) {
msg.textContent = e.status === 404 ? "Каталог кластера не найден." : "Ошибка загрузки: " + e.message;
}
}
}
function bindAdvancedInputs() {
const ids = [
"fld-adv-api-version",
"fld-adv-ip-family",
"fld-adv-api-addr",
"fld-adv-api-port",
"fld-adv-pod-subnet",
"fld-adv-svc-subnet",
"fld-adv-kube-proxy",
"fld-adv-disable-cni",
"fld-adv-image",
"fld-adv-cp",
"fld-adv-workers",
"fld-adv-port-enable",
"fld-adv-host-port",
"fld-adv-container-port",
"fld-adv-listen-addr",
"fld-adv-port-proto",
"fld-adv-kubeadm",
];
ids.forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener("input", updateAdvancedPreview);
el.addEventListener("change", updateAdvancedPreview);
});
const pe = document.getElementById("fld-adv-port-enable");
if (pe) {
pe.addEventListener("change", function () {
togglePortRow();
updateAdvancedPreview();
});
}
}
function bindForm() {
const form = document.getElementById("form-cluster-edit");
if (!form) return;
document.querySelectorAll('input[name="save_mode"]').forEach(function (r) {
r.addEventListener("change", function () {
setModeUi(getSaveMode() === "yaml");
});
});
form.addEventListener("submit", async function (ev) {
ev.preventDefault();
const msg = document.getElementById("edit-msg");
const mode = getSaveMode();
const descEl = document.getElementById("fld-description");
const descVal = descEl ? descEl.value : "";
/** @type {Record<string, unknown>} */
const payload = { description: descVal };
if (mode === "yaml") {
let yamlText;
try {
yamlText = serializeAdvancedKindCluster(readAdvancedFormFromDom());
} catch (e) {
if (msg) msg.textContent = e.message || "Проверьте поля расширенного режима.";
return;
}
const yamlChanged = yamlText !== (loadedYamlSnapshot || "");
const advChanged = advancedFormSnapshotString() !== initialAdvancedSnapshot;
const descChanged = descVal !== initialDescription;
if (!yamlChanged && !advChanged && !descChanged) {
if (msg) msg.textContent = "Изменений нет.";
return;
}
if (yamlChanged || advChanged) {
payload.kind_config_yaml = yamlText;
}
} else {
const verInput = document.getElementById("kubernetes_version_edit");
const workersEl = document.getElementById("fld-workers-edit");
const ver = verInput ? verInput.value.trim() : "";
const wParsed = workersEl ? parseInt(workersEl.value, 10) : NaN;
const workersOk = !Number.isNaN(wParsed);
const effInitW = initialWorkers !== null && initialWorkers !== undefined ? initialWorkers : 0;
const workersChanged = workersOk && wParsed !== effInitW;
const verChanged = normTag(ver) !== normTag(initialTag);
const descChanged = descVal !== initialDescription;
if (!verChanged && !workersChanged && !descChanged) {
if (msg) msg.textContent = "Изменений нет.";
return;
}
if (verChanged) {
if (!ver) {
if (msg) msg.textContent = "Укажите тег образа или откатите поле к прежнему значению.";
return;
}
payload.kubernetes_version = ver;
}
if (workersChanged) {
if (!workersOk || wParsed < 0 || wParsed > 20) {
if (msg) msg.textContent = "Число worker-нод должно быть от 0 до 20.";
return;
}
payload.workers = wParsed;
}
}
if (msg) msg.textContent = "Сохранение…";
try {
const r = await api("/clusters/" + encodeURIComponent(clusterName) + "/config", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(payload),
});
if (msg) {
msg.textContent = r.message || "Сохранено.";
}
openClusterEditSaveModal(!!r.updated_kind_config_yaml, !!r.registered_in_kind);
await loadConfig();
} catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message;
}
});
}
wireSaveInfoModal();
loadVersions();
loadConfig().then(function () {
bindAdvancedInputs();
togglePortRow();
});
bindForm();
setModeUi(false);
})();