UI: навигация, документация, favicon; журнал развёртывания; валидация формы
- Меню API: ссылка «Теги образов», обёртка прокрутки пилюль, z-index и padding против обрезки hover - Документация: ширина колонки как у дашборда (72rem) - Favicon SVG + GET /favicon.ico, link в base.html - provision_log.json, GET .../provision-log, кнопка в таблице кластеров - Валидация create: имя, workers, тег kindest; модалка alert - Прочие правки из сессии (clusters, job_store, стили, шаблоны)
This commit is contained in:
+250
-12
@@ -35,6 +35,18 @@
|
||||
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
|
||||
var cachedLocalClusterDirsCount = 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";
|
||||
}
|
||||
@@ -115,6 +127,37 @@
|
||||
* Скачать 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить ``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");
|
||||
@@ -137,6 +180,8 @@
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
||||
trash:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
|
||||
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>',
|
||||
};
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
@@ -618,32 +663,139 @@
|
||||
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);
|
||||
data.tags.slice(0, 100).forEach(function (t) {
|
||||
/* Весь список с бэкенда: первым идёт latest, далее семверы от новых к старым (раньше slice(0,100) отрезал 1.19 и др.) */
|
||||
data.tags.forEach(function (t) {
|
||||
const o = document.createElement("option");
|
||||
o.value = t;
|
||||
o.textContent = t;
|
||||
o.textContent =
|
||||
t === "latest" ? "latest (самый новый образ kindest/node)" : t;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.onchange = function () {
|
||||
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
|
||||
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).",
|
||||
};
|
||||
}
|
||||
|
||||
function jobBadgeClass(status) {
|
||||
if (status === "success") return "badge badge-ok";
|
||||
if (status === "failed") return "badge badge-err";
|
||||
@@ -749,6 +901,19 @@
|
||||
!c.has_local_kubeconfig,
|
||||
),
|
||||
);
|
||||
if (c.has_provision_log) {
|
||||
td.appendChild(
|
||||
iconActionButton(
|
||||
ICONS.logFile,
|
||||
"Журнал развёртывания: полный JSON последнего создания или старта кластера",
|
||||
"icon-btn--secondary",
|
||||
function () {
|
||||
openProvisionLogModal(c.name);
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
td.appendChild(
|
||||
iconActionButton(
|
||||
ICONS.trash,
|
||||
@@ -923,18 +1088,26 @@
|
||||
*/
|
||||
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;
|
||||
fn(!!confirmed);
|
||||
var out = confirmModalIsAlert ? true : !!confirmed;
|
||||
confirmModalIsAlert = false;
|
||||
fn(out);
|
||||
} else {
|
||||
confirmModalIsAlert = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать модальное подтверждение (вместо window.confirm).
|
||||
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean }} opts
|
||||
* При opts.alert — одна кнопка «Понятно», клик по фону и Escape закрывают как подтверждение.
|
||||
*
|
||||
* @param {{ title?: string, message: string, confirmLabel?: string, danger?: boolean, alert?: boolean }} opts
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function openConfirmModal(opts) {
|
||||
@@ -943,18 +1116,30 @@
|
||||
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) {
|
||||
closeConfirmModal(false);
|
||||
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 || "Подтвердить";
|
||||
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();
|
||||
@@ -1256,10 +1441,47 @@
|
||||
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: String(fd.get("name") || "").trim(),
|
||||
kubernetes_version: String(fd.get("kubernetes_version") || "").trim(),
|
||||
workers: parseInt(String(fd.get("workers") || "0"), 10),
|
||||
name: nameRaw,
|
||||
kubernetes_version: k8sVer,
|
||||
workers: workersParsed,
|
||||
};
|
||||
try {
|
||||
const res = await api("/clusters", {
|
||||
@@ -1310,7 +1532,11 @@
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
if (isConfirmModalOpen()) {
|
||||
closeConfirmModal(false);
|
||||
closeConfirmModal(confirmModalIsAlert ? true : false);
|
||||
return;
|
||||
}
|
||||
if (isProvisionLogModalOpen()) {
|
||||
closeProvisionLogModal();
|
||||
return;
|
||||
}
|
||||
hideActionTooltip();
|
||||
@@ -1332,7 +1558,18 @@
|
||||
}
|
||||
if (confirmOv) {
|
||||
confirmOv.addEventListener("click", function (ev) {
|
||||
if (ev.target === confirmOv) closeConfirmModal(false);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1365,6 +1602,7 @@
|
||||
loadVersions();
|
||||
}
|
||||
bindActionTooltipHosts(document.getElementById("modal-overlay"));
|
||||
bindActionTooltipHosts(document.getElementById("provision-log-modal-overlay"));
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
Reference in New Issue
Block a user