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()) {
+67 -17
View File
@@ -24,6 +24,25 @@
el.classList.add("hidden");
}
/** Полноэкранный спиннер как на панели (page-loading-overlay в documentation.html). */
function showDocumentationPageLoadingOverlay() {
var el = document.getElementById("documentation-page-loading-overlay");
if (!el) return;
el.classList.remove("hidden");
el.setAttribute("aria-busy", "true");
el.setAttribute("aria-hidden", "false");
document.body.classList.add("documentation-page-loading");
}
function hideDocumentationPageLoadingOverlay() {
var el = document.getElementById("documentation-page-loading-overlay");
if (!el) return;
el.classList.add("hidden");
el.setAttribute("aria-busy", "false");
el.setAttribute("aria-hidden", "true");
document.body.classList.remove("documentation-page-loading");
}
function getPathFromLocation() {
var q = new URLSearchParams(window.location.search || "");
var p = (q.get("path") || "").trim();
@@ -193,10 +212,36 @@
return page;
}
function renderMarkdownToRoot(text, docPath, rootEl, errEl, loadEl) {
/**
* Таблицы Markdown с более чем двумя колонками: класс и data-label для мини-карточек при ширине до 480px (CSS).
* @param {HTMLElement} rootEl
*/
function markupWideMarkdownTables(rootEl) {
if (!rootEl || !rootEl.querySelectorAll) return;
rootEl.querySelectorAll(".markdown-body table").forEach(function (table) {
var thList = table.querySelectorAll("thead tr th");
if (!thList.length) return;
var n = thList.length;
if (n <= 2) return;
table.classList.add("markdown-body-table--stack");
var labels = [];
for (var i = 0; i < thList.length; i++) {
labels.push((thList[i].textContent || "").trim());
}
var tbody = table.querySelector("tbody");
if (!tbody) return;
tbody.querySelectorAll("tr").forEach(function (tr) {
var tds = tr.querySelectorAll("td");
for (var j = 0; j < tds.length; j++) {
if (labels[j]) tds[j].setAttribute("data-label", labels[j]);
}
});
});
}
function renderMarkdownToRoot(text, docPath, rootEl, errEl) {
if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
showErr(errEl, "Не загружены скрипты marked или DOMPurify из /static/js/vendor/.");
if (loadEl) loadEl.classList.add("hidden");
applyDocumentationTabTitle("");
return;
}
@@ -223,6 +268,7 @@
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
var pageInner = sectionizeFromNodes(temp);
rootEl.appendChild(pageInner);
markupWideMarkdownTables(rootEl);
rootEl.removeAttribute("hidden");
} catch (e2) {
showErr(errEl, "Ошибка разбора: " + (e2.message || String(e2)));
@@ -242,18 +288,20 @@
var rootEl = document.getElementById("readme-doc-root");
var errEl = document.getElementById("readme-error");
var loadEl = document.getElementById("readme-loading");
if (!rootEl || !loadEl) return;
if (!rootEl) {
hideDocumentationPageLoadingOverlay();
return;
}
hideErr(errEl);
rootEl.setAttribute("hidden", "hidden");
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
loadEl.classList.remove("hidden");
updateBreadcrumbs(docPath);
var url = fetchUrlForPath(docPath);
showDocumentationPageLoadingOverlay();
try {
hideErr(errEl);
rootEl.setAttribute("hidden", "hidden");
while (rootEl.firstChild) rootEl.removeChild(rootEl.firstChild);
updateBreadcrumbs(docPath);
var url = fetchUrlForPath(docPath);
var r = await fetch(url, {
headers: { Accept: "text/markdown, text/plain, */*" },
});
@@ -267,13 +315,11 @@
/* raw */
}
showErr(errEl, "Не удалось загрузить документ: " + (detail || r.statusText));
loadEl.classList.add("hidden");
applyDocumentationTabTitle("");
return;
}
renderMarkdownToRoot(text, docPath, rootEl, errEl, loadEl);
loadEl.classList.add("hidden");
renderMarkdownToRoot(text, docPath, rootEl, errEl);
var newUrl = docUrlForPath(docPath);
if (push) {
@@ -283,8 +329,9 @@
}
} catch (e) {
showErr(errEl, "Ошибка: " + (e.message || String(e)));
loadEl.classList.add("hidden");
applyDocumentationTabTitle("");
} finally {
hideDocumentationPageLoadingOverlay();
}
}
@@ -309,7 +356,10 @@
function run() {
var rootEl = document.getElementById("readme-doc-root");
if (!rootEl) return;
if (!rootEl) {
hideDocumentationPageLoadingOverlay();
return;
}
window.addEventListener("popstate", onPopState);
rootEl.addEventListener("click", onRootClick);
+58 -12
View File
@@ -80,6 +80,37 @@
}
}
/**
* Ячейка «время»: дата и время отдельными span (при ширине до 920px — в две строки, см. CSS .journal-time-stack).
* @param {string} iso
* @returns {string} безопасный HTML
*/
function formatUtcCellHtml(iso) {
if (!iso) {
return esc("—");
}
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return esc(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">' +
esc(day) +
"</span>" +
'<span class="journal-time-clock">' +
esc(clock) +
"</span>" +
"</span>"
);
} catch (e) {
return esc(iso);
}
}
function statusClass(st) {
const s = (st || "").toLowerCase();
if (s === "success") return "journal-status-pill journal-status-pill--ok";
@@ -189,11 +220,12 @@
return b;
}
/* Короткий текст на кнопках — одна строка пагинации; смысл в aria-label */
wrap.appendChild(
mkBtn( Первая", page <= 1, "Первая страница", function () { onSelectPage(1); }),
mkBtn(«", page <= 1, "Первая страница", function () { onSelectPage(1); }),
);
wrap.appendChild(
mkBtn(" Назад", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
mkBtn("", page <= 1, "Предыдущая страница", function () { onSelectPage(page - 1); }),
);
const pagesEl = document.createElement("div");
@@ -227,10 +259,10 @@
wrap.appendChild(pagesEl);
wrap.appendChild(
mkBtn("Вперёд ", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
mkBtn("", page >= totalPages, "Следующая страница", function () { onSelectPage(page + 1); }),
);
wrap.appendChild(
mkBtn("Последняя »", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
mkBtn("»»", page >= totalPages, "Последняя страница", function () { onSelectPage(totalPages); }),
);
pagNav.appendChild(wrap);
@@ -252,7 +284,7 @@
const tr = document.createElement("tr");
tr.className = "journal-data-row";
const cluster = e.source_cluster || e.cluster_name || "—";
const finished = formatUtc(
const finishedHtml = formatUtcCellHtml(
e.finished_at_utc || e.created_at_utc,
);
const kind = mode === "helm_addons" ? formatHelmJournalKind(e) : (e.kind || "");
@@ -266,15 +298,29 @@
const logText = lines.join("\n");
const detailsId = "journal-log-" + Math.random().toString(36).slice(2, 11);
/* Классы create-jobs-card-td* — карточки на узком экране (<620px), см. .journal-list-card в style.css */
tr.innerHTML =
"<td class=\"journal-cell-time\">" + esc(finished) + "</td>" +
"<td><code class=\"journal-code\">" + esc(cluster) + "</code></td>" +
"<td><code class=\"journal-code journal-cell-kind\">" + esc(kind) + "</code></td>" +
"<td><span class=\"" + esc(statusClass(e.status)) + "\">" + esc(e.status || "") + "</span></td>" +
"<td class=\"journal-cell-msg\">" + esc(msg || "—") + "</td>" +
"<td class=\"journal-cell-actions\">" +
"<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\">" +
esc(cluster) +
"</code></td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--kind\"><code class=\"journal-code journal-cell-kind\">" +
esc(kind) +
"</code></td>" +
"<td class=\"create-jobs-card-td create-jobs-card-td--status\"><span class=\"" +
esc(statusClass(e.status)) +
"\">" +
esc(e.status || "") +
"</span></td>" +
"<td class=\"journal-cell-msg create-jobs-card-td create-jobs-card-td--msg\"><span class=\"create-jobs-msg-value\">" +
esc(msg || "—") +
"</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=\"" +
detailsId + "\">Показать лог</button>" +
detailsId +
"\">Показать лог</button>" +
"</td>";
tbody.appendChild(tr);
+131
View File
@@ -0,0 +1,131 @@
/**
* Адаптивное меню шапки: выезжающая панель при ширине viewport < 920px.
* Закрытие по клику на подложку, Escape, переходу на другую страницу и при расширении окна.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
var MQ = "(max-width: 919px)";
var OPEN_CLASS = "top-nav-actions--open";
var BODY_CLASS = "nav-mobile-open";
var TOGGLE_OPEN_CLASS = "nav-menu-toggle--open";
function mqMobile() {
return window.matchMedia(MQ).matches;
}
/** Закрывает выпадающее меню API (дублирует логику из base.html, чтобы не дублировать DOM-события). */
function closeApiDropdown() {
var dd = document.querySelector(".nav-dropdown");
if (!dd) return;
var btn = document.getElementById("nav-api-trigger");
var menu = document.getElementById("nav-api-menu");
dd.classList.remove("nav-dropdown--open");
if (btn) btn.setAttribute("aria-expanded", "false");
if (menu) menu.setAttribute("hidden", "");
}
function getEls() {
return {
toggle: document.getElementById("nav-menu-toggle"),
drawer: document.getElementById("top-nav-drawer"),
backdrop: document.getElementById("nav-menu-backdrop"),
};
}
function setOpen(open) {
var els = getEls();
if (!els.toggle || !els.drawer || !els.backdrop) return;
els.toggle.setAttribute("aria-expanded", open ? "true" : "false");
els.toggle.setAttribute(
"aria-label",
open ? "Закрыть меню разделов" : "Открыть меню разделов",
);
els.toggle.classList.toggle(TOGGLE_OPEN_CLASS, open);
els.drawer.classList.toggle(OPEN_CLASS, open);
if (open) {
closeApiDropdown();
els.backdrop.removeAttribute("hidden");
els.backdrop.setAttribute("aria-hidden", "false");
document.body.classList.add(BODY_CLASS);
var closeBtn = document.getElementById("nav-drawer-close");
if (closeBtn && mqMobile()) {
try {
closeBtn.focus();
} catch (e) {
/* focus не всегда доступен */
}
}
} else {
els.backdrop.setAttribute("hidden", "");
els.backdrop.setAttribute("aria-hidden", "true");
document.body.classList.remove(BODY_CLASS);
closeApiDropdown();
}
}
function toggle() {
var els = getEls();
if (!els.drawer) return;
setOpen(!els.drawer.classList.contains(OPEN_CLASS));
}
function closeIfMobile() {
if (mqMobile()) setOpen(false);
}
function onResize() {
if (!mqMobile()) setOpen(false);
}
function init() {
var els = getEls();
if (!els.toggle || !els.drawer || !els.backdrop) return;
els.toggle.addEventListener("click", function (e) {
e.stopPropagation();
if (!mqMobile()) return;
toggle();
});
els.backdrop.addEventListener("click", function () {
closeIfMobile();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeIfMobile();
});
window.addEventListener("resize", onResize);
/* Закрытие при уходе со страницы по ссылке внутри панели */
els.drawer.addEventListener("click", function (e) {
var a = e.target.closest("a[href]");
if (!a || !mqMobile()) return;
if (a.getAttribute("href") === "#" || a.getAttribute("href") === "") return;
if (a.getAttribute("target") === "_blank") return;
/* data-open-window открывает новое окно — страница не перезагружается */
if (a.hasAttribute("data-open-window")) return;
setOpen(false);
});
var closeDrawer = document.getElementById("nav-drawer-close");
if (closeDrawer) {
closeDrawer.addEventListener("click", function (e) {
e.stopPropagation();
closeIfMobile();
});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();