UI: адаптив, журнал, спиннеры; docs: README и api_routes
- Навигация: выезжающее меню при узком экране (nav-mobile.js) - Журнал: карточки <620px, компактная пагинация, время в две строки <920px - Создание кластера: оверлей загрузки, инкрементальное обновление таблицы заданий - Документация: полноэкранный спиннер при загрузке и навигации - Главная: масштабирование CTA, статистика 2 колонки <520px, донаты перенос <710px - README: env.example, новые фичи UI, автор в конце файла - api_routes: маршрут /cluster-create, спиннеры, шаблоны; автор в конце - env.example: автор перенесён в конец файла
This commit is contained in:
+368
-60
@@ -96,6 +96,16 @@
|
||||
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");
|
||||
@@ -969,8 +979,12 @@
|
||||
const tbody = document.createElement("tbody");
|
||||
rows.forEach(function (cells) {
|
||||
const tr = document.createElement("tr");
|
||||
cells.forEach(function (cell) {
|
||||
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 {
|
||||
@@ -2012,6 +2026,9 @@
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка таблицы: версия и workers в ``.cluster-list-value`` — в карточках (<920px) значение по центру правой колонки.
|
||||
*/
|
||||
async function loadClusters() {
|
||||
const tbody = document.querySelector("#tbl-clusters tbody");
|
||||
const msg = document.getElementById("list-msg");
|
||||
@@ -2049,12 +2066,12 @@
|
||||
"<td class=\"col-kubeconfig\">" +
|
||||
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
||||
"</td>" +
|
||||
"<td class=\"col-version\">" +
|
||||
"<td class=\"col-version\"><span class=\"cluster-list-value\">" +
|
||||
escapeHtml(String(ver)) +
|
||||
"</td>" +
|
||||
"<td class=\"col-workers\">" +
|
||||
"</span></td>" +
|
||||
"<td class=\"col-workers\"><span class=\"cluster-list-value\">" +
|
||||
escapeHtml(String(wn)) +
|
||||
"</td>" +
|
||||
"</span></td>" +
|
||||
"<td class=\"col-state\">" +
|
||||
stateCell +
|
||||
"</td>" +
|
||||
@@ -2094,6 +2111,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Время для ячейки таблицы: разметка дата + время (при ширине до 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
|
||||
@@ -2135,9 +2183,300 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Стабильный ключ строки журнала на странице создания (сверка 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");
|
||||
@@ -2146,65 +2485,33 @@
|
||||
try {
|
||||
const data = await api("/journal/recent?limit=30&offset=0");
|
||||
const entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
const frag = document.createDocumentFragment();
|
||||
entries.forEach(function (e) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "journal-data-row";
|
||||
const cluster = e.source_cluster || e.cluster_name || "—";
|
||||
const finished = formatJournalUtcDisplay(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\">" +
|
||||
escapeHtml(finished) +
|
||||
"</td>" +
|
||||
"<td><code class=\"journal-code\">" +
|
||||
escapeHtml(cluster) +
|
||||
"</code></td>" +
|
||||
"<td><code class=\"journal-code journal-cell-kind\">" +
|
||||
escapeHtml(kind) +
|
||||
"</code></td>" +
|
||||
"<td><span class=\"" +
|
||||
escapeHtml(journalStatusPillClass(e.status)) +
|
||||
"\">" +
|
||||
escapeHtml(e.status || "") +
|
||||
"</span></td>" +
|
||||
"<td class=\"journal-cell-msg\">" +
|
||||
escapeHtml(message || "—") +
|
||||
"</td>" +
|
||||
"<td class=\"journal-cell-actions\">" +
|
||||
"<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);
|
||||
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 {
|
||||
const btn = tr.querySelector(".btn-journal-toggle");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Нет строк";
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
tbody.replaceChildren(frag);
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
if (!entries.length) {
|
||||
msg.textContent =
|
||||
@@ -2846,6 +3153,7 @@
|
||||
} finally {
|
||||
hideHomePageLoadingOverlay();
|
||||
hideClustersPageLoadingOverlay();
|
||||
hideCreatePageLoadingOverlay();
|
||||
}
|
||||
})();
|
||||
if (isCreatePage()) {
|
||||
|
||||
Reference in New Issue
Block a user