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()) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user