Веб-UI: темы, навигация API, статистика и доработки API

- Шапка: логотип Kubernetes, ссылка на главную, выпадающее меню API (Swagger/ReDoc/Health), переключатель светлой/тёмной темы (localStorage).
- Светлая тема в синей гамме; выравнивание кнопки темы в ряду с пилюлями.
- Дашборд: единая карточка ошибки health/stats, подсказка Docker/Podman, поле container_cli в GET /stats, total_workers_from_meta всегда число (0 без meta).
- Правки кластеров, job_store, compose, документация и частичные шаблоны.
This commit is contained in:
Sergey Antropoff
2026-04-04 07:58:19 +03:00
parent aa8003061e
commit 6f3daa33ec
20 changed files with 1885 additions and 451 deletions
+406 -125
View File
@@ -28,6 +28,20 @@
var currentPollJobId = null;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
var currentModalClusterName = null;
/** Кэш блоков cluster_resources по имени кластера (последний успешный GET /stats). */
var cachedClusterResourcesByName = {};
/** Успешный снимок GET /health — для ряда карточек среды + «Каталогов». */
var cachedHealthPayload = null;
/** local_cluster_dirs_count из GET /stats; показывается справа от Kubectl. */
var cachedLocalClusterDirsCount = null;
function isHomePage() {
return (document.body.getAttribute("data-dashboard-mode") || "home") === "home";
}
function isCreatePage() {
return document.body.getAttribute("data-dashboard-mode") === "create";
}
function formatApiError(data, fallback) {
if (!data) return fallback;
@@ -240,143 +254,363 @@
}, 4500);
}
function setStatusBannerClass(ok, degraded) {
const el = document.getElementById("status-banner");
/**
* Карточки среды в блоке статистики (как плитки «Кластеров в kind»).
* @param {string} label подпись снизу (CAPS в стиле stats-tile__label)
* @param {string} valueText крупный текст сверху
* @param {boolean} isOk
* @param {string | null} [detail] доп. строка мелким шрифтом
*/
function buildStatsEnvCard(label, valueText, isOk, detail) {
const tile = document.createElement("div");
tile.className =
"stats-tile stats-env-card " + (isOk ? "stats-env-card--ok" : "stats-env-card--bad");
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);
if (detail) {
const dEl = document.createElement("span");
dEl.className = "stats-env-card__detail muted";
dEl.textContent = detail.slice(0, 400);
tile.appendChild(dEl);
}
return tile;
}
/** Нормализованное имя CLI для подписей (docker → Docker, podman → Podman). */
function titleCaseContainerCli(cliRaw) {
const s = String(cliRaw || "docker").trim() || "docker";
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
}
/** Текст под заголовком «Статистика»: через какой движок идут узлы и метрики. */
function updateStatsEngineHint(cliRaw) {
const el = document.getElementById("stats-engine-hint");
if (!el) return;
var cls = "hero-panel-status muted ";
if (ok) cls += "ok";
else if (degraded) cls += "degraded";
else cls += "err";
el.className = cls;
const name = titleCaseContainerCli(cliRaw);
el.textContent =
"Узлы kind (контейнеры control-plane и worker) и метрики ниже собираются через " + name + ".";
el.classList.remove("hidden");
}
function hideStatsEngineHint() {
const el = document.getElementById("stats-engine-hint");
if (!el) return;
el.classList.add("hidden");
el.textContent = "";
}
/**
* Скрыть ряд среды и метрик, показать одну карточку ошибки на всю карточку «Статистика».
* @param {"Health"|"Статистика"} sourceLabel подпись под «Ошибка»
*/
function coverStatsCardWithError(sourceLabel, message) {
hideStatsEngineHint();
const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid");
if (failureWrap) {
failureWrap.classList.remove("hidden");
failureWrap.replaceChildren();
const tile = document.createElement("div");
tile.className = "stats-tile stats-env-card stats-env-card--bad stats-failure-card";
const valEl = document.createElement("span");
valEl.className = "stats-tile__value";
valEl.textContent = "Ошибка";
const labEl = document.createElement("span");
labEl.className = "stats-tile__label";
labEl.textContent = sourceLabel;
const dEl = document.createElement("span");
dEl.className = "stats-env-card__detail muted";
dEl.textContent = message || "";
tile.appendChild(valEl);
tile.appendChild(labEl);
tile.appendChild(dEl);
failureWrap.appendChild(tile);
}
if (env) env.classList.add("hidden");
if (metricsGrid) metricsGrid.classList.add("hidden");
}
/** Показать снова ряд среды и сетку метрик (после успешных запросов). */
function revealStatsCardBody() {
const failureWrap = document.getElementById("stats-failure-wrap");
const env = document.getElementById("stats-env");
const metricsGrid = document.getElementById("stats-grid");
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;
}
/**
* Ряд: Docker API · Kind · Kubectl · Каталогов (число из /stats).
*/
function paintStatsEnvRow() {
const el = document.getElementById("stats-env");
if (!el) return;
if (!cachedHealthPayload) {
return;
}
el.className = "stats-env-cards";
el.replaceChildren();
const h = cachedHealthPayload;
const cliRaw = String(h.container_cli || "docker").trim() || "docker";
const cliTitle = titleCaseContainerCli(cliRaw);
const apiOk = !!h.container_engine_ok;
el.appendChild(
buildStatsEnvCard(
cliTitle + " API",
apiOk ? "OK" : "Недоступно",
apiOk,
!apiOk && h.container_engine_detail ? String(h.container_engine_detail) : null,
),
);
const kindOk = !!h.kind_in_path;
el.appendChild(buildStatsEnvCard("Kind", kindOk ? "OK" : "нет в PATH", kindOk, null));
const kubectlOk = !!h.kubectl_in_path;
el.appendChild(
buildStatsEnvCard("Kubectl", kubectlOk ? "OK" : "нет в PATH", kubectlOk, null),
);
const dirVal =
cachedLocalClusterDirsCount != null ? String(cachedLocalClusterDirsCount) : "—";
el.appendChild(buildStatsEnvMetricCard("Каталогов", dirVal));
}
async function loadHealth() {
const el = document.getElementById("status-banner");
const el = document.getElementById("stats-env");
if (!el) return;
try {
const h = await api("/health");
const ok =
h.status === "ok" && h.container_engine_ok && h.kind_in_path && h.kubectl_in_path;
setStatusBannerClass(ok, !ok);
var lines = "<strong>Среда:</strong> ";
lines += escapeHtml(String(h.container_cli || "?"));
lines += " → " + (h.container_engine_ok ? "API OK" : "API недоступен");
lines += " · kind: " + (h.kind_in_path ? "да" : "нет");
lines += " · kubectl: " + (h.kubectl_in_path ? "да" : "нет");
if (!h.container_engine_ok && h.container_engine_detail) {
lines +=
"<br/><span class=\"muted\">" +
escapeHtml(String(h.container_engine_detail).slice(0, 400)) +
"</span>";
}
el.innerHTML = lines;
cachedHealthPayload = h;
revealStatsCardBody();
paintStatsEnvRow();
updateStatsEngineHint(h.container_cli);
} catch (e) {
setStatusBannerClass(false, false);
el.textContent = "Не удалось запросить health: " + e.message;
cachedHealthPayload = null;
el.replaceChildren();
coverStatsCardWithError("Health", e.message || String(e));
}
}
/**
* Блок «Ресурсы узлов» из cluster_resources (CPU/RAM/I/O из docker stats).
* SVG-донаты по полю aggregate_cluster_resources (главная страница).
* @param {object | null | undefined} agg
* @param {string | null | undefined} resourcesError
* @param {string | undefined} containerCliRaw имя CLI из /stats (docker/podman) для подписи доната RAM
*/
function renderStatsResources(s) {
const wrap = document.getElementById("stats-resources");
if (!wrap) return;
wrap.replaceChildren();
if (s.cluster_resources_error) {
function renderAggregateDonuts(agg, resourcesError, containerCliRaw) {
const root = document.getElementById("aggregate-donuts");
if (!root) return;
root.replaceChildren();
if (resourcesError) {
const p = document.createElement("p");
p.className = "muted stats-resources-msg";
p.textContent = "Ресурсы узлов: " + s.cluster_resources_error;
wrap.appendChild(p);
p.className = "muted aggregate-donuts-placeholder";
p.textContent = "Метрики узлов недоступны: " + resourcesError;
root.appendChild(p);
return;
}
const list = s.cluster_resources || [];
if (!list.length) {
if (!agg || !agg.nodes_count) {
const p2 = document.createElement("p");
p2.className = "muted stats-resources-msg";
p2.textContent = "Нет кластеров в kind — метрики узлов пусты.";
wrap.appendChild(p2);
p2.className = "muted aggregate-donuts-placeholder";
p2.textContent = "Нет запущенных узлов — диаграммы появятся после старта кластера.";
root.appendChild(p2);
return;
}
const h3 = document.createElement("h3");
h3.className = "stats-resources-heading";
h3.textContent = "Ресурсы узлов (CPU, RAM, I/O)";
wrap.appendChild(h3);
list.forEach(function (cr) {
const sec = document.createElement("section");
sec.className = "stats-cluster-res";
const h4 = document.createElement("h4");
h4.className = "stats-cluster-res__title";
h4.textContent = cr.cluster_name;
sec.appendChild(h4);
const nodes = cr.nodes || [];
if (!nodes.length) {
const pn = document.createElement("p");
pn.className = "muted stats-cluster-res__note";
pn.textContent = cr.note || "Нет запущенных контейнеров узлов.";
sec.appendChild(pn);
} else {
nodes.forEach(function (n) {
const row = document.createElement("div");
row.className = "stats-node-res";
const code = document.createElement("code");
code.textContent = n.container_name;
row.appendChild(code);
const ul = document.createElement("ul");
ul.className = "stats-node-res__metrics";
function addMetric(label, val) {
if (val == null || val === "") return;
const li = document.createElement("li");
const strong = document.createElement("strong");
strong.textContent = label + ": ";
li.appendChild(strong);
li.appendChild(document.createTextNode(String(val)));
ul.appendChild(li);
}
addMetric("CPU", n.cpu_percent);
addMetric("Память", n.memory_usage);
addMetric("% RAM", n.memory_percent);
addMetric("Сеть", n.net_io);
addMetric("Диск", n.block_io);
addMetric("PID", n.pids != null ? String(n.pids) : null);
row.appendChild(ul);
sec.appendChild(row);
});
function donutItem(title, ringPct, centerLabel, subLine) {
const p = Math.max(0, Math.min(100, Number(ringPct) || 0));
const wrap = document.createElement("div");
wrap.className = "donut-item";
wrap.setAttribute("role", "group");
wrap.setAttribute("aria-label", title);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 110 110");
svg.setAttribute("class", "donut-svg");
svg.setAttribute("aria-hidden", "true");
const track = document.createElementNS("http://www.w3.org/2000/svg", "circle");
track.setAttribute("class", "donut-track");
track.setAttribute("cx", "55");
track.setAttribute("cy", "55");
track.setAttribute("r", "40");
track.setAttribute("fill", "none");
track.setAttribute("stroke-width", "9");
track.setAttribute("pathLength", "100");
const fill = document.createElementNS("http://www.w3.org/2000/svg", "circle");
fill.setAttribute("class", "donut-fill");
fill.setAttribute("cx", "55");
fill.setAttribute("cy", "55");
fill.setAttribute("r", "40");
fill.setAttribute("fill", "none");
fill.setAttribute("stroke-width", "9");
fill.setAttribute("pathLength", "100");
fill.setAttribute("stroke-dasharray", p + " " + (100 - p));
fill.setAttribute("transform", "rotate(-90 55 55)");
svg.appendChild(track);
svg.appendChild(fill);
wrap.appendChild(svg);
const meta = document.createElement("div");
meta.className = "donut-meta";
const tEl = document.createElement("span");
tEl.className = "donut-meta__title";
tEl.textContent = title;
meta.appendChild(tEl);
const vEl = document.createElement("span");
vEl.className = "donut-meta__val";
vEl.textContent = centerLabel || "—";
meta.appendChild(vEl);
if (subLine) {
const sEl = document.createElement("span");
sEl.className = "donut-meta__sub muted";
sEl.textContent = subLine;
meta.appendChild(sEl);
}
wrap.appendChild(sec);
wrap.appendChild(meta);
return wrap;
}
const ramEngine = titleCaseContainerCli(containerCliRaw);
const grid = document.createElement("div");
grid.className = "donut-grid";
grid.appendChild(
donutItem("CPU (ср.)", agg.cpu_ring, String(agg.cpu_label || "—"), "узлов: " + agg.nodes_count),
);
grid.appendChild(
donutItem("% RAM (" + ramEngine + ")", agg.memory_percent_ring, String(agg.memory_percent_label || "—"), ""),
);
grid.appendChild(
donutItem("RAM used/limit", agg.memory_used_ratio_ring, String(agg.memory_used_ratio_label || "—"), ""),
);
grid.appendChild(
donutItem("Сеть Σ", agg.network_ring, String(agg.network_label || "—"), "накопит. I/O"),
);
grid.appendChild(
donutItem("Диск Σ", agg.disk_ring, String(agg.disk_label || "—"), "накопит. I/O"),
);
root.appendChild(grid);
}
/**
* Раскрывающийся блок метрик узлов под строкой таблицы кластеров.
* @param {string} clusterName
*/
function buildClusterResourcesDetails(clusterName) {
const cr = cachedClusterResourcesByName[clusterName];
const details = document.createElement("details");
details.className = "cluster-nodes-resources";
const summ = document.createElement("summary");
summ.textContent = "Ресурсы узлов (CPU, RAM, сеть, диск)";
details.appendChild(summ);
if (!cr || !(cr.nodes && cr.nodes.length)) {
const p = document.createElement("p");
p.className = "muted cluster-nodes-resources__empty";
p.textContent = cr && cr.note ? cr.note : "Нет данных: узлы остановлены или метрики недоступны.";
details.appendChild(p);
return details;
}
cr.nodes.forEach(function (n) {
const row = document.createElement("div");
row.className = "stats-node-res cluster-row-node-res";
const code = document.createElement("code");
code.textContent = n.container_name;
row.appendChild(code);
const ul = document.createElement("ul");
ul.className = "stats-node-res__metrics";
function addMetric(label, val) {
if (val == null || val === "") return;
const li = document.createElement("li");
const strong = document.createElement("strong");
strong.textContent = label + ": ";
li.appendChild(strong);
li.appendChild(document.createTextNode(String(val)));
ul.appendChild(li);
}
addMetric("CPU", n.cpu_percent);
addMetric("Память", n.memory_usage);
addMetric("% RAM", n.memory_percent);
addMetric("Сеть", n.net_io);
addMetric("Диск", n.block_io);
addMetric("PID", n.pids != null ? String(n.pids) : null);
row.appendChild(ul);
details.appendChild(row);
});
return details;
}
async function loadStats() {
const dl = document.getElementById("stats-dl");
const errEl = document.getElementById("stats-err");
if (!dl) return;
const grid = document.getElementById("stats-grid");
if (!grid) return;
try {
const s = await api("/stats");
errEl.classList.add("hidden");
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],
["Локальных каталогов", s.local_cluster_dirs_count],
["Сумма workers (meta)", s.total_workers_from_meta != null ? s.total_workers_from_meta : "—"],
["Сумма workers (meta)", s.total_workers_from_meta ?? 0],
["Заданий в памяти", s.jobs_total],
["Заданий с ошибкой", s.jobs_recent_failed],
];
const frag = document.createDocumentFragment();
grid.replaceChildren();
rows.forEach(function (kv) {
const dt = document.createElement("dt");
dt.textContent = kv[0];
const dd = document.createElement("dd");
dd.textContent = String(kv[1]);
frag.appendChild(dt);
frag.appendChild(dd);
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);
});
dl.replaceChildren(frag);
renderStatsResources(s);
cachedClusterResourcesByName = {};
(s.cluster_resources || []).forEach(function (cr) {
cachedClusterResourcesByName[cr.cluster_name] = cr;
});
renderAggregateDonuts(
s.aggregate_cluster_resources,
s.cluster_resources_error,
s.container_cli || "docker",
);
} catch (e) {
errEl.textContent = "Статистика: " + e.message;
errEl.classList.remove("hidden");
const wrap = document.getElementById("stats-resources");
if (wrap) wrap.replaceChildren();
coverStatsCardWithError("Статистика", e.message || String(e));
grid.replaceChildren();
cachedClusterResourcesByName = {};
const donuts = document.getElementById("aggregate-donuts");
if (donuts) {
donuts.replaceChildren();
const ph = document.createElement("p");
ph.className = "muted aggregate-donuts-placeholder";
ph.textContent = "Сводка по узлам недоступна (ошибка загрузки статистики).";
donuts.appendChild(ph);
}
}
}
@@ -430,6 +664,19 @@
const ver = (c.meta && (c.meta.kubernetes_version_tag || c.meta.node_image)) || "—";
const wn = c.meta && c.meta.worker_nodes != null ? c.meta.worker_nodes : "—";
const nameEsc = escapeHtml(c.name);
var stateCell = "";
if (c.registered_in_kind) {
if (c.kind_nodes_running) {
stateCell =
"<span class=\"badge badge-ok\" title=\"Контейнеры узлов запущены\">Запущен</span>";
} else {
stateCell =
"<span class=\"badge badge-stopped\" title=\"Зарегистрирован в kind, узлы остановлены\">Остановлен</span>";
}
} else {
stateCell =
"<span class=\"muted\" title=\"Нет в kind — только локальный каталог или кластер удалён из kind\">—</span>";
}
tr.innerHTML =
"<td><code class=\"cluster-name\">" +
nameEsc +
@@ -446,6 +693,9 @@
"<td>" +
escapeHtml(String(wn)) +
"</td>" +
"<td>" +
stateCell +
"</td>" +
"<td class=\"actions\"><span class=\"actions-toolbar\"></span></td>";
const td = tr.querySelector(".actions-toolbar");
td.appendChild(
@@ -459,18 +709,7 @@
false,
),
);
td.appendChild(
iconActionButton(
ICONS.play,
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
"",
function () {
startCluster(c.name);
},
false,
),
);
if (c.registered_in_kind) {
if (c.registered_in_kind && c.kind_nodes_running) {
td.appendChild(
iconActionButton(
ICONS.stop,
@@ -482,6 +721,18 @@
false,
),
);
} else {
td.appendChild(
iconActionButton(
ICONS.play,
"Старт: если кластер в kind — запуск контейнеров; иначе при наличии kind-config.yaml — kind create в фоне",
"",
function () {
startCluster(c.name);
},
false,
),
);
}
td.appendChild(
iconActionButton(
@@ -510,6 +761,16 @@
),
);
frag.appendChild(tr);
if (isHomePage()) {
const trExp = document.createElement("tr");
trExp.className = "cluster-resources-expand-row";
const tdExp = document.createElement("td");
tdExp.colSpan = 7;
tdExp.className = "cluster-resources-expand-cell";
tdExp.appendChild(buildClusterResourcesDetails(c.name));
trExp.appendChild(tdExp);
frag.appendChild(trExp);
}
});
tbody.replaceChildren(frag);
bindActionTooltipHosts(document.getElementById("tbl-clusters"));
@@ -968,11 +1229,19 @@
pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
}
function refreshLists() {
loadHealth();
loadStats();
loadClusters();
loadJobs();
async function refreshLists() {
try {
await loadHealth();
if (isHomePage()) {
await loadStats();
await loadClusters();
}
if (isCreatePage()) {
await loadJobs();
}
} catch (e) {
/* loadHealth/loadStats/loadClusters сами сообщают об ошибках в UI */
}
}
function init() {
@@ -1078,11 +1347,23 @@
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
loadHealth();
loadStats();
loadVersions();
loadClusters();
loadJobs();
(async function bootstrap() {
try {
await loadHealth();
if (isHomePage()) {
await loadStats();
await loadClusters();
}
if (isCreatePage()) {
await loadJobs();
}
} catch (e) {
/* см. сообщения в блоках */
}
})();
if (isCreatePage()) {
loadVersions();
}
bindActionTooltipHosts(document.getElementById("modal-overlay"));
}