/**
 * Панель управления кластерами kind (REST /api/v1).
 * Полная перезагрузка страницы (location.reload) не используется: только fetch и точечная
 * замена содержимого блоков (статистика, таблицы, плашка среды) — SPA-поведение.
 *
 * Автор: Сергей Антропов
 * Сайт: https://devops.org.ru
 */
(function () {
  "use strict";

  const body = document.body;
  const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");

  /** Интервал автообновления таблиц и среды на панели (мс) */
  var AUTO_REFRESH_MS = 3500;
  /** Опрос GET /jobs/{id} для активного задания (мс) — чаще, чтобы журнал обновлялся живее */
  var JOB_POLL_ACTIVE_MS = 650;

  /** @type {ReturnType<typeof setInterval> | null} */
  var autoTimer = null;
  /** @type {ReturnType<typeof setInterval> | null} */
  var pollTimer = null;
  var createInProgress = false;
  /** Блокировать форму «Создать» только при создании из формы, не при старт/стоп из таблицы. */
  var pollLockCreateForm = false;
  /** @type {string | null} */
  var currentPollJobId = null;
  /** Полноэкранный спиннер старт/стоп (не используется при создании кластера из формы). */
  var clusterLifecycleJobOverlayActive = false;
  /** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */
  var cachedClusterResourcesByName = {};
  /** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
  var cachedHealthPayload = null;
  /**
   * Кэш GET /versions для проверки тега перед созданием кластера.
   * null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов.
   */
  var cachedKindestTags = null;
  /** true, если запрос /versions упал (сеть, 5xx и т.д.). */
  var cachedKindestVersionsLoadError = false;
  /** true, если сервер вернул skipped (например KIND_K8S_SKIP_VERSION_LIST). */
  var cachedKindestTagsSkipped = false;
  /** Режим модалки подтверждения: только кнопка «Понятно» (Escape закрывает как OK). */
  var confirmModalIsAlert = false;

  function isHomePage() {
    return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
  }

  /** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */
  function isClustersListPage() {
    return document.body.getAttribute("data-dashboard-mode") === "clusters";
  }

  function isCreatePage() {
    return document.body.getAttribute("data-dashboard-mode") === "create";
  }

  /** Страница одного кластера: ``/cluster/<имя>``. */
  function isClusterPage() {
    return document.body.getAttribute("data-dashboard-mode") === "cluster";
  }

  /** Имя кластера с ``body[data-cluster-name]`` (страница кластера). */
  function getClusterPageName() {
    const raw = document.body.getAttribute("data-cluster-name");
    return raw && raw.trim() ? raw.trim() : null;
  }

  /** Скрыть полноэкранный спиннер первой загрузки страницы кластера. */
  function hideClusterPageLoadingOverlay() {
    const el = document.getElementById("cluster-page-loading-overlay");
    if (!el) return;
    el.classList.add("hidden");
    el.setAttribute("aria-busy", "false");
    document.body.classList.remove("cluster-page-loading");
  }

  /** Скрыть спиннер первой загрузки главной панели. */
  function hideHomePageLoadingOverlay() {
    const el = document.getElementById("home-page-loading-overlay");
    if (!el) return;
    el.classList.add("hidden");
    el.setAttribute("aria-busy", "false");
    document.body.classList.remove("dashboard-home-loading");
  }

  /** Скрыть спиннер первой загрузки страницы «Кластеры». */
  function hideClustersPageLoadingOverlay() {
    const el = document.getElementById("clusters-page-loading-overlay");
    if (!el) return;
    el.classList.add("hidden");
    el.setAttribute("aria-busy", "false");
    el.setAttribute("aria-hidden", "true");
    document.body.classList.remove("dashboard-clusters-loading");
  }

  /** Скрыть спиннер первой загрузки страницы «Создание кластера» (/cluster-create). */
  function hideCreatePageLoadingOverlay() {
    const el = document.getElementById("create-page-loading-overlay");
    if (!el) return;
    el.classList.add("hidden");
    el.setAttribute("aria-busy", "false");
    el.setAttribute("aria-hidden", "true");
    document.body.classList.remove("dashboard-create-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;
    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 isProvisionLogModalOpen() {
    const el = document.getElementById("provision-log-modal-overlay");
    return !!(el && !el.classList.contains("hidden"));
  }

  function closeProvisionLogModal() {
    const el = document.getElementById("provision-log-modal-overlay");
    if (el) el.classList.add("hidden");
  }

  function isJobLogModalOpen() {
    const el = document.getElementById("job-log-modal-overlay");
    return !!(el && !el.classList.contains("hidden"));
  }

  function closeJobLogModal() {
    const el = document.getElementById("job-log-modal-overlay");
    if (el) el.classList.add("hidden");
  }

  /**
   * Открыть модалку с текстовым журналом завершённого задания (GET /jobs/{id}).
   * @param {string} jobId
   */
  function openJobLogModal(jobId) {
    const overlay = document.getElementById("job-log-modal-overlay");
    const sub = document.getElementById("job-log-modal-sub");
    const pre = document.getElementById("job-log-modal-body");
    const titleEl = document.getElementById("job-log-modal-title");
    if (!overlay || !pre) return;
    pre.textContent = "Загрузка журнала…";
    if (sub) sub.textContent = "Задание: " + jobId;
    if (titleEl) titleEl.textContent = "Журнал задания";
    overlay.classList.remove("hidden");
    api("/jobs/" + encodeURIComponent(jobId))
      .then(function (job) {
        if (titleEl) {
          titleEl.textContent =
            "Журнал задания · " + (job.status || "") + (job.kind ? " · " + job.kind : "");
        }
        if (sub) {
          var parts = ["Задание: " + jobId];
          if (job.cluster_name) parts.push("Кластер: " + job.cluster_name);
          if (job.message) parts.push(job.message);
          sub.textContent = parts.join(" · ");
        }
        var lines = job.progress_log || [];
        pre.textContent = lines.length ? lines.join("\n") : job.message || "(журнал пуст)";
      })
      .catch(function (e) {
        pre.textContent = "Не удалось загрузить журнал: " + (e && e.message ? e.message : String(e));
      });
  }

  /**
   * Загрузить ``GET /clusters/{name}/provision-log`` и показать JSON в модалке.
   * @param {string} clusterName
   */
  function openProvisionLogModal(clusterName) {
    const overlay = document.getElementById("provision-log-modal-overlay");
    const sub = document.getElementById("provision-log-modal-sub");
    const pre = document.getElementById("provision-log-body");
    if (!overlay || !pre) return;
    pre.textContent = "Загрузка…";
    if (sub) sub.textContent = "Кластер: " + clusterName;
    overlay.classList.remove("hidden");
    api("/clusters/" + encodeURIComponent(clusterName) + "/provision-log")
      .then(function (data) {
        pre.textContent = JSON.stringify(data, null, 2);
      })
      .catch(function (e) {
        pre.textContent = "Ошибка: " + e.message;
      });
  }

  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);
  }

  /**
   * Скачать kubeconfig для kubectl из другого контейнера: host.docker.internal:порт + tls-server-name (см. GET …/kubeconfig/docker).
   * @param {string} clusterName
   */
  function downloadKubeconfigDocker(clusterName) {
    const url = API + "/clusters/" + encodeURIComponent(clusterName) + "/kubeconfig/docker";
    const a = document.createElement("a");
    a.href = url;
    a.download = "kubeconfig-docker-" + 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>',
    /** kubeconfig для контейнера: apiserver по имени control-plane в сети Docker */
    kubeDocker:
      '<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 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></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>',
    logFile:
      '<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} */
  var actionTooltipHideTimer = null;

  function getActionTooltipEl() {
    return document.getElementById("action-tooltip");
  }

  /** Скрыть плавающую подсказку (#action-tooltip, position: fixed). */
  function hideActionTooltip() {
    if (actionTooltipHideTimer) {
      clearTimeout(actionTooltipHideTimer);
      actionTooltipHideTimer = null;
    }
    const el = getActionTooltipEl();
    if (!el) return;
    el.classList.add("hidden");
    el.textContent = "";
    el.style.visibility = "";
  }

  /**
   * Показать подсказку у иконки (полный текст, без обрезки overflow таблицы).
   * @param {HTMLElement} host элемент .icon-tooltip-host
   */
  function showActionTooltip(host) {
    const el = getActionTooltipEl();
    const text = host.getAttribute("data-tooltip");
    if (!el || !text) return;
    if (actionTooltipHideTimer) {
      clearTimeout(actionTooltipHideTimer);
      actionTooltipHideTimer = null;
    }
    el.textContent = text;
    const margin = 10;
    const maxW = Math.min(352, window.innerWidth - margin * 2);
    el.style.maxWidth = maxW + "px";
    el.classList.remove("hidden");
    var w = el.offsetWidth;
    var h = el.offsetHeight;
    var r = host.getBoundingClientRect();
    var left = r.left + r.width / 2 - w / 2;
    if (left < margin) left = margin;
    if (left + w > window.innerWidth - margin) left = Math.max(margin, window.innerWidth - w - margin);
    var top = r.top - h - margin;
    if (top < margin) top = r.bottom + margin;
    if (top + h > window.innerHeight - margin) {
      top = Math.max(margin, window.innerHeight - h - margin);
    }
    el.style.left = left + "px";
    el.style.top = top + "px";
  }

  /**
   * Подписать .icon-tooltip-host внутри root на hover/focus (повторно безопасно).
   * @param {ParentNode | null} root
   */
  function bindActionTooltipHosts(root) {
    if (!root) return;
    root.querySelectorAll(".icon-tooltip-host").forEach(function (host) {
      if (host.dataset.actionTooltipBound === "1") return;
      host.dataset.actionTooltipBound = "1";
      host.addEventListener("mouseenter", function () {
        showActionTooltip(host);
      });
      host.addEventListener("mouseleave", function () {
        actionTooltipHideTimer = setTimeout(hideActionTooltip, 120);
      });
      host.addEventListener("focusin", function () {
        showActionTooltip(host);
      });
      host.addEventListener("focusout", function (ev) {
        var rel = ev.relatedTarget;
        if (!rel || !host.contains(rel)) hideActionTooltip();
      });
    });
  }

  /**
   * Кнопка-иконка в обёртке; подсказка — data-tooltip (см. #action-tooltip в base.html).
   * @param {string} svgHtml
   * @param {string} tooltip
   * @param {string} [classExtra] например icon-btn--danger
   * @param {((ev: Event) => void) | null} onClick
   * @param {boolean} [isDisabled]
   */
  function iconActionButton(svgHtml, tooltip, classExtra, onClick, isDisabled) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "icon-btn" + (classExtra ? " " + classExtra : "");
    btn.innerHTML = svgHtml;
    btn.setAttribute("aria-label", tooltip);
    if (isDisabled) {
      btn.disabled = true;
    } else if (onClick) {
      btn.addEventListener("click", onClick);
    }
    const host = document.createElement("span");
    host.className = "icon-tooltip-host";
    host.setAttribute("data-tooltip", tooltip);
    host.appendChild(btn);
    return host;
  }

  function showToast(message, isError) {
    const el = document.getElementById("toast");
    if (!el) return;
    el.textContent = message;
    el.classList.remove("hidden", "toast-error", "toast-ok");
    el.classList.add(isError ? "toast-error" : "toast-ok");
    clearTimeout(el._hideT);
    el._hideT = setTimeout(function () {
      el.classList.add("hidden");
    }, 4500);
  }

  /**
   * Карточки среды в блоке статистики (как плитки «Кластеров в kind»).
   * @param {string} label подпись снизу (CAPS в стиле stats-tile__label)
   * @param {string} valueText крупный текст сверху
   * @param {boolean} isOk
   * @param {string | null} [detail] доп. строка мелким шрифтом
   */
  function buildStatsEnvCard(label, valueText, isOk, detail) {
    const tile = document.createElement("div");
    tile.className =
      "stats-tile stats-env-card " + (isOk ? "stats-env-card--ok" : "stats-env-card--bad");
    const valEl = document.createElement("span");
    valEl.className = "stats-tile__value";
    valEl.textContent = valueText;
    const labEl = document.createElement("span");
    labEl.className = "stats-tile__label";
    labEl.textContent = label;
    tile.appendChild(valEl);
    tile.appendChild(labEl);
    if (detail) {
      const dEl = document.createElement("span");
      dEl.className = "stats-env-card__detail muted";
      dEl.textContent = detail.slice(0, 400);
      tile.appendChild(dEl);
    }
    return tile;
  }

  /** Нормализованное имя CLI для подписей (docker → Docker, podman → Podman). */
  function titleCaseContainerCli(cliRaw) {
    const s = String(cliRaw || "docker").trim() || "docker";
    return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
  }

  /** Текст под заголовком «Статистика»: через какой движок идут узлы и метрики. */
  function updateStatsEngineHint(cliRaw) {
    const el = document.getElementById("stats-engine-hint");
    if (!el) return;
    const name = titleCaseContainerCli(cliRaw);
    el.textContent =
      "Узлы kind (контейнеры control-plane и worker) и метрики ниже собираются через " + name + ".";
    el.classList.remove("hidden");
  }

  function hideStatsEngineHint() {
    const el = document.getElementById("stats-engine-hint");
    if (!el) return;
    el.classList.add("hidden");
    el.textContent = "";
  }

  /**
   * Скрыть ряд среды и метрик, показать одну карточку ошибки на всю карточку «Статистика».
   * @param {"Health"|"Статистика"} sourceLabel подпись под «Ошибка»
   */
  function coverStatsCardWithError(sourceLabel, message) {
    hideStatsEngineHint();
    const failureWrap = document.getElementById("stats-failure-wrap");
    const env = document.getElementById("stats-env");
    const metricsGrid = document.getElementById("stats-grid");
    const resCard = document.getElementById("home-resources-summary-card");
    if (failureWrap) {
      failureWrap.classList.remove("hidden");
      failureWrap.replaceChildren();
      const tile = document.createElement("div");
      tile.className = "stats-tile stats-env-card stats-env-card--bad stats-failure-card";
      const valEl = document.createElement("span");
      valEl.className = "stats-tile__value";
      valEl.textContent = "Ошибка";
      const labEl = document.createElement("span");
      labEl.className = "stats-tile__label";
      labEl.textContent = sourceLabel;
      const dEl = document.createElement("span");
      dEl.className = "stats-env-card__detail muted";
      dEl.textContent = message || "";
      tile.appendChild(valEl);
      tile.appendChild(labEl);
      tile.appendChild(dEl);
      failureWrap.appendChild(tile);
    }
    if (env) env.classList.add("hidden");
    if (metricsGrid) metricsGrid.classList.add("hidden");
    if (resCard) resCard.classList.add("hidden");
  }

  /** Показать снова ряд среды и сетку метрик (после успешных запросов). */
  function revealStatsCardBody() {
    const failureWrap = document.getElementById("stats-failure-wrap");
    const env = document.getElementById("stats-env");
    const metricsGrid = document.getElementById("stats-grid");
    const resCard = document.getElementById("home-resources-summary-card");
    if (failureWrap) {
      failureWrap.classList.add("hidden");
      failureWrap.replaceChildren();
    }
    if (env) env.classList.remove("hidden");
    if (metricsGrid) metricsGrid.classList.remove("hidden");
    if (resCard) resCard.classList.remove("hidden");
  }

  /**
   * Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` — первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»).
   */
  function paintStatsEnvRow() {
    const el = document.getElementById("stats-env");
    if (!el) return;
    if (!cachedHealthPayload) {
      return;
    }
    el.className = "stats-env-cards";
    el.replaceChildren();
    const h = cachedHealthPayload;
    const cliRaw = String(h.container_cli || "docker").trim() || "docker";
    const cliTitle = titleCaseContainerCli(cliRaw);
    const apiOk = !!h.container_engine_ok;
    el.appendChild(
      buildStatsEnvCard(
        cliTitle + " API",
        apiOk ? "OK" : "Недоступно",
        apiOk,
        !apiOk && h.container_engine_detail ? String(h.container_engine_detail) : null,
      ),
    );
    const kindOk = !!h.kind_in_path;
    el.appendChild(buildStatsEnvCard("Kind", kindOk ? "OK" : "нет в PATH", kindOk, null));
    const kubectlOk = !!h.kubectl_in_path;
    el.appendChild(
      buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
    );
  }

  async function loadHealth() {
    const el = document.getElementById("stats-env");
    if (!el) return;
    try {
      const h = await api("/health");
      cachedHealthPayload = h;
      revealStatsCardBody();
      paintStatsEnvRow();
      updateStatsEngineHint(h.container_cli);
    } catch (e) {
      cachedHealthPayload = null;
      el.replaceChildren();
      coverStatsCardWithError("Health", e.message || String(e));
    }
  }

  /**
   * Отрисовка донатов в заданный корень (главная: ``#aggregate-donuts``, страница кластера: ``#cluster-page-aggregate-donuts``).
   * @param {HTMLElement | null} root
   * @param {object | null | undefined} agg
   * @param {string | null | undefined} resourcesError
   * @param {string | undefined} containerCliRaw docker/podman для подписи доната RAM
   */
  function renderAggregateDonutsToRoot(root, agg, resourcesError, containerCliRaw) {
    if (!root) return;

    root.replaceChildren();
    if (resourcesError) {
      const p = document.createElement("p");
      p.className = "muted aggregate-donuts-placeholder";
      p.textContent = "Метрики узлов недоступны: " + resourcesError;
      root.appendChild(p);
      return;
    }
    if (!agg || !agg.nodes_count) {
      const p2 = document.createElement("p");
      p2.className = "muted aggregate-donuts-placeholder";
      p2.textContent = "Нет запущенных узлов — диаграммы появятся после старта кластера.";
      root.appendChild(p2);
      return;
    }

    function donutItem(title, ringPct, centerLabel, subLine) {
      const p = Math.max(0, Math.min(100, Number(ringPct) || 0));
      const wrap = document.createElement("div");
      wrap.className = "donut-item";
      wrap.setAttribute("role", "group");
      wrap.setAttribute("aria-label", title);
      const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
      svg.setAttribute("viewBox", "0 0 110 110");
      svg.setAttribute("class", "donut-svg");
      svg.setAttribute("aria-hidden", "true");
      const track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
      track.setAttribute("class", "donut-track");
      track.setAttribute("cx", "55");
      track.setAttribute("cy", "55");
      track.setAttribute("r", "40");
      track.setAttribute("fill", "none");
      track.setAttribute("stroke-width", "9");
      track.setAttribute("pathLength", "100");
      const fill = document.createElementNS("http://www.w3.org/2000/svg", "circle");
      fill.setAttribute("class", "donut-fill");
      fill.setAttribute("cx", "55");
      fill.setAttribute("cy", "55");
      fill.setAttribute("r", "40");
      fill.setAttribute("fill", "none");
      fill.setAttribute("stroke-width", "9");
      fill.setAttribute("pathLength", "100");
      fill.setAttribute("stroke-dasharray", p + " " + (100 - p));
      fill.setAttribute("transform", "rotate(-90 55 55)");
      svg.appendChild(track);
      svg.appendChild(fill);
      wrap.appendChild(svg);
      const meta = document.createElement("div");
      meta.className = "donut-meta";
      const tEl = document.createElement("span");
      tEl.className = "donut-meta__title";
      tEl.textContent = title;
      meta.appendChild(tEl);
      const vEl = document.createElement("span");
      vEl.className = "donut-meta__val";
      vEl.textContent = centerLabel || "—";
      meta.appendChild(vEl);
      if (subLine) {
        const sEl = document.createElement("span");
        sEl.className = "donut-meta__sub muted";
        sEl.textContent = subLine;
        meta.appendChild(sEl);
      }
      wrap.appendChild(meta);
      return wrap;
    }

    const ramEngine = titleCaseContainerCli(containerCliRaw);
    const grid = document.createElement("div");
    grid.className = "donut-grid";
    grid.appendChild(
      donutItem("CPU (ср.)", agg.cpu_ring, String(agg.cpu_label || "—"), "узлов: " + agg.nodes_count),
    );
    grid.appendChild(
      donutItem("% RAM (" + ramEngine + ")", agg.memory_percent_ring, String(agg.memory_percent_label || "—"), ""),
    );
    grid.appendChild(
      donutItem("RAM used/limit", agg.memory_used_ratio_ring, String(agg.memory_used_ratio_label || "—"), ""),
    );
    grid.appendChild(
      donutItem("Сеть (всего)", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"),
    );
    grid.appendChild(
      donutItem("Диск (всего)", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"),
    );
    root.appendChild(grid);
  }

  /**
   * SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»).
   */
  function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
    renderAggregateDonutsToRoot(
      document.getElementById("aggregate-donuts"),
      agg,
      resourcesError,
      containerCliRaw,
    );
  }

  /**
   * Доля из строки вида «12.3%» для мини-полосы (0–100).
   * @param {unknown} value
   * @returns {number | null}
   */
  function parseLoosePercent(value) {
    if (value == null || value === "") return null;
    const m = String(value).match(/([\d.,]+)\s*%/);
    if (!m) return null;
    const x = parseFloat(m[1].replace(",", "."));
    if (Number.isNaN(x)) return null;
    return Math.max(0, Math.min(100, x));
  }

  /** Множители суффиксов Docker/Podman (как в ``container_resource_stats.py``). */
  var DOCKER_SUFFIX_TO_BYTES = {
    b: 1,
    kb: 1e3,
    kib: 1024,
    mb: 1e6,
    mib: Math.pow(1024, 2),
    gb: 1e9,
    gib: Math.pow(1024, 3),
    tb: 1e12,
    tib: Math.pow(1024, 4),
    pb: 1e15,
    pib: Math.pow(1024, 5),
  };

  /**
   * Разбор одного значения вроде ``512MiB``, ``800kB`` в байты.
   * @param {string} chunk
   * @returns {number | null}
   */
  function parseDockerSizeBytes(chunk) {
    const t = String(chunk || "")
      .trim()
      .toLowerCase()
      .replace(/\s/g, "");
    if (!t) return null;
    const m = t.match(/^([\d.]+)([a-z]+)$/);
    if (!m) return null;
    const mult = DOCKER_SUFFIX_TO_BYTES[m[2]];
    if (mult == null) return null;
    const v = parseFloat(m[1]);
    if (Number.isNaN(v)) return null;
    return v * mult;
  }

  /**
   * Сумма байт по NetIO/BlockIO (чтение + запись, обе части до ``/``).
   * @param {string | null | undefined} raw
   * @returns {number | null}
   */
  function parseIoPairSumBytes(raw) {
    if (raw == null || String(raw).indexOf("/") < 0) return null;
    const parts = String(raw).split("/");
    const ba = parseDockerSizeBytes(parts[0]);
    const bb = parseDockerSizeBytes(parts[1]);
    if (ba == null && bb == null) return null;
    return (ba || 0) + (bb || 0);
  }

  /**
   * Процент занятой памяти: левая часть MemUsage / правая (лимит контейнера).
   * @param {string | null | undefined} memoryUsage
   * @returns {number | null}
   */
  function memoryUsedRatioPercent(memoryUsage) {
    if (memoryUsage == null || String(memoryUsage).indexOf("/") < 0) return null;
    const pair = String(memoryUsage).split("/");
    const used = parseDockerSizeBytes(pair[0]);
    const limit = parseDockerSizeBytes(pair[1]);
    if (used == null || limit == null || limit <= 0) return null;
    return Math.min(100, (100 * used) / limit);
  }

  /**
   * Карточка одного узла: все метрики ``docker stats`` полосами, как CPU/RAM.
   * Сеть и диск нормализуются к максимуму среди узлов этого кластера (сравнение нагрузки).
   * @param {object} n KindNodeResourceStat
   * @param {{ netPct: number, blkPct: number }} rel
   */
  function buildClusterResourceNodeCard(n, rel) {
    const card = document.createElement("article");
    card.className = "cluster-node-res-card";
    const head = document.createElement("div");
    head.className = "cluster-node-res-card__head";
    const code = document.createElement("code");
    code.textContent = n.container_name || "—";
    head.appendChild(code);
    card.appendChild(head);

    const bars = document.createElement("div");
    bars.className = "cluster-node-res-card__bars";
    /**
     * @param {string} label
     * @param {number | null | undefined} pct 0–100
     * @param {string} [hint] краткое значение справа
     * @param {string} [rowTitle] подсказка при наведении на строку
     */
    function addBar(label, pct, hint, rowTitle) {
      const row = document.createElement("div");
      row.className = "cluster-node-res-card__bar-row";
      if (rowTitle) row.title = rowTitle;
      const lab = document.createElement("span");
      lab.className = "cluster-node-res-card__bar-label";
      lab.textContent = label;
      const track = document.createElement("div");
      track.className = "cluster-node-res-card__bar-track";
      const fill = document.createElement("div");
      fill.className = "cluster-node-res-card__bar-fill";
      const p = pct != null && !Number.isNaN(pct) ? pct : 0;
      fill.style.width = Math.min(100, Math.max(0, p)) + "%";
      track.appendChild(fill);
      row.appendChild(lab);
      row.appendChild(track);
      const hi = document.createElement("span");
      hi.className = "cluster-node-res-card__bar-hint muted";
      hi.textContent = hint != null && hint !== "" ? hint : "—";
      row.appendChild(hi);
      bars.appendChild(row);
    }

    const cpuP = parseLoosePercent(n.cpu_percent);
    addBar("CPU", cpuP, n.cpu_percent || "—", "Загрузка CPU (docker stats CPUPerc)");

    const ramP = parseLoosePercent(n.memory_percent);
    addBar("RAM %", ramP, n.memory_percent || "—", "Процент памяти от лимита контейнера (MemPerc)");

    const memRatio = memoryUsedRatioPercent(n.memory_usage);
    addBar(
      "Память",
      memRatio,
      n.memory_usage || "—",
      "Использовано / лимит контейнера (MemUsage): доля в процентах",
    );

    addBar(
      "Сеть",
      rel.netPct,
      n.net_io || "—",
      "Суммарный сетевой I/O (вход+выход); полоса относительно максимума среди узлов кластера",
    );
    addBar(
      "Диск",
      rel.blkPct,
      n.block_io || "—",
      "Суммарный блочный I/O; полоса относительно максимума среди узлов кластера",
    );

    card.appendChild(bars);
    return card;
  }

  /**
   * Карточки узлов в контейнер (страница кластера), без ``<details>``.
   * @param {HTMLElement | null} parent
   * @param {object | null | undefined} cr поле ``cluster_resources`` из overview
   */
  function appendClusterNodeCardsRow(parent, cr) {
    if (!parent) return;
    parent.replaceChildren();
    if (!cr || !(cr.nodes && cr.nodes.length)) {
      const p = document.createElement("p");
      p.className = "muted cluster-nodes-resources__empty";
      p.textContent = cr && cr.note ? cr.note : "Нет данных: узлы остановлены или метрики недоступны.";
      parent.appendChild(p);
      return;
    }
    const nodes = cr.nodes;
    const netBytes = nodes.map(function (node) {
      return parseIoPairSumBytes(node.net_io) || 0;
    });
    const blkBytes = nodes.map(function (node) {
      return parseIoPairSumBytes(node.block_io) || 0;
    });
    const maxNet = Math.max(1, Math.max.apply(null, netBytes));
    const maxBlk = Math.max(1, Math.max.apply(null, blkBytes));
    nodes.forEach(function (n, i) {
      parent.appendChild(
        buildClusterResourceNodeCard(n, {
          netPct: (100 * netBytes[i]) / maxNet,
          blkPct: (100 * blkBytes[i]) / maxBlk,
        }),
      );
    });
  }

  /**
   * Карточка мета справа: kind, kubeconfig, состояние, версия образа, workers (сетка плиток).
   * @param {HTMLElement | null} el
   * @param {object} d ответ ``GET /clusters/{name}/overview``
   */
  function paintClusterDetailMeta(el, d) {
    if (!el) return;
    el.replaceChildren();
    const card = document.createElement("div");
    card.className = "cluster-detail-meta-card";
    const grid = document.createElement("div");
    grid.className = "cluster-detail-meta-grid";

    /**
     * Одна плитка: подпись сверху, значение снизу.
     * @param {string} label
     * @param {HTMLElement} valueEl
     * @param {boolean} [fullRow] на всю ширину сетки (последняя плитка при нечётном числе)
     */
    function addTile(label, valueEl, fullRow) {
      const tile = document.createElement("div");
      tile.className = "cluster-meta-tile" + (fullRow ? " cluster-meta-tile--wide" : "");
      const lab = document.createElement("span");
      lab.className = "cluster-meta-tile__label muted";
      lab.textContent = label;
      const val = document.createElement("div");
      val.className = "cluster-meta-tile__value";
      val.appendChild(valueEl);
      tile.appendChild(lab);
      tile.appendChild(val);
      grid.appendChild(tile);
    }

    function badgeYesNo(yes) {
      const s = document.createElement("span");
      s.className = yes ? "badge badge-ok cluster-meta-badge" : "badge cluster-meta-badge";
      s.textContent = yes ? "да" : "нет";
      return s;
    }

    addTile("kind", badgeYesNo(!!d.registered_in_kind));
    addTile("kubeconfig", badgeYesNo(!!d.has_local_kubeconfig));

    const stateBadge = document.createElement("span");
    stateBadge.className = "badge cluster-meta-badge";
    if (d.registered_in_kind) {
      if (d.kind_nodes_running) {
        stateBadge.classList.add("badge-ok");
        stateBadge.textContent = "Запущен";
        stateBadge.title = "Контейнеры узлов запущены";
      } else {
        stateBadge.classList.add("badge-stopped");
        stateBadge.textContent = "Остановлен";
        stateBadge.title = "Зарегистрирован в kind, узлы остановлены";
      }
    } else {
      stateBadge.classList.add("cluster-meta-badge--muted");
      stateBadge.textContent = "—";
      stateBadge.title = "Нет в kind — только локальный каталог";
    }
    addTile("Состояние", stateBadge);

    const ver =
      (d.meta && (d.meta.kubernetes_version_tag || d.meta.node_image)) || "—";
    const codeVer = document.createElement("code");
    codeVer.className = "cluster-meta-version-tag";
    codeVer.textContent = String(ver);
    addTile("Версия образа", codeVer);

    const workersEl = document.createElement("span");
    workersEl.className = "cluster-meta-workers";
    workersEl.textContent =
      d.meta && d.meta.worker_nodes != null ? String(d.meta.worker_nodes) : "—";
    addTile("Workers", workersEl, false);

    card.appendChild(grid);
    el.appendChild(card);
  }

  /**
   * @param {HTMLElement} root
   * @param {string} text
   */
  function clusterK8sShowMessage(root, text) {
    if (!root) return;
    root.replaceChildren();
    const p = document.createElement("p");
    p.className = "cluster-k8s-msg muted";
    p.textContent = text;
    root.appendChild(p);
  }

  /**
   * Ячейка: текст (через textContent) или готовый DOM-элемент (кнопка).
   * @param {HTMLElement} root
   * @param {string[]} headers
   * @param {Array<Array<string | HTMLElement>>} rows
   */
  function clusterK8sBuildTable(root, headers, rows) {
    if (!root) return;
    root.replaceChildren();
    if (!rows.length) {
      clusterK8sShowMessage(root, "Нет объектов.");
      return;
    }
    const wrap = document.createElement("div");
    wrap.className = "cluster-k8s-table-scroll";
    const table = document.createElement("table");
    table.className = "cluster-k8s-table";
    const thead = document.createElement("thead");
    const trh = document.createElement("tr");
    headers.forEach(function (h) {
      const th = document.createElement("th");
      th.textContent = h;
      trh.appendChild(th);
    });
    thead.appendChild(trh);
    table.appendChild(thead);
    const tbody = document.createElement("tbody");
    rows.forEach(function (cells) {
      const tr = document.createElement("tr");
      cells.forEach(function (cell, colIdx) {
        const td = document.createElement("td");
        const lab = headers[colIdx];
        if (lab != null && String(lab).trim() !== "") {
          td.setAttribute("data-label", String(lab));
        }
        if (cell != null && typeof cell === "object" && cell.nodeType === 1) {
          td.appendChild(cell);
        } else {
          td.textContent = cell == null ? "" : String(cell);
        }
        tr.appendChild(td);
      });
      tbody.appendChild(tr);
    });
    table.appendChild(tbody);
    wrap.appendChild(table);
    root.appendChild(wrap);
  }

  /**
   * @param {object | null | undefined} block поле k8s_* из overview
   * @param {string | null} kubeErr
   * @param {HTMLElement | null} root
   * @param {function(HTMLElement, object[]): void} renderRows
   */
  function clusterK8sRenderBlock(block, kubeErr, root, renderRows) {
    if (!root) return;
    if (kubeErr) {
      clusterK8sShowMessage(root, kubeErr);
      return;
    }
    if (!block || !block.ok) {
      clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны.");
      return;
    }
    const items = block.items || [];
    renderRows(root, items);
  }

  /**
   * Блоки K8s на странице кластера с фильтром по namespace (ключ → поле overview → id корня таблицы).
   * Автор: Сергей Антропов — https://devops.org.ru
   */
  var CLUSTER_K8S_NS_BLOCKS = [
    { key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" },
    { key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" },
    { key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" },
    { key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" },
    { key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" },
    { key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" },
    { key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" },
    { key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" },
    { key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" },
    { key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" },
  ];

  /** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */
  var lastClusterK8sOverview = null;
  /** Однократная привязка делегирования change на фильтры namespace. */
  var clusterNsFiltersBound = false;

  /**
   * Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview).
   * @param {object|undefined|null} block
   * @returns {object[]}
   */
  function k8sBlockItemsArray(block) {
    if (!block || typeof block !== "object") return [];
    var items = block.items;
    return Array.isArray(items) ? items : [];
  }

  /**
   * Отбор объектов по выбранному namespace (пустая строка — все).
   * @param {object[]|undefined} items
   * @param {string} ns
   * @returns {object[]}
   */
  function k8sFilterItemsByNamespace(items, ns) {
    if (!ns) return items || [];
    return (items || []).filter(function (it) {
      return ((it.metadata && it.metadata.namespace) || "") === ns;
    });
  }

  /**
   * Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта.
   * Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк).
   * @param {object[]|undefined} items
   * @returns {object[]}
   */
  function k8sSortNamespacedItems(items) {
    if (!items || !items.length) return items || [];
    return items.slice().sort(function (a, b) {
      var nsA = (a.metadata && a.metadata.namespace) || "";
      var nsB = (b.metadata && b.metadata.namespace) || "";
      var c = String(nsA).localeCompare(String(nsB), "ru");
      if (c !== 0) return c;
      var nA = (a.metadata && a.metadata.name) || "";
      var nB = (b.metadata && b.metadata.name) || "";
      return String(nA).localeCompare(String(nB), "ru");
    });
  }

  /**
   * Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace``
   * из таблиц ресурсов (pods/deployments/…); без дублей, сортировка по ru.
   * Автор: Сергей Антропов — https://devops.org.ru
   * @param {object} d ответ cluster overview
   * @returns {string[]}
   */
  function k8sNamespaceNamesFromOverview(d) {
    var seen = Object.create(null);
    var ordered = [];

    function addName(raw) {
      if (raw == null) return;
      var s = String(raw).trim();
      if (!s || seen[s]) return;
      seen[s] = true;
      ordered.push(s);
    }

    var nsBlock = d.k8s_namespaces;
    if (nsBlock && nsBlock.ok) {
      k8sBlockItemsArray(nsBlock).forEach(function (it) {
        var meta = it && it.metadata;
        addName(meta && meta.name);
      });
    }

    for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
      var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop];
      if (!blk || !blk.ok) continue;
      k8sBlockItemsArray(blk).forEach(function (it) {
        var meta = it && it.metadata;
        addName(meta && meta.namespace);
      });
    }

    ordered.sort(function (a, b) {
      return a.localeCompare(b, "ru");
    });
    return ordered;
  }

  /**
   * Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-<key>.
   * @param {object | null | undefined} block
   * @param {string | null} kubeErr
   * @param {HTMLElement | null} root
   * @param {string} filterKey
   * @param {function(HTMLElement, object[]): void} renderFn
   */
  function clusterK8sRenderBlockNs(block, kubeErr, root, filterKey, renderFn) {
    if (!root) return;
    var sel = document.getElementById("cluster-k8s-filter-" + filterKey);
    var ns = sel && sel.value ? sel.value : "";
    if (kubeErr) {
      clusterK8sShowMessage(root, kubeErr);
      return;
    }
    if (!block || !block.ok) {
      clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны.");
      return;
    }
    var items = k8sFilterItemsByNamespace(k8sBlockItemsArray(block), ns);
    items = k8sSortNamespacedItems(items);
    renderFn(root, items);
  }

  /**
   * Заполняет option во всех селектах фильтра; сохраняет прежнее value, если namespace ещё в списке.
   * @param {string[]} namespaceNames
   */
  function clusterK8sPopulateNsSelects(namespaceNames) {
    CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
      var sel = document.getElementById("cluster-k8s-filter-" + row.key);
      if (!sel) return;
      var prev = (sel.value || "").trim();
      sel.replaceChildren();
      var o0 = document.createElement("option");
      o0.value = "";
      o0.textContent = "Все namespace";
      sel.appendChild(o0);
      namespaceNames.forEach(function (name) {
        var o = document.createElement("option");
        o.value = name;
        o.textContent = name;
        sel.appendChild(o);
      });
      if (prev && namespaceNames.indexOf(prev) >= 0) sel.value = prev;
      else sel.value = "";
    });
  }

  function k8sNodeReady(item) {
    const cond = (item.status && item.status.conditions) || [];
    for (var i = 0; i < cond.length; i++) {
      if (cond[i].type === "Ready") return cond[i].status === "True" ? "True" : "False";
    }
    return "—";
  }

  function k8sNodeRoles(item) {
    const labels = (item.metadata && item.metadata.labels) || {};
    if (labels["node-role.kubernetes.io/control-plane"] || labels["node-role.kubernetes.io/master"])
      return "control-plane";
    return "worker";
  }

  function k8sNodeInternalIP(item) {
    const addrs = (item.status && item.status.addresses) || [];
    for (var i = 0; i < addrs.length; i++) {
      if (addrs[i].type === "InternalIP") return addrs[i].address || "—";
    }
    return "—";
  }

  function renderK8sNodesTable(root, items) {
    const rows = items.map(function (it) {
      const name = (it.metadata && it.metadata.name) || "—";
      const ver = (it.status && it.status.nodeInfo && it.status.nodeInfo.kubeletVersion) || "—";
      return [name, k8sNodeReady(it), k8sNodeRoles(it), String(ver), k8sNodeInternalIP(it)];
    });
    clusterK8sBuildTable(root, ["Имя", "Ready", "Роль", "Версия", "Internal IP"], rows);
  }

  function k8sPodReadySummary(item) {
    const cst = (item.status && item.status.containerStatuses) || [];
    if (!cst.length) return "—";
    var ready = 0;
    cst.forEach(function (c) {
      if (c.ready) ready++;
    });
    return String(ready) + "/" + String(cst.length);
  }

  /**
   * Рестарт пода: DELETE на API; контроллер пересоздаёт Pod.
   * @param {string} clusterName
   * @param {string} namespace
   * @param {string} podName
   */
  async function restartClusterPod(clusterName, namespace, podName) {
    const ok = await openConfirmModal({
      title: "Перезапустить под?",
      message:
        "Namespace «" +
        namespace +
        "», под «" +
        podName +
        "»: под будет удалён; при наличии контроллера (Deployment и т.д.) будет создан новый экземпляр.",
      confirmLabel: "Рестарт",
      danger: false,
    });
    if (!ok) return;
    try {
      await api("/clusters/" + encodeURIComponent(clusterName) + "/pods/restart", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ namespace: namespace, pod: podName }),
      });
      showToast("Под отправлен на пересоздание", false);
      await loadClusterOverview(clusterName);
    } catch (e) {
      showToast(e.message || "Ошибка рестарта", true);
    }
  }

  function renderK8sPodsTable(root, items, clusterName) {
    const tip =
      "Перезапуск пода: удаление; при наличии контроллера (Deployment и т.д.) будет создан новый Pod.";
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const ns = meta.namespace || "—";
      const name = meta.name || "—";
      const phase = (it.status && it.status.phase) || "—";
      const node = (it.spec && it.spec.nodeName) || "—";
      const host = document.createElement("span");
      host.className = "icon-tooltip-host";
      host.setAttribute("data-tooltip", tip);
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = "icon-btn icon-btn--secondary btn-k8s-restart--solo";
      btn.innerHTML = ICONS.restart;
      btn.setAttribute("aria-label", tip);
      btn.addEventListener("click", function () {
        restartClusterPod(clusterName, ns, name);
      });
      host.appendChild(btn);
      return [ns, name, k8sPodReadySummary(it), String(phase), String(node), host];
    });
    clusterK8sBuildTable(root, ["Namespace", "Под", "Ready", "Фаза", "Узел", ""], rows);
    bindActionTooltipHosts(root);
  }

  function renderK8sNamespacesTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const st = it.status || {};
      return [meta.name || "—", String(st.phase || "—")];
    });
    clusterK8sBuildTable(root, ["Имя", "Фаза"], rows);
  }

  function renderK8sReplicaSetsTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const st = it.status || {};
      const spec = it.spec || {};
      const want = spec.replicas != null ? spec.replicas : "—";
      const cur = st.replicas != null ? st.replicas : 0;
      const ready = st.readyReplicas != null ? st.readyReplicas : 0;
      return [
        meta.namespace || "—",
        meta.name || "—",
        String(ready) + " / " + String(cur) + " / " + String(want),
      ];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready / Текущие / Задано"], rows);
  }

  function renderK8sJobsTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const st = it.status || {};
      const spec = it.spec || {};
      const want = spec.completions != null ? spec.completions : "—";
      const succ = st.succeeded != null ? st.succeeded : 0;
      const act = st.active != null ? st.active : 0;
      const fail = st.failed != null ? st.failed : 0;
      return [
        meta.namespace || "—",
        meta.name || "—",
        String(succ) + " / " + String(want),
        "активн.:" + act + ", неудач:" + fail,
      ];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Успешно / всего", "Активные / неудач"], rows);
  }

  function renderK8sCronJobsTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const spec = it.spec || {};
      const susp = spec.suspend ? "да" : "нет";
      return [meta.namespace || "—", meta.name || "—", String(spec.schedule || "—"), susp];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Schedule", "Suspend"], rows);
  }

  function renderK8sPvcsTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const st = it.status || {};
      const spec = it.spec || {};
      const req = spec.resources && spec.resources.requests && spec.resources.requests.storage;
      const cap = st.capacity && st.capacity.storage;
      const size = cap || req || "—";
      return [meta.namespace || "—", meta.name || "—", String(st.phase || "—"), String(size)];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Фаза", "Размер"], rows);
  }

  function renderK8sDeploymentsTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const st = it.status || {};
      const spec = it.spec || {};
      const want = spec.replicas != null ? spec.replicas : "—";
      const ready = st.readyReplicas != null ? st.readyReplicas : 0;
      return [
        meta.namespace || "—",
        meta.name || "—",
        String(ready) + " / " + String(want),
        String(st.updatedReplicas != null ? st.updatedReplicas : "—"),
      ];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready", "Up-to-date"], rows);
  }

  function renderK8sStsDsTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const st = it.status || {};
      const spec = it.spec || {};
      const want = spec.replicas != null ? spec.replicas : "—";
      const ready = st.readyReplicas != null ? st.readyReplicas : 0;
      return [meta.namespace || "—", meta.name || "—", String(ready) + " / " + String(want)];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Ready"], rows);
  }

  function renderK8sServicesTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const spec = it.spec || {};
      const st = spec.type || "—";
      const ports = (spec.ports || [])
        .map(function (p) {
          return (p.port || "") + (p.protocol ? "/" + p.protocol : "");
        })
        .join(", ");
      return [meta.namespace || "—", meta.name || "—", String(st), ports || "—"];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Тип", "Порты"], rows);
  }

  function renderK8sIngressesTable(root, items) {
    const rows = items.map(function (it) {
      const meta = it.metadata || {};
      const rules = (it.spec && it.spec.rules) || [];
      const hosts = rules
        .map(function (r) {
          return r.host || "";
        })
        .filter(Boolean)
        .join(", ");
      return [meta.namespace || "—", meta.name || "—", hosts || "—"];
    });
    clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows);
  }

  /**
   * Фабрика функций рендера по ключу фильтра (для подов нужен clusterName).
   * @param {string} cname
   * @returns {Record<string, function(HTMLElement, object[]): void>}
   */
  function buildClusterK8sNsFilterRenderMap(cname) {
    return {
      pods: function (root, items) {
        renderK8sPodsTable(root, items, cname);
      },
      deployments: renderK8sDeploymentsTable,
      statefulsets: renderK8sStsDsTable,
      daemonsets: renderK8sStsDsTable,
      replicasets: renderK8sReplicaSetsTable,
      jobs: renderK8sJobsTable,
      cronjobs: renderK8sCronJobsTable,
      services: renderK8sServicesTable,
      ingresses: renderK8sIngressesTable,
      pvcs: renderK8sPvcsTable,
    };
  }

  /**
   * Перерисовка одной таблицы после смены select (без нового API-запроса).
   * @param {string} key ключ data-cluster-k8s-ns-filter
   */
  function refillClusterK8sNsFilter(key) {
    if (!lastClusterK8sOverview) return;
    var d = lastClusterK8sOverview;
    var kubeErr = d.kubeconfig_error || null;
    var cname = d.cluster_name || getClusterPageName() || "";
    var renderByKey = buildClusterK8sNsFilterRenderMap(cname);
    var renderFn = renderByKey[key];
    if (!renderFn) return;
    for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
      if (CLUSTER_K8S_NS_BLOCKS[i].key !== key) continue;
      var row = CLUSTER_K8S_NS_BLOCKS[i];
      clusterK8sRenderBlockNs(d[row.prop], kubeErr, document.getElementById(row.rootId), key, renderFn);
      return;
    }
  }

  /** Делегирование: один listener на document для всех .cluster-k8s-ns-select. */
  function ensureClusterNsFilterListeners() {
    if (clusterNsFiltersBound) return;
    document.addEventListener("change", function (ev) {
      var t = ev.target;
      if (!t || !t.matches || !t.matches("select.cluster-k8s-ns-select[data-cluster-k8s-ns-filter]")) return;
      var key = t.getAttribute("data-cluster-k8s-ns-filter");
      if (key) refillClusterK8sNsFilter(key);
    });
    clusterNsFiltersBound = true;
  }

  /**
   * Таблицы Kubernetes из полей ``k8s_*`` ответа overview.
   * @param {object} d
   */
  function fillClusterK8sTablesFromOverview(d) {
    lastClusterK8sOverview = d;
    const kubeErr = d.kubeconfig_error || null;
    const cname = d.cluster_name || getClusterPageName() || "";
    ensureClusterNsFilterListeners();

    clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
    clusterK8sRenderBlock(
      d.k8s_namespaces,
      kubeErr,
      document.getElementById("cluster-k8s-namespaces-root"),
      renderK8sNamespacesTable,
    );

    const renderByKey = buildClusterK8sNsFilterRenderMap(cname);
    CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
      clusterK8sRenderBlockNs(
        d[row.prop],
        kubeErr,
        document.getElementById(row.rootId),
        row.key,
        renderByKey[row.key],
      );
    });

    /* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */
    clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d));
  }

  /**
   * Кнопки действий над кластером — те же, что в таблице на главной.
   * @param {HTMLElement} host
   * @param {{ name: string, registered_in_kind: boolean, kind_nodes_running: boolean, has_local_kubeconfig: boolean, has_provision_log?: boolean }} c
   */
  function appendClusterActionButtons(host, c) {
    host.replaceChildren();
    const name = c.name;
    host.appendChild(
      iconActionButton(
        ICONS.state,
        "Состояние: узлы и поды",
        "",
        function () {
          openWorkloadsModal(name);
        },
        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(
          ICONS.stop,
          "Стоп: остановить узлы (docker/podman stop), запись кластера в kind сохраняется",
          "icon-btn--secondary",
          function () {
            stopCluster(name);
          },
          false,
        ),
      );
    } else {
      host.appendChild(
        iconActionButton(
          ICONS.play,
          "Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
          "",
          function () {
            startCluster(name);
          },
          false,
        ),
      );
    }
    host.appendChild(
      iconActionButton(
        ICONS.download,
        c.has_local_kubeconfig
          ? "Скачать kubeconfig для kubectl на хосте (localhost:порт)"
          : "Kubeconfig ещё нет — появится после создания или подъёма кластера",
        "icon-btn--secondary",
        c.has_local_kubeconfig
          ? function () {
              downloadKubeconfig(name);
            }
          : null,
        !c.has_local_kubeconfig,
      ),
    );
    host.appendChild(
      iconActionButton(
        ICONS.kubeDocker,
        c.has_local_kubeconfig
          ? "Скачать kubeconfig для kubectl из другого контейнера: host.docker.internal:порт + tls-server-name (как в веб-UI; на Linux — extra_hosts host-gateway)"
          : "Kubeconfig ещё нет — появится после создания или подъёма кластера",
        "icon-btn--secondary",
        c.has_local_kubeconfig
          ? function () {
              downloadKubeconfigDocker(name);
            }
          : null,
        !c.has_local_kubeconfig,
      ),
    );
    if (c.has_provision_log) {
      host.appendChild(
        iconActionButton(
          ICONS.logFile,
          "Журнал развёртывания: полный JSON последнего создания или старта кластера",
          "icon-btn--secondary",
          function () {
            openProvisionLogModal(name);
          },
          false,
        ),
      );
    }
    host.appendChild(
      iconActionButton(
        ICONS.trash,
        "Удалить кластер (kind delete) и каталог clusters/<имя>/",
        "icon-btn--danger",
        function () {
          deleteCluster(name);
        },
        false,
      ),
    );
    bindActionTooltipHosts(host);
  }

  /**
   * Скрыть секцию «Установленные аддоны» (#cluster-detail-addons-section) и очистить сетку.
   */
  function clearClusterDetailAddonsStrip() {
    var strip = document.getElementById("cluster-detail-addons-strip");
    var section = document.getElementById("cluster-detail-addons-section");
    if (strip) strip.replaceChildren();
    if (section) {
      section.classList.add("hidden");
      section.setAttribute("hidden", "");
      section.setAttribute("aria-hidden", "true");
    }
  }

  /**
   * Мини-карточки установленных Helm-аддонов в отдельной section.card после «Ресурсы узлов».
   * @param {string} clusterName
   * @param {object} st ответ GET /clusters/…/addons/status
   */
  function renderClusterDetailAddonsStrip(clusterName, st) {
    var strip = document.getElementById("cluster-detail-addons-strip");
    var section = document.getElementById("cluster-detail-addons-section");
    if (!strip || !section || !st) {
      clearClusterDetailAddonsStrip();
      return;
    }
    var items = [];
    if (st.ingress_nginx) {
      items.push({
        title: "ingress-nginx",
        ver: st.ingress_nginx_chart_version,
        ns: "ingress-nginx",
      });
    }
    if (st.kube_prometheus_stack) {
      items.push({
        title: "kube-prometheus-stack",
        ver: st.kube_prometheus_stack_chart_version,
        ns: "monitoring",
      });
    }
    if (st.metrics_server) {
      items.push({
        title: "metrics-server",
        ver: st.metrics_server_chart_version,
        ns: "kube-system",
      });
    }
    if (st.istio_mesh_ready) {
      var iv = st.istiod_chart_version || st.istio_base_chart_version;
      var kv = st.kiali_server_chart_version;
      var parts = [];
      if (iv) parts.push("Istio " + iv);
      if (kv) parts.push("Kiali " + kv);
      items.push({
        title: "Istio + Kiali",
        verLine: parts.length ? parts.join(" · ") : null,
        ns: "istio-system",
      });
    }
    strip.replaceChildren();
    if (!items.length) {
      section.classList.add("hidden");
      section.setAttribute("hidden", "");
      section.setAttribute("aria-hidden", "true");
      return;
    }
    section.classList.remove("hidden");
    section.removeAttribute("hidden");
    section.setAttribute("aria-hidden", "false");
    items.forEach(function (it) {
      var a = document.createElement("a");
      a.className = "cluster-detail-addon-mini";
      a.href = "/cluster-addons";
      a.setAttribute("role", "listitem");
      a.setAttribute(
        "title",
        "Открыть страницу аддонов Helm; выберите кластер «" + clusterName + "» для настройки.",
      );
      var t = document.createElement("span");
      t.className = "cluster-detail-addon-mini__title";
      t.textContent = it.title;
      a.appendChild(t);
      var v = document.createElement("span");
      v.className = "cluster-detail-addon-mini__ver";
      if (it.verLine) {
        v.textContent = it.verLine;
      } else if (it.ver) {
        v.textContent = "Чарт " + it.ver;
      } else {
        v.textContent = "Версия чарта не определена";
      }
      a.appendChild(v);
      var n = document.createElement("span");
      n.className = "cluster-detail-addon-mini__ns";
      n.textContent = "ns: " + it.ns;
      a.appendChild(n);
      strip.appendChild(a);
    });
  }

  /**
   * Загрузить статус аддонов и обновить карточку «Установленные аддоны» (при наличии kubeconfig на диске).
   * @param {string} clusterName
   * @param {boolean} hasLocalKubeconfig
   */
  async function refreshClusterDetailAddonsStrip(clusterName, hasLocalKubeconfig) {
    if (!clusterName || !hasLocalKubeconfig) {
      clearClusterDetailAddonsStrip();
      return;
    }
    try {
      var st = await api("/clusters/" + encodeURIComponent(clusterName) + "/addons/status");
      renderClusterDetailAddonsStrip(clusterName, st);
    } catch (e) {
      clearClusterDetailAddonsStrip();
    }
  }

  /**
   * Загрузка ``GET /clusters/{name}/overview`` и заполнение блоков страницы кластера.
   * @param {string} name
   */
  async function loadClusterOverview(name) {
    const msg = document.getElementById("cluster-overview-msg");
    const hint = document.getElementById("cluster-kube-global-hint");
    try {
      const d = await api("/clusters/" + encodeURIComponent(name) + "/overview");
      if (msg) msg.textContent = "";
      if (d.cluster_resources && d.cluster_resources.cluster_name) {
        cachedClusterResourcesByName[d.cluster_resources.cluster_name] = d.cluster_resources;
      }
      paintClusterDetailMeta(document.getElementById("cluster-detail-meta"), d);
      appendClusterActionButtons(document.getElementById("cluster-detail-toolbar"), {
        name: d.cluster_name,
        registered_in_kind: !!d.registered_in_kind,
        kind_nodes_running: !!d.kind_nodes_running,
        has_local_kubeconfig: !!d.has_local_kubeconfig,
        has_provision_log: !!d.has_provision_log,
      });
      await refreshClusterDetailAddonsStrip(d.cluster_name, !!d.has_local_kubeconfig);
      const cliRaw =
        (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
      renderAggregateDonutsToRoot(
        document.getElementById("cluster-page-aggregate-donuts"),
        d.aggregate_resources,
        d.resources_error,
        cliRaw,
      );
      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);
      clearClusterDetailAddonsStrip();
      if (msg) {
        msg.textContent =
          "Кластер или API недоступны: " +
          errText +
          ". Проверьте сеть, имя кластера и что узлы kind запущены.";
      }
      if (hint) {
        hint.textContent = "";
        hint.classList.add("hidden");
      }
      [
        "cluster-k8s-nodes-root",
        "cluster-k8s-namespaces-root",
        "cluster-k8s-pods-root",
        "cluster-k8s-deployments-root",
        "cluster-k8s-statefulsets-root",
        "cluster-k8s-daemonsets-root",
        "cluster-k8s-replicasets-root",
        "cluster-k8s-jobs-root",
        "cluster-k8s-cronjobs-root",
        "cluster-k8s-services-root",
        "cluster-k8s-ingresses-root",
        "cluster-k8s-pvcs-root",
      ].forEach(function (id) {
        const el = document.getElementById(id);
        if (el) {
          clusterK8sShowMessage(
            el,
            "Данные Kubernetes недоступны (ошибка загрузки сводки). " + errText,
          );
        }
      });
    } finally {
      hideClusterPageLoadingOverlay();
    }
  }

  /** После старт/стоп/создания — обновить страницу кластера, если открыта та же запись. */
  async function refreshClusterPageIfNeeded(affectedClusterName) {
    if (!isClusterPage() || !affectedClusterName) return;
    const n = getClusterPageName();
    if (n && n === affectedClusterName) {
      await loadClusterOverview(n);
    }
  }

  async function loadStats() {
    const grid = document.getElementById("stats-grid");
    const donutsRoot = document.getElementById("aggregate-donuts");
    const needGrid = !!grid;
    const needDonuts = !!donutsRoot;
    if (!needGrid && !needDonuts) return;
    try {
      const s = await api("/stats");
      if (needGrid) {
        revealStatsCardBody();
        paintStatsEnvRow();
        const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
        updateStatsEngineHint(cliForHint);
        const dirVal =
          s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined
            ? String(s.local_cluster_dirs_count)
            : "—";
        const rows = [
          ["Каталогов", dirVal],
          ["Кластеров в kind", s.kind_clusters_count],
          ["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
          ["Заданий в памяти", s.jobs_total],
          ["Заданий с ошибкой", s.jobs_recent_failed],
          ["Аддонов", s.helm_addons_installable_count ?? 0],
        ];
        grid.replaceChildren();
        rows.forEach(function (kv) {
          const tile = document.createElement("div");
          tile.className = "stats-tile";
          const valEl = document.createElement("span");
          valEl.className = "stats-tile__value";
          valEl.textContent = String(kv[1]);
          const labEl = document.createElement("span");
          labEl.className = "stats-tile__label";
          labEl.textContent = kv[0];
          tile.appendChild(valEl);
          tile.appendChild(labEl);
          grid.appendChild(tile);
        });
      }
      cachedClusterResourcesByName = {};
      if (needDonuts) {
        renderAggregateDonuts(
          s.aggregate_cluster_resources,
          s.cluster_resources_error,
          s.container_cli || "docker",
        );
      }
    } catch (e) {
      if (needGrid) {
        coverStatsCardWithError("Статистика", e.message || String(e));
        grid.replaceChildren();
      }
      cachedClusterResourcesByName = {};
      const donuts = document.getElementById("aggregate-donuts");
      if (donuts) {
        donuts.replaceChildren();
        const ph = document.createElement("p");
        ph.className = "muted aggregate-donuts-placeholder";
        ph.textContent = "Сводка по узлам недоступна (ошибка загрузки статистики).";
        donuts.appendChild(ph);
      }
    }
  }

  async function loadVersions() {
    const sel = document.getElementById("version-select");
    const verInput = document.getElementById("kubernetes_version");
    if (!sel || !verInput) return;
    cachedKindestTags = null;
    cachedKindestVersionsLoadError = false;
    cachedKindestTagsSkipped = false;
    sel.innerHTML = "<option value=\"\">— загрузка —</option>";
    try {
      const data = await api("/versions");
      cachedKindestTagsSkipped = !!data.skipped;
      sel.innerHTML = "";
      if (!data.tags || !data.tags.length) {
        cachedKindestTags = [];
        sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
        return;
      }
      cachedKindestTags = data.tags.slice();
      const opt0 = document.createElement("option");
      opt0.value = "";
      opt0.textContent = "— выберите тег —";
      sel.appendChild(opt0);
      /* Весь список с бэкенда: первым идёт latest, далее семверы от новых к старым (раньше slice(0,100) отрезал 1.19 и др.) */
      data.tags.forEach(function (t) {
        const o = document.createElement("option");
        o.value = t;
        o.textContent =
          t === "latest" ? "latest (самый новый образ kindest/node)" : t;
        sel.appendChild(o);
      });
      sel.onchange = function () {
        if (!sel.value) return;
        verInput.value = sel.value === "latest" ? "latest" : sel.value.replace(/^v/, "");
      };
    } catch (e) {
      cachedKindestVersionsLoadError = true;
      cachedKindestTags = null;
      sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
    }
  }

  /**
   * Имя кластера: DNS-подмножество, как на бэкенде (a-z0-9-, не длиннее 63).
   * @param {string} name
   */
  function isValidClusterName(name) {
    const s = String(name || "").trim();
    if (!s || s.length > 63) return false;
    return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(s);
  }

  /**
   * Число worker-нод 0…20; иначе null.
   * @param {unknown} raw
   * @returns {number | null}
   */
  function parseWorkersBounded(raw) {
    const n = parseInt(String(raw === undefined || raw === null ? "" : raw), 10);
    if (Number.isNaN(n) || n < 0 || n > 20) return null;
    return n;
  }

  /**
   * Нормализация тега kindest/node для сравнения с каталогом (latest или vX.Y.Z).
   * @param {string} raw
   */
  function normalizeKindestTagForLookup(raw) {
    let s = String(raw || "").trim().toLowerCase();
    if (!s) return "";
    if (s === "latest" || s === "vlatest") return "latest";
    s = s.replace(/^v/, "");
    return "v" + s;
  }

  /**
   * Проверка поля версии перед POST /clusters: каталог доступен и тег в списке.
   * @param {string} kubernetesVersionRaw
   * @returns {{ ok: true } | { ok: false, modalTitle: string, modalMessage: string }}
   */
  function validateCreateFormKubernetesVersion(kubernetesVersionRaw) {
    const normalized = normalizeKindestTagForLookup(kubernetesVersionRaw);
    if (!normalized) {
      return {
        ok: false,
        modalTitle: "Нет версии Kubernetes",
        modalMessage:
          "Заполните поле «Версия Kubernetes / тег node» (например 1.29.4, v1.29.4 или latest) или выберите значение в списке «Тег образа».",
      };
    }

    if (cachedKindestVersionsLoadError) {
      return {
        ok: false,
        modalTitle: "Каталог тегов недоступен",
        modalMessage:
          "Не удалось загрузить список тегов kindest/node с сервера. Проверьте соединение и обновите страницу. Пока каталог не загружен, нельзя убедиться, что образ существует.",
      };
    }

    if (cachedKindestTags === null) {
      return {
        ok: false,
        modalTitle: "Список тегов загружается",
        modalMessage:
          "Подождите, пока в поле «Тег образа» завершится загрузка (не должно оставаться «— загрузка —»), и повторите отправку формы.",
      };
    }

    if (!cachedKindestTags.length) {
      const skipHint = cachedKindestTagsSkipped
        ? " На сервере отключена выдача списка (переменная KIND_K8S_SKIP_VERSION_LIST)."
        : "";
      return {
        ok: false,
        modalTitle: "Каталог тегов недоступен",
        modalMessage:
          "Приложение не получило ни одного тега kindest/node для проверки." +
          skipHint +
          " Создание кластера с непроверенным тегом отключено. Включите загрузку каталога или обратитесь к администратору.",
      };
    }

    const canonSet = new Set(cachedKindestTags.map(normalizeKindestTagForLookup));
    if (canonSet.has(normalized)) {
      return { ok: true };
    }

    return {
      ok: false,
      modalTitle: "Тег не найден в каталоге",
      modalMessage:
        "Значение «" +
        String(kubernetesVersionRaw).trim() +
        "» отсутствует в списке доступных тегов kindest/node в приложении. Выберите тег из списка «Тег образа» или введите поддерживаемый семвер (см. подпись поля и Docker Hub).",
    };
  }

  /**
   * Строка таблицы: версия и workers в ``.cluster-list-value`` — в карточках (<920px) значение по центру правой колонки.
   */
  async function loadClusters() {
    const tbody = document.querySelector("#tbl-clusters tbody");
    const msg = document.getElementById("list-msg");
    if (!tbody) return;
    try {
      const rows = await api("/clusters");
      const frag = document.createDocumentFragment();
      rows.forEach(function (c) {
        const tr = document.createElement("tr");
        const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—";
        const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
        const nameEsc = escapeHtml(c.name);
        var stateCell = "";
        if (c.registered_in_kind) {
          if (c.kind_nodes_running) {
            stateCell =
              "<span class=\"badge badge-ok\" title=\"Контейнеры узлов запущены\">Запущен</span>";
          } else {
            stateCell =
              "<span class=\"badge badge-stopped\" title=\"Зарегистрирован в kind, узлы остановлены\">Остановлен</span>";
          }
        } else {
          stateCell =
            "<span class=\"muted\" title=\"Нет в kind — только локальный каталог или кластер удалён из kind\">—</span>";
        }
        tr.innerHTML =
          "<td 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 class=\"col-kind\">" +
          (c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
          "</td>" +
          "<td class=\"col-kubeconfig\">" +
          (c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
          "</td>" +
          "<td class=\"col-version\"><span class=\"cluster-list-value\">" +
          escapeHtml(String(ver)) +
          "</span></td>" +
          "<td class=\"col-workers\"><span class=\"cluster-list-value\">" +
          escapeHtml(String(wn)) +
          "</span></td>" +
          "<td class=\"col-state\">" +
          stateCell +
          "</td>" +
          "<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
        const td = tr.querySelector(".actions-toolbar");
        appendClusterActionButtons(td, {
          name: c.name,
          registered_in_kind: !!c.registered_in_kind,
          kind_nodes_running: !!c.kind_nodes_running,
          has_local_kubeconfig: !!c.has_local_kubeconfig,
          has_provision_log: !!c.has_provision_log,
        });
        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;
    }
  }

  /**
   * Формат времени как на странице «Журнал» (ISO → «YYYY-MM-DD HH:MM:SS.sss Z»).
   * @param {string} iso
   */
  function formatJournalUtcDisplay(iso) {
    if (!iso) return "—";
    try {
      const d = new Date(iso);
      if (Number.isNaN(d.getTime())) return iso;
      return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
    } catch (e) {
      return iso;
    }
  }

  /**
   * Время для ячейки таблицы: разметка дата + время (при ширине до 920px — колонкой, см. .journal-time-stack в style.css).
   * @param {string} iso
   * @returns {string} безопасный HTML
   */
  function formatJournalUtcDisplayHtml(iso) {
    if (!iso) {
      return escapeHtml("—");
    }
    try {
      const d = new Date(iso);
      if (Number.isNaN(d.getTime())) {
        return escapeHtml(iso);
      }
      const day = d.toISOString().slice(0, 10);
      const clock = d.toISOString().slice(11, 19);
      return (
        '<span class="journal-time-stack">' +
        '<span class="journal-time-date">' +
        escapeHtml(day) +
        "</span>" +
        '<span class="journal-time-clock">' +
        escapeHtml(clock) +
        "</span>" +
        "</span>"
      );
    } catch (e) {
      return escapeHtml(iso);
    }
  }

  /**
   * Класс плашки статуса записи журнала (success / failed / cancelled).
   * @param {string} st
   */
  function journalStatusPillClass(st) {
    const s = (st || "").toLowerCase();
    if (s === "success") return "journal-status-pill journal-status-pill--ok";
    if (s === "failed") return "journal-status-pill journal-status-pill--err";
    if (s === "cancelled") return "journal-status-pill journal-status-pill--warn";
    return "journal-status-pill";
  }

  /**
   * Раскрытие строки лога — как на /journal (режим заданий по кластеру).
   * Автор: Сергей Антропов — https://devops.org.ru
   */
  function bindCreateJobsJournalToggle() {
    const tbl = document.getElementById("tbl-jobs");
    if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return;
    tbl.setAttribute("data-create-jobs-log-bound", "1");
    tbl.addEventListener(
      "click",
      function (ev) {
        const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null;
        if (!btn || !tbl.contains(btn) || btn.disabled) return;
        const tr = btn.closest("tr.journal-data-row");
        if (!tr) return;
        const trDetails = tr.nextElementSibling;
        if (!trDetails || !trDetails.classList.contains("journal-details-row")) return;
        const pre = trDetails.querySelector("pre.journal-log-pre");
        if (!pre) return;
        const open = trDetails.classList.contains("hidden");
        trDetails.classList.toggle("hidden", !open);
        pre.hidden = !open;
        btn.setAttribute("aria-expanded", open ? "true" : "false");
        btn.textContent = open ? "Скрыть лог" : "Показать лог";
      },
      false,
    );
  }

  /**
   * Стабильный ключ строки журнала на странице создания (сверка DOM ↔ API при инкрементальном обновлении).
   * @param {Record<string, unknown>} e запись из API
   */
  function createJobJournalRowKey(e) {
    const jid = String(e.job_id || "").trim();
    if (jid) return jid;
    return (
      "legacy:" +
      String(e.finished_at_utc || e.created_at_utc || "") +
      ":" +
      String(e.source_cluster || e.cluster_name || "") +
      ":" +
      String(e.kind || "")
    );
  }

  /**
   * Собирает ключи строк с раскрытым логом — только для полной перерисовки при рассинхроне.
   * @param {HTMLElement | null} tbody
   * @returns {Set<string>}
   */
  function getExpandedCreateJobsRowKeys(tbody) {
    const keys = new Set();
    if (!tbody) return keys;
    tbody.querySelectorAll("tr.journal-details-row:not(.hidden)").forEach(function (trDetails) {
      const tr = trDetails.previousElementSibling;
      if (!tr || !tr.classList.contains("journal-data-row")) return;
      const k = tr.getAttribute("data-journal-row-key");
      if (k) keys.add(k);
    });
    return keys;
  }

  /**
   * Восстанавливает раскрытие после полной замены tbody (fallback).
   * @param {HTMLElement} tbody
   * @param {Set<string>} expandedKeys
   */
  function restoreExpandedCreateJobsRows(tbody, expandedKeys) {
    if (!expandedKeys.size) return;
    let tr = tbody.firstElementChild;
    while (tr) {
      if (tr.classList.contains("journal-data-row")) {
        const k = tr.getAttribute("data-journal-row-key");
        const next = tr.nextElementSibling;
        if (
          k &&
          expandedKeys.has(k) &&
          next &&
          next.classList.contains("journal-details-row")
        ) {
          next.classList.remove("hidden");
          const pre = next.querySelector("pre.journal-log-pre");
          if (pre) pre.hidden = false;
          const btn = tr.querySelector("button.btn-journal-toggle");
          if (btn && !btn.disabled) {
            btn.setAttribute("aria-expanded", "true");
            btn.textContent = "Скрыть лог";
          }
        }
      }
      tr = tr.nextElementSibling;
    }
  }

  /**
   * Пара строк «данные + опционально лог» для таблицы заданий на /cluster-create.
   * @param {Record<string, unknown>} e
   * @returns {DocumentFragment}
   */
  function buildCreateJobRowFragment(e) {
    const frag = document.createDocumentFragment();
    const tr = document.createElement("tr");
    tr.className = "journal-data-row";
    tr.setAttribute("data-journal-row-key", createJobJournalRowKey(e));
    const cluster = e.source_cluster || e.cluster_name || "—";
    const finishedHtml = formatJournalUtcDisplayHtml(e.finished_at_utc || e.created_at_utc);
    const kind = e.kind || "";
    const message = (e.message || "").trim();
    const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
    const logText = lines.join("\n");
    const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);

    tr.innerHTML =
      "<td class=\"journal-cell-time create-jobs-card-td create-jobs-card-td--time\">" +
      finishedHtml +
      "</td>" +
      "<td class=\"create-jobs-card-td create-jobs-card-td--cluster\"><code class=\"journal-code\">" +
      escapeHtml(cluster) +
      "</code></td>" +
      "<td class=\"create-jobs-card-td create-jobs-card-td--kind\"><code class=\"journal-code journal-cell-kind\">" +
      escapeHtml(kind) +
      "</code></td>" +
      "<td class=\"create-jobs-card-td create-jobs-card-td--status\"><span class=\"" +
      escapeHtml(journalStatusPillClass(e.status)) +
      "\">" +
      escapeHtml(e.status || "") +
      "</span></td>" +
      "<td class=\"journal-cell-msg create-jobs-card-td create-jobs-card-td--msg\"><span class=\"create-jobs-msg-value\">" +
      escapeHtml(message || "—") +
      "</span></td>" +
      "<td class=\"journal-cell-actions create-jobs-card-td create-jobs-card-td--log\">" +
      "<button type=\"button\" class=\"btn-secondary btn-journal-toggle\" aria-expanded=\"false\" aria-controls=\"" +
      escapeHtml(detailsId) +
      "\">Показать лог</button>" +
      "</td>";

    frag.appendChild(tr);

    if (logText) {
      const trDetails = document.createElement("tr");
      trDetails.className = "journal-details-row hidden";
      trDetails.innerHTML =
        "<td colspan=\"6\" class=\"journal-details-cell\">" +
        "<pre id=\"" +
        escapeHtml(detailsId) +
        "\" class=\"mono journal-log-pre\" hidden></pre>" +
        "</td>";
      const pre = trDetails.querySelector("pre");
      if (pre) pre.textContent = logText;
      frag.appendChild(trDetails);
    } else {
      const btn = tr.querySelector(".btn-journal-toggle");
      if (btn) {
        btn.disabled = true;
        btn.textContent = "Нет строк";
      }
    }
    return frag;
  }

  /** Первая строка данных журнала в tbody или null. */
  function getFirstCreateJobsDataRow(tbody) {
    let tr = tbody.firstElementChild;
    while (tr) {
      if (tr.classList.contains("journal-data-row")) return tr;
      tr = tr.nextElementSibling;
    }
    return null;
  }

  /**
   * Пары (строка данных, строка лога или null) сверху вниз.
   * @param {HTMLElement} tbody
   * @returns {{ dataRow: HTMLElement, detailsRow: HTMLElement | null }[]}
   */
  function collectCreateJobsRowPairs(tbody) {
    const pairs = [];
    let tr = tbody.firstElementChild;
    while (tr) {
      if (!tr.classList.contains("journal-data-row")) {
        tr = tr.nextElementSibling;
        continue;
      }
      const dataRow = tr;
      const nx = dataRow.nextElementSibling;
      let detailsRow = null;
      if (nx && nx.classList.contains("journal-details-row")) {
        detailsRow = nx;
        tr = nx.nextElementSibling;
      } else {
        tr = nx;
      }
      pairs.push({ dataRow: dataRow, detailsRow: detailsRow });
    }
    return pairs;
  }

  /** Удаляет последнюю пару строк (хвост при сдвиге окна limit). */
  function removeLastCreateJobsPair(tbody) {
    const pairs = collectCreateJobsRowPairs(tbody);
    if (!pairs.length) return;
    const last = pairs[pairs.length - 1];
    if (last.detailsRow) last.detailsRow.remove();
    last.dataRow.remove();
  }

  /**
   * Вставляет сверху записи из API, пока не встретится ключ текущей верхней строки (новые — только префикс списка).
   * @param {HTMLElement} tbody
   * @param {Record<string, unknown>[]} entries
   */
  function prependNewCreateJobEntries(tbody, entries) {
    const first = getFirstCreateJobsDataRow(tbody);
    const firstKey = first ? first.getAttribute("data-journal-row-key") : null;
    const toPrepend = [];
    for (let i = 0; i < entries.length; i++) {
      const k = createJobJournalRowKey(entries[i]);
      if (firstKey !== null && k === firstKey) break;
      toPrepend.push(entries[i]);
    }
    for (let i = toPrepend.length - 1; i >= 0; i--) {
      tbody.insertBefore(buildCreateJobRowFragment(toPrepend[i]), tbody.firstChild);
    }
  }

  /** Оставляет не больше targetCount пар строк. */
  function trimCreateJobsTail(tbody, targetCount) {
    while (collectCreateJobsRowPairs(tbody).length > targetCount) {
      removeLastCreateJobsPair(tbody);
    }
  }

  /**
   * Обновляет ячейки и текст лога без замены узлов — раскрытие и скролл не сбрасываются.
   * @param {{ dataRow: HTMLElement, detailsRow: HTMLElement | null }} pair
   * @param {Record<string, unknown>} e
   */
  function updateCreateJobPairFromEntry(pair, e) {
    const cluster = e.source_cluster || e.cluster_name || "—";
    const finishedHtml = formatJournalUtcDisplayHtml(e.finished_at_utc || e.created_at_utc);
    const kind = e.kind || "";
    const message = (e.message || "").trim();
    const lines = Array.isArray(e.log_lines) ? e.log_lines : [];
    const logText = lines.join("\n");

    const timeTd = pair.dataRow.querySelector(".create-jobs-card-td--time");
    if (timeTd) timeTd.innerHTML = finishedHtml;
    const clusterCode = pair.dataRow.querySelector(".create-jobs-card-td--cluster .journal-code");
    if (clusterCode) clusterCode.textContent = cluster;
    const kindCode = pair.dataRow.querySelector(".journal-cell-kind");
    if (kindCode) kindCode.textContent = kind;
    const statusWrap = pair.dataRow.querySelector(".create-jobs-card-td--status span");
    if (statusWrap) {
      statusWrap.className = journalStatusPillClass(e.status);
      statusWrap.textContent = e.status || "";
    }
    const msgSpan = pair.dataRow.querySelector(".create-jobs-msg-value");
    if (msgSpan) msgSpan.textContent = message || "—";

    const btn = pair.dataRow.querySelector(".btn-journal-toggle");
    if (logText) {
      if (btn) {
        btn.disabled = false;
        const expanded = btn.getAttribute("aria-expanded") === "true";
        btn.textContent = expanded ? "Скрыть лог" : "Показать лог";
      }
      if (pair.detailsRow) {
        const pre = pair.detailsRow.querySelector("pre.journal-log-pre");
        if (pre) pre.textContent = logText;
      } else {
        const detailsId = "create-jobs-log-" + Math.random().toString(36).slice(2, 11);
        const trDetails = document.createElement("tr");
        trDetails.className = "journal-details-row hidden";
        trDetails.innerHTML =
          "<td colspan=\"6\" class=\"journal-details-cell\">" +
          "<pre id=\"" +
          escapeHtml(detailsId) +
          "\" class=\"mono journal-log-pre\" hidden></pre>" +
          "</td>";
        const pre = trDetails.querySelector("pre");
        if (pre) pre.textContent = logText;
        pair.dataRow.insertAdjacentElement("afterend", trDetails);
        pair.detailsRow = trDetails;
        if (btn) {
          btn.setAttribute("aria-controls", detailsId);
          btn.setAttribute("aria-expanded", "false");
          btn.textContent = "Показать лог";
        }
      }
    } else {
      if (pair.detailsRow) {
        pair.detailsRow.remove();
        pair.detailsRow = null;
      }
      if (btn) {
        btn.disabled = true;
        btn.textContent = "Нет строк";
        btn.setAttribute("aria-expanded", "false");
        btn.removeAttribute("aria-controls");
      }
    }
  }

  /**
   * Полная замена tbody одним фрагментом (первый заход или несовпадение ключей с API).
   * @param {HTMLElement} tbody
   * @param {Record<string, unknown>[]} entries
   */
  function replaceCreateJobsTableBody(tbody, entries) {
    const expandedKeys = getExpandedCreateJobsRowKeys(tbody);
    const frag = document.createDocumentFragment();
    entries.forEach(function (e) {
      frag.appendChild(buildCreateJobRowFragment(e));
    });
    tbody.replaceChildren(frag);
    restoreExpandedCreateJobsRows(tbody, expandedKeys);
  }

  /**
   * Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
   * (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
   * Обычное обновление: только новые строки сверху + подрезка хвоста + правка ячеек; без полной пересборки DOM.
   */
  async function loadJobs() {
    const tbody = document.querySelector("#tbl-jobs tbody");
    const msg = document.getElementById("jobs-msg");
    if (!tbody) return;
    try {
      const data = await api("/journal/recent?limit=30&offset=0");
      const entries = Array.isArray(data.entries) ? data.entries : [];

      if (!getFirstCreateJobsDataRow(tbody)) {
        replaceCreateJobsTableBody(tbody, entries);
      } else {
        prependNewCreateJobEntries(tbody, entries);
        trimCreateJobsTail(tbody, entries.length);
        const pairs = collectCreateJobsRowPairs(tbody);
        if (pairs.length !== entries.length) {
          replaceCreateJobsTableBody(tbody, entries);
        } else {
          let keysOk = true;
          for (let i = 0; i < entries.length; i++) {
            if (createJobJournalRowKey(entries[i]) !== pairs[i].dataRow.getAttribute("data-journal-row-key")) {
              keysOk = false;
              break;
            }
          }
          if (!keysOk) {
            replaceCreateJobsTableBody(tbody, entries);
          } else {
            for (let i = 0; i < entries.length; i++) {
              updateCreateJobPairFromEntry(pairs[i], entries[i]);
            }
          }
        }
      }

      if (msg) {
        if (!entries.length) {
          msg.textContent =
            "Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами).";
        } else {
          const total = typeof data.total === "number" ? data.total : entries.length;
          msg.textContent =
            total > entries.length
              ? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах."
              : "";
        }
      }
    } 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");
    if (!overlay) return;
    hideActionTooltip();
    document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
    sub.textContent = "";
    nodes.textContent = "";
    pods.textContent = "";
    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 = "Узлы: код " + w.nodes_rc + " · Поды: код " + w.pods_rc;
      nodes.textContent = w.nodes_output || "(пусто)";
      pods.textContent = w.pods_output || "(пусто)";
    } 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");
    const cancelBtn = document.getElementById("confirm-modal-cancel");
    if (ov) ov.classList.add("hidden");
    document.body.classList.remove("modal-open");
    if (cancelBtn) cancelBtn.classList.remove("hidden");
    if (confirmModalResolver) {
      var fn = confirmModalResolver;
      confirmModalResolver = null;
      var out = confirmModalIsAlert ? true : !!confirmed;
      confirmModalIsAlert = false;
      fn(out);
    } else {
      confirmModalIsAlert = false;
    }
  }

  /**
   * Показать модальное подтверждение (вместо window.confirm).
   * При opts.alert — одна кнопка «Понятно», клик по фону и Escape закрывают как подтверждение.
   *
   * @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: 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");
      const cancelBtn = document.getElementById("confirm-modal-cancel");
      if (!ov || !titleEl || !msgEl || !okBtn) {
        resolve(false);
        return;
      }
      /* Снять предыдущее ожидание без логики «alert = всегда true» (иначе Promise подтверждения ломается). */
      if (confirmModalResolver) {
        const prevFn = confirmModalResolver;
        confirmModalResolver = null;
        confirmModalIsAlert = false;
        prevFn(false);
        ov.classList.add("hidden");
        document.body.classList.remove("modal-open");
        if (cancelBtn) cancelBtn.classList.remove("hidden");
      }
      confirmModalResolver = resolve;
      confirmModalIsAlert = !!opts.alert;
      titleEl.textContent = opts.title || "Подтвердите действие";
      msgEl.textContent = opts.message || "";
      okBtn.textContent = opts.confirmLabel || (confirmModalIsAlert ? "Понятно" : "Подтвердить");
      okBtn.className = opts.danger ? "btn-danger" : "";
      if (cancelBtn) {
        cancelBtn.classList.toggle("hidden", confirmModalIsAlert);
      }
      ov.classList.remove("hidden");
      document.body.classList.add("modal-open");
      okBtn.focus();
    });
  }

  function closeModal() {
    hideActionTooltip();
    const overlay = document.getElementById("modal-overlay");
    if (overlay) overlay.classList.add("hidden");
    document.body.classList.remove("modal-open");
  }

  async function stopCluster(name) {
    const ok = await openConfirmModal({
      title: "Остановить узлы кластера?",
      message:
        "Кластер «" +
        name +
        "»: узлы будут остановлены. Сведения о кластере сохранятся — позже можно снова нажать «Старт».",
      confirmLabel: "Остановить",
      danger: false,
    });
    if (!ok) return;
    const url = API + "/clusters/" + encodeURIComponent(name) + "/stop";
    try {
      const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
      const text = await r.text();
      var data = {};
      try {
        data = text ? JSON.parse(text) : {};
      } catch (e) {
        data = {};
      }
      if (r.status === 202 && data.job_id) {
        setProgressHint(name, "stop");
        pollJob(data.job_id, {
          lockCreateForm: false,
          lifecycleOverlayLabel: "Останавливаем узлы кластера «" + name + "»…",
        });
        return;
      }
      if (!r.ok) {
        showToast(formatApiError(data, text || r.statusText), true);
        return;
      }
      showToast(String(data.summary || "Готово"), false);
      await loadClusters();
      await loadStats();
      await loadHealth();
      await refreshClusterPageIfNeeded(name);
    } catch (e) {
      showToast(e.message, true);
    }
  }

  /**
   * Старт: либо docker start узлов (кластер уже в kind), либо фоновый kind create по kind-config.yaml.
   */
  async function startCluster(name) {
    const url = API + "/clusters/" + encodeURIComponent(name) + "/start";
    const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
    const text = await r.text();
    var data = {};
    try {
      data = text ? JSON.parse(text) : {};
    } catch (e) {
      data = {};
    }
    if (r.status === 202 && data.job_id) {
      var sm = data.mode || "kind_config";
      if (sm === "containers") setProgressHint(name, "start_containers");
      else if (sm === "kind_config_reapply") setProgressHint(name, "start_reapply");
      else setProgressHint(name, "start_config");
      var startLab =
        sm === "containers"
          ? "Запуск узлов кластера «" + name + "»…"
          : sm === "kind_config_reapply"
            ? "Пересоздание кластера «" + name + "» в kind…"
            : "Подъём кластера «" + name + "» по конфигурации…";
      pollJob(data.job_id, { lockCreateForm: false, lifecycleOverlayLabel: startLab });
      return;
    }
    if (!r.ok) {
      showToast(formatApiError(data, text || r.statusText), true);
      return;
    }
    showToast(String(data.summary || "Готово"), false);
    await loadClusters();
    await loadStats();
    await loadHealth();
    await refreshClusterPageIfNeeded(name);
  }

  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);
      if (isClusterPage() && getClusterPageName() === name) {
        window.location.href = "/";
        return;
      }
      await loadClusters();
      await loadStats();
      await loadJobs();
    } catch (e) {
      if (msg) msg.textContent = "Ошибка удаления: " + e.message;
      showToast(e.message, true);
    }
  }

  function setCreateFormDisabled(disabled) {
    const form = document.getElementById("form-create");
    if (!form) return;
    const btn = form.querySelector('[type="submit"]');
    if (btn) {
      btn.disabled = disabled;
      btn.textContent = disabled ? "Создание…" : "Создать кластер";
    }
    form.querySelectorAll("input, select").forEach(function (el) {
      el.disabled = disabled;
    });
  }

  function showCreateProgress(show) {
    const wrap = document.getElementById("create-progress-wrap");
    if (!wrap) return;
    wrap.classList.toggle("hidden", !show);
    wrap.setAttribute("aria-hidden", show ? "false" : "true");
    if (!show) {
      const logEl = document.getElementById("job-log-panel");
      if (logEl) logEl.textContent = "";
    }
  }

  /**
   * Подсказка над прогресс-баром (без технических имён команд).
   * @param {string | null} clusterName
   * @param {"create"|"start_config"|"start_reapply"|"start_containers"|"stop"} [mode]
   */
  function setProgressHint(clusterName, mode) {
    const hint = document.getElementById("create-progress-hint");
    if (!hint) return;
    mode = mode || "create";
    var named = clusterName ? "«" + clusterName + "»" : "";
    if (mode === "stop") {
      hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже.";
    } else if (mode === "start_containers") {
      hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже.";
    } else if (mode === "start_reapply") {
      hint.textContent =
        "Пересоздаём кластер " +
        named +
        " в kind по обновлённому kind-config.yaml (сначала удаление записи, затем создание). Журнал ниже.";
    } else if (mode === "start_config") {
      hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже.";
    } else {
      hint.textContent =
        "Создаём кластер" +
        (named ? " " + named : "") +
        ". Сначала при необходимости скачивается образ, затем создаются узлы — всё в журнале ниже.";
    }
  }

  function updateCreateProgressFromJob(j) {
    const bar = document.getElementById("create-progress-bar");
    const track = document.getElementById("create-progress-track");
    const label = document.getElementById("create-progress-label");
    var pct = j.progress_percent != null ? Number(j.progress_percent) : 0;
    if (isNaN(pct)) pct = 0;
    pct = Math.max(0, Math.min(100, pct));
    var stage = j.progress_stage || (j.status === "queued" ? "В очереди…" : "…");
    if (bar) bar.style.width = pct + "%";
    if (track) {
      track.setAttribute("aria-valuenow", String(pct));
    }
    if (label) label.textContent = stage;
  }

  function stopPollJob() {
    hideClusterLifecycleJobOverlay();
    if (pollTimer) {
      clearInterval(pollTimer);
      pollTimer = null;
    }
    currentPollJobId = null;
    createInProgress = false;
    if (pollLockCreateForm) {
      setCreateFormDisabled(false);
      pollLockCreateForm = false;
    }
    showCreateProgress(false);
  }

  async function requestCancelJob() {
    if (!currentPollJobId) return;
    try {
      await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
      showToast("Запрос на прерывание отправлен", false);
    } catch (e) {
      showToast(e.message || "Не удалось отменить", true);
    }
  }

  /**
   * @param {string} jobId
   * @param {{ lockCreateForm?: boolean, lifecycleOverlayLabel?: string }} [opts]
   * ``lifecycleOverlayLabel`` — полноэкранный спиннер (старт/стоп с главной или страницы кластера).
   */
  function pollJob(jobId, opts) {
    opts = opts || {};
    pollLockCreateForm = !!opts.lockCreateForm;
    const details = document.getElementById("job-details");
    const msg = document.getElementById("create-msg");
    const logEl = document.getElementById("job-log-panel");
    if (logEl) logEl.textContent = "";
    if (details) {
      details.classList.remove("hidden");
      details.open = false;
    }
    if (pollTimer) clearInterval(pollTimer);
    createInProgress = true;
    currentPollJobId = jobId;
    if (pollLockCreateForm) {
      setCreateFormDisabled(true);
    }
    if (opts.lifecycleOverlayLabel) {
      showClusterLifecycleJobOverlay(opts.lifecycleOverlayLabel);
    }
    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 (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");
            logEl.textContent = j.progress_log.join("\n");
            logEl.scrollTop = logEl.scrollHeight;
          } else if (j.status === "running" || j.status === "queued") {
            logEl.setAttribute("data-placeholder", "1");
            logEl.textContent =
              "Журнал подгружается… Строки скачивания и создания узлов появляются по мере вывода команды.";
          }
        }

        if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
          stopPollJob();
          setProgressHint(null, "create");
          if (msg) {
            if (j.status === "success") {
              if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу.";
              else if (j.kind === "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 = "Кластер создан.";
            } else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
            else msg.textContent = "Ошибка: " + (j.message || "");
          }
          if (j.status === "success") {
            if (j.kind === "start_cluster") showToast("Кластер запущен", false);
            else if (j.kind === "start_cluster_reapply") showToast("Кластер пересоздан по новому конфигу", false);
            else if (j.kind === "stop_containers") showToast("Узлы остановлены", false);
            else if (j.kind === "start_containers") showToast("Узлы запущены", false);
            else showToast("Кластер создан", false);
          } else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
          else showToast(j.message || "Ошибка", true);
          await loadClusters();
          await loadStats();
          await loadJobs();
          await loadHealth();
          await refreshClusterPageIfNeeded(j.cluster_name || "");
        }
      } catch (e) {
        if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
      }
    };
    tick();
    pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
  }

  async function refreshLists() {
    try {
      await loadHealth();
      if (isHomePage() || isClustersListPage()) {
        await loadStats();
      }
      if (isClustersListPage()) {
        await loadClusters();
      }
      if (isCreatePage()) {
        await loadJobs();
      }
      if (isClusterPage()) {
        const n = getClusterPageName();
        if (n) await loadClusterOverview(n);
      }
    } catch (e) {
      /* loadHealth/loadStats/loadClusters сами сообщают об ошибках в UI */
    }
  }

  function init() {
    const form = document.getElementById("form-create");
    if (form) {
      form.addEventListener("submit", async function (ev) {
        ev.preventDefault();
        if (createInProgress) return;
        const msg = document.getElementById("create-msg");
        const details = document.getElementById("job-details");
        if (msg) msg.textContent = "";
        if (details) details.classList.add("hidden");
        setProgressHint(null, "create");
        const fd = new FormData(form);
        const nameRaw = String(fd.get("name") || "").trim();
        const k8sVer = String(fd.get("kubernetes_version") || "").trim();
        const workersParsed = parseWorkersBounded(fd.get("workers"));

        if (!isValidClusterName(nameRaw)) {
          await openConfirmModal({
            alert: true,
            title: "Некорректное имя кластера",
            message:
              "Имя должно состоять из строчных латинских букв, цифр и дефиса (a-z, 0-9, -), начинаться и заканчиваться буквой или цифрой, длина не более 63 символов. Пример: dev, my-cluster-1.",
            confirmLabel: "Понятно",
          });
          return;
        }

        if (workersParsed === null) {
          await openConfirmModal({
            alert: true,
            title: "Некорректное число worker-нод",
            message:
              "Укажите целое число worker-нод от 0 до 20 включительно.",
            confirmLabel: "Понятно",
          });
          return;
        }

        const verCheck = validateCreateFormKubernetesVersion(k8sVer);
        if (!verCheck.ok) {
          await openConfirmModal({
            alert: true,
            title: verCheck.modalTitle,
            message: verCheck.modalMessage,
            confirmLabel: "Понятно",
          });
          return;
        }

        const body = {
          name: nameRaw,
          kubernetes_version: k8sVer,
          workers: workersParsed,
        };
        try {
          const res = await api("/clusters", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(body),
          });
          if (msg) msg.textContent = "Задание: " + res.job_id;
          pollJob(res.job_id, { lockCreateForm: true });
        } catch (e) {
          if (msg) msg.textContent = "Ошибка: " + e.message;
          showToast(e.message, true);
        }
      });
    }

    const btnCancel = document.getElementById("btn-cancel-create");
    if (btnCancel) {
      btnCancel.addEventListener("click", function () {
        requestCancelJob();
      });
    }

    const btnJobsRefresh = document.getElementById("btn-jobs-refresh");
    if (btnJobsRefresh) {
      btnJobsRefresh.addEventListener("click", function () {
        loadJobs();
      });
    }

    if (document.getElementById("tbl-jobs")) {
      bindCreateJobsJournalToggle();
    }

    const mClose = document.getElementById("modal-close");
    if (mClose) mClose.addEventListener("click", closeModal);

    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(confirmModalIsAlert ? true : false);
        return;
      }
      if (isJobLogModalOpen()) {
        closeJobLogModal();
        return;
      }
      if (isProvisionLogModalOpen()) {
        closeProvisionLogModal();
        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(confirmModalIsAlert ? true : false);
      });
    }

    const provLogOv = document.getElementById("provision-log-modal-overlay");
    const provLogClose = document.getElementById("provision-log-modal-close");
    if (provLogClose) {
      provLogClose.addEventListener("click", closeProvisionLogModal);
    }
    if (provLogOv) {
      provLogOv.addEventListener("click", function (ev) {
        if (ev.target === provLogOv) closeProvisionLogModal();
      });
    }

    const jobLogOv = document.getElementById("job-log-modal-overlay");
    const jobLogClose = document.getElementById("job-log-modal-close");
    if (jobLogClose) {
      jobLogClose.addEventListener("click", closeJobLogModal);
    }
    if (jobLogOv) {
      jobLogOv.addEventListener("click", function (ev) {
        if (ev.target === jobLogOv) closeJobLogModal();
      });
    }

    window.addEventListener(
      "scroll",
      function () {
        hideActionTooltip();
      },
      true,
    );
    window.addEventListener("resize", hideActionTooltip);

    autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);

    (async function bootstrap() {
      try {
        await loadHealth();
        if (isHomePage()) {
          await loadStats();
        }
        if (isClustersListPage()) {
          await loadStats();
          await loadClusters();
        }
        if (isCreatePage()) {
          await loadJobs();
        }
        if (isClusterPage()) {
          const n = getClusterPageName();
          if (n) await loadClusterOverview(n);
          else hideClusterPageLoadingOverlay();
        }
      } catch (e) {
        const errText = e && e.message ? e.message : String(e);
        const lm = document.getElementById("list-msg");
        if (isHomePage() && lm) {
          lm.textContent = "Ошибка загрузки панели: " + errText;
        }
        if (isClustersListPage() && lm) {
          lm.textContent = "Ошибка загрузки: " + errText;
        }
      } finally {
        hideHomePageLoadingOverlay();
        hideClustersPageLoadingOverlay();
        hideCreatePageLoadingOverlay();
      }
    })();
    if (isCreatePage()) {
      loadVersions();
    }
    bindActionTooltipHosts(document.getElementById("modal-overlay"));
    bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
    bindActionTooltipHosts(document.getElementById("job-log-modal-overlay"));
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
