Веб-интерфейс: страница /clusters, навигация и крошки для кластеров

- Выделена страница списка кластеров, панель упрощена; nav_active и крошки
  ведут в раздел Кластеры; theme.js синхронизирует активную пилюлю по URL.
- Доработки дашборда, аддонов, журнала, стилей и API-документации.
- Поддержка Podman: docker-compose.podman.yml, скрипты сокета; Makefile и env.
This commit is contained in:
Sergey Antropoff
2026-04-04 13:42:21 +03:00
parent 17f6233fd7
commit eb063aec20
38 changed files with 3990 additions and 734 deletions
+413 -224
View File
@@ -32,9 +32,6 @@
var cachedClusterResourcesByName = {};
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
var cachedHealthPayload = null;
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
var cachedLocalClusterDirsCount = null;
/**
* Кэш GET /versions для проверки тега перед созданием кластера.
* null — загрузка ещё не завершена; [] — каталог пуст или отключён; иначе список тегов.
@@ -51,6 +48,11 @@
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
}
/** Страница «Кластеры»: таблица и донаты узлов (`/clusters`). */
function isClustersListPage() {
return document.body.getAttribute("data-dashboard-mode") === "clusters";
}
function isCreatePage() {
return document.body.getAttribute("data-dashboard-mode") === "create";
}
@@ -84,6 +86,16 @@
document.body.classList.remove("dashboard-home-loading");
}
/** Скрыть спиннер первой загрузки страницы «Кластеры». */
function hideClustersPageLoadingOverlay() {
const el = document.getElementById("clusters-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-clusters-loading");
}
/** Показать оверлей со спиннером на время старта/остановки узлов (до завершения задания в pollJob). */
function showClusterLifecycleJobOverlay(labelText) {
const el = document.getElementById("cluster-lifecycle-job-overlay");
@@ -149,32 +161,6 @@
return d.innerHTML;
}
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
/**
* Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC).
* @param {string} iso
*/
function formatJobTimeUtc(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return (
pad2(d.getUTCDate()) +
"." +
pad2(d.getUTCMonth() + 1) +
"." +
String(d.getUTCFullYear()).slice(-2) +
" " +
pad2(d.getUTCHours()) +
":" +
pad2(d.getUTCMinutes()) +
" UTC"
);
}
/**
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
* @param {string} clusterName
@@ -459,6 +445,7 @@
const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid");
const resCard = document.getElementById("home-resources-summary-card");
if (failureWrap) {
failureWrap.classList.remove("hidden");
failureWrap.replaceChildren();
@@ -480,6 +467,7 @@
}
if (env) env.classList.add("hidden");
if (metricsGrid) metricsGrid.classList.add("hidden");
if (resCard) resCard.classList.add("hidden");
}
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
@@ -487,31 +475,18 @@
const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid");
const resCard = document.getElementById("home-resources-summary-card");
if (failureWrap) {
failureWrap.classList.add("hidden");
failureWrap.replaceChildren();
}
if (env) env.classList.remove("hidden");
if (metricsGrid) metricsGrid.classList.remove("hidden");
}
/** Карточка-метрика в ряду среды (без зелёного/красного статуса), напр. «Каталогов». */
function buildStatsEnvMetricCard(label, valueText) {
const tile = document.createElement("div");
tile.className = "stats-tile stats-env-card stats-env-card--metric";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = valueText;
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = label;
tile.appendChild(valEl);
tile.appendChild(labEl);
return tile;
if (resCard) resCard.classList.remove("hidden");
}
/**
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats).
* Ряд: Docker API · Kind · Kubectl. Число каталогов в ``clusters/`` — первая плитка в ``#stats-grid`` (слева от «Кластеров в kind»).
*/
function paintStatsEnvRow() {
const el = document.getElementById("stats-env");
@@ -539,9 +514,6 @@
el.appendChild(
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
);
const dirVal =
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
}
async function loadHealth() {
@@ -659,7 +631,7 @@
}
/**
* SVG-донаты по полю aggregate_cluster_resources (только главная: элемент ``#aggregate-donuts``).
* SVG-донаты по полю aggregate_cluster_resources (главная: ``#aggregate-donuts`` в карточке статистики; страница «Кластеры»).
*/
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
renderAggregateDonutsToRoot(
@@ -1016,6 +988,163 @@
renderRows(root, items);
}
/**
* Блоки K8s на странице кластера с фильтром по namespace (ключ → поле overview → id корня таблицы).
* Автор: Сергей Антропов — https://devops.org.ru
*/
var CLUSTER_K8S_NS_BLOCKS = [
{ key: "pods", prop: "k8s_pods", rootId: "cluster-k8s-pods-root" },
{ key: "deployments", prop: "k8s_deployments", rootId: "cluster-k8s-deployments-root" },
{ key: "statefulsets", prop: "k8s_statefulsets", rootId: "cluster-k8s-statefulsets-root" },
{ key: "daemonsets", prop: "k8s_daemonsets", rootId: "cluster-k8s-daemonsets-root" },
{ key: "replicasets", prop: "k8s_replicasets", rootId: "cluster-k8s-replicasets-root" },
{ key: "jobs", prop: "k8s_jobs", rootId: "cluster-k8s-jobs-root" },
{ key: "cronjobs", prop: "k8s_cronjobs", rootId: "cluster-k8s-cronjobs-root" },
{ key: "services", prop: "k8s_services", rootId: "cluster-k8s-services-root" },
{ key: "ingresses", prop: "k8s_ingresses", rootId: "cluster-k8s-ingresses-root" },
{ key: "pvcs", prop: "k8s_pvcs", rootId: "cluster-k8s-pvcs-root" },
];
/** Последний ответ overview для перерисовки без повторного запроса при смене фильтра. */
var lastClusterK8sOverview = null;
/** Однократная привязка делегирования change на фильтры namespace. */
var clusterNsFiltersBound = false;
/**
* Безопасно взять массив ``items`` из блока K8sListJsonBlock (ответ overview).
* @param {object|undefined|null} block
* @returns {object[]}
*/
function k8sBlockItemsArray(block) {
if (!block || typeof block !== "object") return [];
var items = block.items;
return Array.isArray(items) ? items : [];
}
/**
* Отбор объектов по выбранному namespace (пустая строка — все).
* @param {object[]|undefined} items
* @param {string} ns
* @returns {object[]}
*/
function k8sFilterItemsByNamespace(items, ns) {
if (!ns) return items || [];
return (items || []).filter(function (it) {
return ((it.metadata && it.metadata.namespace) || "") === ns;
});
}
/**
* Сортировка namespaced-ресурсов: сначала namespace (ru), затем имя объекта.
* Используется при «Все namespace» и при фильтре по одному ns (единый порядок строк).
* @param {object[]|undefined} items
* @returns {object[]}
*/
function k8sSortNamespacedItems(items) {
if (!items || !items.length) return items || [];
return items.slice().sort(function (a, b) {
var nsA = (a.metadata && a.metadata.namespace) || "";
var nsB = (b.metadata && b.metadata.namespace) || "";
var c = String(nsA).localeCompare(String(nsB), "ru");
if (c !== 0) return c;
var nA = (a.metadata && a.metadata.name) || "";
var nB = (b.metadata && b.metadata.name) || "";
return String(nA).localeCompare(String(nB), "ru");
});
}
/**
* Список имён namespace для выпадающих списков: ``kubectl get ns`` + все ``metadata.namespace``
* из таблиц ресурсов (pods/deployments/…); без дублей, сортировка по ru.
* Автор: Сергей Антропов — https://devops.org.ru
* @param {object} d ответ cluster overview
* @returns {string[]}
*/
function k8sNamespaceNamesFromOverview(d) {
var seen = Object.create(null);
var ordered = [];
function addName(raw) {
if (raw == null) return;
var s = String(raw).trim();
if (!s || seen[s]) return;
seen[s] = true;
ordered.push(s);
}
var nsBlock = d.k8s_namespaces;
if (nsBlock && nsBlock.ok) {
k8sBlockItemsArray(nsBlock).forEach(function (it) {
var meta = it && it.metadata;
addName(meta && meta.name);
});
}
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
var blk = d[CLUSTER_K8S_NS_BLOCKS[i].prop];
if (!blk || !blk.ok) continue;
k8sBlockItemsArray(blk).forEach(function (it) {
var meta = it && it.metadata;
addName(meta && meta.namespace);
});
}
ordered.sort(function (a, b) {
return a.localeCompare(b, "ru");
});
return ordered;
}
/**
* Рендер блока k8s_* с учётом значения select#cluster-k8s-filter-<key>.
* @param {object | null | undefined} block
* @param {string | null} kubeErr
* @param {HTMLElement | null} root
* @param {string} filterKey
* @param {function(HTMLElement, object[]): void} renderFn
*/
function clusterK8sRenderBlockNs(block, kubeErr, root, filterKey, renderFn) {
if (!root) return;
var sel = document.getElementById("cluster-k8s-filter-" + filterKey);
var ns = sel && sel.value ? sel.value : "";
if (kubeErr) {
clusterK8sShowMessage(root, kubeErr);
return;
}
if (!block || !block.ok) {
clusterK8sShowMessage(root, (block && block.message) || "Данные Kubernetes недоступны.");
return;
}
var items = k8sFilterItemsByNamespace(k8sBlockItemsArray(block), ns);
items = k8sSortNamespacedItems(items);
renderFn(root, items);
}
/**
* Заполняет option во всех селектах фильтра; сохраняет прежнее value, если namespace ещё в списке.
* @param {string[]} namespaceNames
*/
function clusterK8sPopulateNsSelects(namespaceNames) {
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
var sel = document.getElementById("cluster-k8s-filter-" + row.key);
if (!sel) return;
var prev = (sel.value || "").trim();
sel.replaceChildren();
var o0 = document.createElement("option");
o0.value = "";
o0.textContent = "Все namespace";
sel.appendChild(o0);
namespaceNames.forEach(function (name) {
var o = document.createElement("option");
o.value = name;
o.textContent = name;
sel.appendChild(o);
});
if (prev && namespaceNames.indexOf(prev) >= 0) sel.value = prev;
else sel.value = "";
});
}
function k8sNodeReady(item) {
const cond = (item.status && item.status.conditions) || [];
for (var i = 0; i < cond.length; i++) {
@@ -1244,13 +1373,70 @@
clusterK8sBuildTable(root, ["Namespace", "Имя", "Hosts"], rows);
}
/**
* Фабрика функций рендера по ключу фильтра (для подов нужен clusterName).
* @param {string} cname
* @returns {Record<string, function(HTMLElement, object[]): void>}
*/
function buildClusterK8sNsFilterRenderMap(cname) {
return {
pods: function (root, items) {
renderK8sPodsTable(root, items, cname);
},
deployments: renderK8sDeploymentsTable,
statefulsets: renderK8sStsDsTable,
daemonsets: renderK8sStsDsTable,
replicasets: renderK8sReplicaSetsTable,
jobs: renderK8sJobsTable,
cronjobs: renderK8sCronJobsTable,
services: renderK8sServicesTable,
ingresses: renderK8sIngressesTable,
pvcs: renderK8sPvcsTable,
};
}
/**
* Перерисовка одной таблицы после смены select (без нового API-запроса).
* @param {string} key ключ data-cluster-k8s-ns-filter
*/
function refillClusterK8sNsFilter(key) {
if (!lastClusterK8sOverview) return;
var d = lastClusterK8sOverview;
var kubeErr = d.kubeconfig_error || null;
var cname = d.cluster_name || getClusterPageName() || "";
var renderByKey = buildClusterK8sNsFilterRenderMap(cname);
var renderFn = renderByKey[key];
if (!renderFn) return;
for (var i = 0; i < CLUSTER_K8S_NS_BLOCKS.length; i++) {
if (CLUSTER_K8S_NS_BLOCKS[i].key !== key) continue;
var row = CLUSTER_K8S_NS_BLOCKS[i];
clusterK8sRenderBlockNs(d[row.prop], kubeErr, document.getElementById(row.rootId), key, renderFn);
return;
}
}
/** Делегирование: один listener на document для всех .cluster-k8s-ns-select. */
function ensureClusterNsFilterListeners() {
if (clusterNsFiltersBound) return;
document.addEventListener("change", function (ev) {
var t = ev.target;
if (!t || !t.matches || !t.matches("select.cluster-k8s-ns-select[data-cluster-k8s-ns-filter]")) return;
var key = t.getAttribute("data-cluster-k8s-ns-filter");
if (key) refillClusterK8sNsFilter(key);
});
clusterNsFiltersBound = true;
}
/**
* Таблицы Kubernetes из полей ``k8s_*`` ответа overview.
* @param {object} d
*/
function fillClusterK8sTablesFromOverview(d) {
lastClusterK8sOverview = d;
const kubeErr = d.kubeconfig_error || null;
const cname = d.cluster_name || getClusterPageName() || "";
ensureClusterNsFilterListeners();
clusterK8sRenderBlock(d.k8s_nodes, kubeErr, document.getElementById("cluster-k8s-nodes-root"), renderK8sNodesTable);
clusterK8sRenderBlock(
d.k8s_namespaces,
@@ -1258,53 +1444,20 @@
document.getElementById("cluster-k8s-namespaces-root"),
renderK8sNamespacesTable,
);
clusterK8sRenderBlock(d.k8s_pods, kubeErr, document.getElementById("cluster-k8s-pods-root"), function (root, items) {
renderK8sPodsTable(root, items, cname);
const renderByKey = buildClusterK8sNsFilterRenderMap(cname);
CLUSTER_K8S_NS_BLOCKS.forEach(function (row) {
clusterK8sRenderBlockNs(
d[row.prop],
kubeErr,
document.getElementById(row.rootId),
row.key,
renderByKey[row.key],
);
});
clusterK8sRenderBlock(
d.k8s_deployments,
kubeErr,
document.getElementById("cluster-k8s-deployments-root"),
renderK8sDeploymentsTable,
);
clusterK8sRenderBlock(
d.k8s_statefulsets,
kubeErr,
document.getElementById("cluster-k8s-statefulsets-root"),
renderK8sStsDsTable,
);
clusterK8sRenderBlock(
d.k8s_daemonsets,
kubeErr,
document.getElementById("cluster-k8s-daemonsets-root"),
renderK8sStsDsTable,
);
clusterK8sRenderBlock(
d.k8s_replicasets,
kubeErr,
document.getElementById("cluster-k8s-replicasets-root"),
renderK8sReplicaSetsTable,
);
clusterK8sRenderBlock(d.k8s_jobs, kubeErr, document.getElementById("cluster-k8s-jobs-root"), renderK8sJobsTable);
clusterK8sRenderBlock(
d.k8s_cronjobs,
kubeErr,
document.getElementById("cluster-k8s-cronjobs-root"),
renderK8sCronJobsTable,
);
clusterK8sRenderBlock(
d.k8s_services,
kubeErr,
document.getElementById("cluster-k8s-services-root"),
renderK8sServicesTable,
);
clusterK8sRenderBlock(
d.k8s_ingresses,
kubeErr,
document.getElementById("cluster-k8s-ingresses-root"),
renderK8sIngressesTable,
);
clusterK8sRenderBlock(d.k8s_pvcs, kubeErr, document.getElementById("cluster-k8s-pvcs-root"), renderK8sPvcsTable);
/* Селекты после таблиц: список ns собран из того же снимка overview (включая metadata.namespace из ресурсов). */
clusterK8sPopulateNsSelects(k8sNamespaceNamesFromOverview(d));
}
/**
@@ -1506,43 +1659,57 @@
async function loadStats() {
const grid = document.getElementById("stats-grid");
if (!grid) return;
const donutsRoot = document.getElementById("aggregate-donuts");
const needGrid = !!grid;
const needDonuts = !!donutsRoot;
if (!needGrid && !needDonuts) return;
try {
const s = await api("/stats");
revealStatsCardBody();
cachedLocalClusterDirsCount = s.local_cluster_dirs_count;
paintStatsEnvRow();
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
updateStatsEngineHint(cliForHint);
const rows = [
["Кластеров в kind", s.kind_clusters_count],
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
];
grid.replaceChildren();
rows.forEach(function (kv) {
const tile = document.createElement("div");
tile.className = "stats-tile";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = String(kv[1]);
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = kv[0];
tile.appendChild(valEl);
tile.appendChild(labEl);
grid.appendChild(tile);
});
if (needGrid) {
revealStatsCardBody();
paintStatsEnvRow();
const cliForHint = s.container_cli || (cachedHealthPayload && cachedHealthPayload.container_cli) || "docker";
updateStatsEngineHint(cliForHint);
const dirVal =
s.local_cluster_dirs_count != null && s.local_cluster_dirs_count !== undefined
? String(s.local_cluster_dirs_count)
: "—";
const rows = [
["Каталогов", dirVal],
["Кластеров в kind", s.kind_clusters_count],
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
["Аддонов", s.helm_addons_installable_count ?? 0],
];
grid.replaceChildren();
rows.forEach(function (kv) {
const tile = document.createElement("div");
tile.className = "stats-tile";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = String(kv[1]);
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = kv[0];
tile.appendChild(valEl);
tile.appendChild(labEl);
grid.appendChild(tile);
});
}
cachedClusterResourcesByName = {};
renderAggregateDonuts(
s.aggregate_cluster_resources,
s.cluster_resources_error,
s.container_cli || "docker",
);
if (needDonuts) {
renderAggregateDonuts(
s.aggregate_cluster_resources,
s.cluster_resources_error,
s.container_cli || "docker",
);
}
} catch (e) {
coverStatsCardWithError("Статистика", e.message || String(e));
grid.replaceChildren();
if (needGrid) {
coverStatsCardWithError("Статистика", e.message || String(e));
grid.replaceChildren();
}
cachedClusterResourcesByName = {};
const donuts = document.getElementById("aggregate-donuts");
if (donuts) {
@@ -1692,14 +1859,6 @@
};
}
function jobBadgeClass(status) {
if (status === "success") return "badge badge-ok";
if (status === "failed") return "badge badge-err";
if (status === "running") return "badge badge-run";
if (status === "cancelled") return "badge badge-cancelled";
return "badge";
}
async function loadClusters() {
const tbody = document.querySelector("#tbl-clusters tbody");
const msg = document.getElementById("list-msg");
@@ -1768,124 +1927,145 @@
}
/**
* Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена).
* Формат времени как на странице «Журнал» (ISO → «YYYY-MM-DD HH:MM:SS.sss Z»).
* @param {string} iso
*/
async function clearCompletedJobs() {
const ok = await openConfirmModal({
title: "Очистить завершённые задания?",
message:
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
confirmLabel: "Очистить",
danger: false,
});
if (!ok) return;
const msg = document.getElementById("jobs-msg");
function formatJournalUtcDisplay(iso) {
if (!iso) return "—";
try {
const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } });
const text = await r.text();
var data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
var n = data.removed != null ? data.removed : 0;
if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать.";
showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false);
await loadJobs();
await loadStats();
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " Z");
} catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message;
showToast(e.message, true);
return iso;
}
}
/**
* Один раз: кнопка «Журнал» в таблице последних заданий открывает модалку (не сбрасывается при автообновлении списка).
* Класс плашки статуса записи журнала (success / failed / cancelled).
* @param {string} st
*/
function bindJobsTableJobLogOpen() {
function journalStatusPillClass(st) {
const s = (st || "").toLowerCase();
if (s === "success") return "journal-status-pill journal-status-pill--ok";
if (s === "failed") return "journal-status-pill journal-status-pill--err";
if (s === "cancelled") return "journal-status-pill journal-status-pill--warn";
return "journal-status-pill";
}
/**
* Раскрытие строки лога — как на /journal (режим заданий по кластеру).
* Автор: Сергей Антропов — https://devops.org.ru
*/
function bindCreateJobsJournalToggle() {
const tbl = document.getElementById("tbl-jobs");
if (!tbl || tbl.getAttribute("data-jobs-job-log-bound") === "1") return;
tbl.setAttribute("data-jobs-job-log-bound", "1");
if (!tbl || tbl.getAttribute("data-create-jobs-log-bound") === "1") return;
tbl.setAttribute("data-create-jobs-log-bound", "1");
tbl.addEventListener(
"click",
function (ev) {
const btn = ev.target && ev.target.closest ? ev.target.closest("button.jobs-open-log-btn") : null;
if (!btn || !tbl.contains(btn)) return;
const jobId = btn.getAttribute("data-job-id");
if (!jobId) return;
ev.preventDefault();
openJobLogModal(jobId);
const btn = ev.target && ev.target.closest ? ev.target.closest("button.btn-journal-toggle") : null;
if (!btn || !tbl.contains(btn) || btn.disabled) return;
const tr = btn.closest("tr.journal-data-row");
if (!tr) return;
const trDetails = tr.nextElementSibling;
if (!trDetails || !trDetails.classList.contains("journal-details-row")) return;
const pre = trDetails.querySelector("pre.journal-log-pre");
if (!pre) return;
const open = trDetails.classList.contains("hidden");
trDetails.classList.toggle("hidden", !open);
pre.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
btn.textContent = open ? "Скрыть лог" : "Показать лог";
},
false,
);
}
/**
* Таблица «Последние задания» на /cluster-create: те же данные, что «Журнал» → задания
* (GET /journal/recent без фильтра кластера — объединение, новые первые; колонка «Кластер» = каталог на томе).
*/
async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg");
if (!tbody) return;
try {
const rows = await api("/jobs?limit=30");
const data = await api("/journal/recent?limit=30&offset=0");
const entries = Array.isArray(data.entries) ? data.entries : [];
const frag = document.createDocumentFragment();
rows.forEach(function (j) {
entries.forEach(function (e) {
const tr = document.createElement("tr");
const st = escapeHtml(j.status || "");
var kindTag = "";
if (j.kind === "start_cluster") kindTag = "[старт] ";
else if (j.kind === "start_cluster_reapply") kindTag = "[старт после правки] ";
else if (j.kind === "stop_containers") kindTag = "[стоп] ";
else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
var cellMsg = kindTag + (j.message || "").slice(0, 140);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
cellMsg =
kindTag +
j.progress_stage +
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
var msgCol =
"<div class=\"jobs-msg-primary\">" + escapeHtml(cellMsg) + "</div>";
if (j.status === "failed" || j.status === "cancelled") {
msgCol +=
"<button type=\"button\" class=\"btn-secondary jobs-open-log-btn\" data-job-id=\"" +
escapeHtml(j.job_id || "") +
"\">Журнал</button>";
}
const iso = j.created_at_utc || "";
const timeLabel = formatJobTimeUtc(iso);
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><time datetime=\"" +
escapeHtml(iso) +
"\" title=\"" +
escapeHtml(iso ? "UTC: " + iso : "") +
"\">" +
escapeHtml(timeLabel) +
"</time></td>" +
"<td>" +
escapeHtml(j.cluster_name || "—") +
"<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=\"" +
jobBadgeClass(j.status) +
escapeHtml(journalStatusPillClass(e.status)) +
"\">" +
st +
escapeHtml(e.status || "") +
"</span></td>" +
"<td class=\"jobs-msg-cell\">" +
msgCol +
"<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);
} else {
const btn = tr.querySelector(".btn-journal-toggle");
if (btn) {
btn.disabled = true;
btn.textContent = "Нет строк";
}
}
});
tbody.replaceChildren(frag);
if (msg) {
msg.textContent = rows.length
? ""
: "Заданий ещё не было (или контейнер перезапускали).";
if (!entries.length) {
msg.textContent =
"Нет записей в journal/jobs_history.json (записи появятся после завершения операций над кластерами).";
} else {
const total = typeof data.total === "number" ? data.total : entries.length;
msg.textContent =
total > entries.length
? "Показаны последние " + entries.length + " из " + total + " записей во всех журналах."
: "";
}
}
} catch (e) {
if (msg) msg.textContent = "Задания: " + e.message;
if (msg) msg.textContent = "Журнал заданий: " + e.message;
}
}
@@ -2298,8 +2478,10 @@
async function refreshLists() {
try {
await loadHealth();
if (isHomePage()) {
if (isHomePage() || isClustersListPage()) {
await loadStats();
}
if (isClustersListPage()) {
await loadClusters();
}
if (isCreatePage()) {
@@ -2390,15 +2572,15 @@
});
}
const btnClearJobs = document.getElementById("btn-jobs-clear");
if (btnClearJobs) {
btnClearJobs.addEventListener("click", function () {
clearCompletedJobs();
const btnJobsRefresh = document.getElementById("btn-jobs-refresh");
if (btnJobsRefresh) {
btnJobsRefresh.addEventListener("click", function () {
loadJobs();
});
}
if (document.getElementById("tbl-jobs")) {
bindJobsTableJobLogOpen();
bindCreateJobsJournalToggle();
}
const mClose = document.getElementById("modal-close");
@@ -2486,6 +2668,9 @@
await loadHealth();
if (isHomePage()) {
await loadStats();
}
if (isClustersListPage()) {
await loadStats();
await loadClusters();
}
if (isCreatePage()) {
@@ -2502,8 +2687,12 @@
if (isHomePage() && lm) {
lm.textContent = "Ошибка загрузки панели: " + errText;
}
if (isClustersListPage() && lm) {
lm.textContent = "Ошибка загрузки: " + errText;
}
} finally {
hideHomePageLoadingOverlay();
hideClustersPageLoadingOverlay();
}
})();
if (isCreatePage()) {