/** * Страница редактирования кластера: 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 = ""; }); 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 = ""; 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 = ""; }); } } /** * Разбор секции networking: (документация kind — cluster-wide networking). * @param {string} text * @returns {Pick} */ 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} */ 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); })();