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:
Sergey Antropoff
2026-04-04 08:15:15 +03:00
parent 6f3daa33ec
commit 4546f50aef
19 changed files with 723 additions and 103 deletions
+250 -12
View File
@@ -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") {