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:
Sergey Antropoff
2026-04-05 00:18:19 +03:00
parent 4b703801e1
commit f0b24c8901
12 changed files with 2426 additions and 173 deletions
+368 -60
View File
@@ -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()) {