Веб-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:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true"><path fill="#326ce5" d="M10.204 14.35l.007.01-.999 2.413a5.171 5.171 0 0 1-2.075-2.597l2.578-.437.004.005a.44.44 0 0 1 .484.606zm-.833-2.129a.44.44 0 0 0 .173-.756l.002-.011L7.585 9.7a5.143 5.143 0 0 0-.73 3.255l2.514-.725.002-.009zm1.145-1.98a.44.44 0 0 0 .699-.337l.01-.005.15-2.62a5.144 5.144 0 0 0-3.01 1.442l2.147 1.523.004-.002zm.76 2.75l.723.349.722-.347.18-.78-.5-.623h-.804l-.5.623.179.779zm1.5-3.095a.44.44 0 0 0 .7.336l.008.003 2.134-1.513a5.188 5.188 0 0 0-2.992-1.442l.148 2.615.002.001zm10.876 5.97l-5.773 7.181a1.6 1.6 0 0 1-1.248.594l-9.261.003a1.6 1.6 0 0 1-1.247-.596l-5.776-7.18a1.583 1.583 0 0 1-.307-1.34L2.1 5.573c.108-.47.425-.864.863-1.073L11.305.513a1.606 1.606 0 0 1 1.385 0l8.345 3.985c.438.209.755.604.863 1.073l2.062 8.955c.108.47-.005.963-.308 1.34zm-3.289-2.057c-.042-.01-.103-.026-.145-.034-.174-.033-.315-.025-.479-.038-.35-.037-.638-.067-.895-.148-.105-.04-.18-.165-.216-.216l-.201-.059a6.45 6.45 0 0 0-.105-2.332 6.465 6.465 0 0 0-.936-2.163c.052-.047.15-.133.177-.159.008-.09.001-.183.094-.282.197-.185.444-.338.743-.522.142-.084.273-.137.415-.242.032-.024.076-.062.11-.089.24-.191.295-.52.123-.736-.172-.216-.506-.236-.745-.045-.034.027-.08.062-.111.088-.134.116-.217.23-.33.35-.246.25-.45.458-.673.609-.097.056-.239.037-.303.033l-.19.135a6.545 6.545 0 0 0-4.146-2.003l-.012-.223c-.065-.062-.143-.115-.163-.25-.022-.268.015-.557.057-.905.023-.163.061-.298.068-.475.001-.04-.001-.099-.001-.142 0-.306-.224-.555-.5-.555-.275 0-.499.249-.499.555l.001.014c0 .041-.002.092 0 .128.006.177.044.312.067.475.042.348.078.637.056.906a.545.545 0 0 1-.162.258l-.012.211a6.424 6.424 0 0 0-4.166 2.003 8.373 8.373 0 0 1-.18-.128c-.09.012-.18.04-.297-.029-.223-.15-.427-.358-.673-.608-.113-.12-.195-.234-.329-.349-.03-.026-.077-.062-.111-.088a.594.594 0 0 0-.348-.132.481.481 0 0 0-.398.176c-.172.216-.117.546.123.737l.007.005.104.083c.142.105.272.159.414.242.299.185.546.338.743.522.076.082.09.226.1.288l.16.143a6.462 6.462 0 0 0-1.02 4.506l-.208.06c-.055.072-.133.184-.215.217-.257.081-.546.11-.895.147-.164.014-.305.006-.48.039-.037.007-.09.02-.133.03l-.004.002-.007.002c-.295.071-.484.342-.423.608.061.267.349.429.645.365l.007-.001.01-.003.129-.029c.17-.046.294-.113.448-.172.33-.118.604-.217.87-.256.112-.009.23.069.288.101l.217-.037a6.5 6.5 0 0 0 2.88 3.596l-.09.218c.033.084.069.199.044.282-.097.252-.263.517-.452.813-.091.136-.185.242-.268.399-.02.037-.045.095-.064.134-.128.275-.034.591.213.71.248.12.556-.007.69-.282v-.002c.02-.039.046-.09.062-.127.07-.162.094-.301.144-.458.132-.332.205-.68.387-.897.05-.06.13-.082.215-.105l.113-.205a6.453 6.453 0 0 0 4.609.012l.106.192c.086.028.18.042.256.155.136.232.229.507.342.84.05.156.074.295.145.457.016.037.043.09.062.129.133.276.442.402.69.282.247-.118.341-.435.213-.71-.02-.039-.045-.096-.065-.134-.083-.156-.177-.261-.268-.398-.19-.296-.346-.541-.443-.793-.04-.13.007-.21.038-.294-.018-.022-.059-.144-.083-.202a6.499 6.499 0 0 0 2.88-3.622c.064.01.176.03.213.038.075-.05.144-.114.28-.104.266.039.54.138.87.256.154.06.277.128.448.173.036.01.088.019.13.028l.009.003.007.001c.297.064.584-.098.645-.365.06-.266-.128-.537-.423-.608zM16.4 9.701l-1.95 1.746v.005a.44.44 0 0 0 .173.757l.003.01 2.526.728a5.199 5.199 0 0 0-.108-1.674A5.208 5.208 0 0 0 16.4 9.7zm-4.013 5.325a.437.437 0 0 0-.404-.232.44.44 0 0 0-.372.233h-.002l-1.268 2.292a5.164 5.164 0 0 0 3.326.003l-1.27-2.296h-.01zm1.888-1.293a.44.44 0 0 0-.27.036.44.44 0 0 0-.214.572l-.003.004 1.01 2.438a5.15 5.15 0 0 0 2.081-2.615l-2.6-.44-.004.005z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
+406
-125
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Переключение светлой/тёмной темы: data-theme на <html>, ключ localStorage.
|
||||
* Начальная тема задаётся инлайн-скриптом в base.html (без мигания).
|
||||
*
|
||||
* Автор: Сергей Антропов
|
||||
* Сайт: https://devops.org.ru
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var STORAGE_KEY = "kind_k8s_theme";
|
||||
|
||||
function getTheme() {
|
||||
var t = document.documentElement.getAttribute("data-theme");
|
||||
return t === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
var next = theme === "light" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch (e) {
|
||||
/* приватный режим и т.п. */
|
||||
}
|
||||
syncToggleButton();
|
||||
}
|
||||
|
||||
function syncToggleButton() {
|
||||
var btn = document.getElementById("theme-toggle");
|
||||
if (!btn) return;
|
||||
var dark = getTheme() === "dark";
|
||||
btn.setAttribute("aria-pressed", dark ? "true" : "false");
|
||||
if (dark) {
|
||||
btn.setAttribute("aria-label", "Включить светлую тему");
|
||||
btn.setAttribute("title", "Светлая тема");
|
||||
} else {
|
||||
btn.setAttribute("aria-label", "Включить тёмную тему");
|
||||
btn.setAttribute("title", "Тёмная тема");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(getTheme() === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
function init() {
|
||||
var btn = document.getElementById("theme-toggle");
|
||||
if (btn) btn.addEventListener("click", toggleTheme);
|
||||
syncToggleButton();
|
||||
}
|
||||
|
||||
/* defer: скрипт в <head> выполняется после разбора документа — кнопка уже есть. */
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
+546
-96
@@ -1,25 +1,28 @@
|
||||
/* Минимальные стили веб-интерфейса Kind Clusters Dashboard.
|
||||
Автор: Сергей Антропов — https://devops.org.ru */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
/* Тема: тёмная по умолчанию; светлая — html[data-theme="light"]. Переключатель в шапке + localStorage. */
|
||||
html {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
}
|
||||
html:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1419;
|
||||
--fg: #e7ecf1;
|
||||
--muted: #8b98a5;
|
||||
--card: #1a2332;
|
||||
--accent: #3b82f6;
|
||||
--border: #2f3d52;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--fg: #111827;
|
||||
--muted: #6b7280;
|
||||
--card: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
}
|
||||
/* Светлая тема: основной текст и акценты в синей гамме (как тёмная), без «чистого» чёрного */
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #eef2fb;
|
||||
--fg: #172c46;
|
||||
--muted: #5a6f8f;
|
||||
--card: #ffffff;
|
||||
--border: #b8c9e8;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -70,13 +73,26 @@ body.modal-open {
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-logo {
|
||||
.nav-brand-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.nav-brand-link:hover .nav-title {
|
||||
color: var(--accent);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.nav-brand-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.nav-logo-img {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.nav-title {
|
||||
font-weight: 700;
|
||||
@@ -85,12 +101,163 @@ body.modal-open {
|
||||
.nav-tag {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
/* Шапка: один flex-ряд, все пилюли + API + тема с align-items: center */
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.nav-links.top-nav-actions {
|
||||
justify-content: flex-end;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Выпадающее меню «API»: Swagger, ReDoc, Health в отдельном окне */
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.nav-dropdown__trigger {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
.nav-dropdown__trigger::after {
|
||||
content: "▾";
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.72em;
|
||||
opacity: 0.85;
|
||||
display: inline-block;
|
||||
transition: transform 0.18s ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.nav-dropdown--open .nav-dropdown__trigger::after {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
.nav-dropdown__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
right: 0;
|
||||
z-index: 160;
|
||||
min-width: 11rem;
|
||||
margin: 0;
|
||||
padding: 0.35rem 0;
|
||||
list-style: none;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
html[data-theme="light"] .nav-dropdown__menu {
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.nav-dropdown__menu[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.nav-dropdown__menu li {
|
||||
margin: 0;
|
||||
}
|
||||
.nav-dropdown__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.48rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--fg);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.nav-dropdown__link:hover,
|
||||
.nav-dropdown__link:focus-visible {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
.nav-dropdown__link.nav-pill--ext::after {
|
||||
content: "↗";
|
||||
margin-left: auto;
|
||||
padding-left: 0.5rem;
|
||||
font-size: 0.72em;
|
||||
opacity: 0.72;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Переключатель светлая / тёмная тема (в конце шапки, высота как у пилюль) */
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.75rem;
|
||||
min-width: 2.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
html[data-theme="light"] .theme-toggle {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
color: var(--accent);
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 2px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.theme-toggle__icon {
|
||||
display: block;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
html[data-theme="light"] .theme-toggle__icon--sun {
|
||||
display: none;
|
||||
}
|
||||
html[data-theme="light"] .theme-toggle__icon--moon {
|
||||
display: block;
|
||||
}
|
||||
html:not([data-theme="light"]) .theme-toggle__icon--sun {
|
||||
display: block;
|
||||
}
|
||||
html:not([data-theme="light"]) .theme-toggle__icon--moon {
|
||||
display: none;
|
||||
}
|
||||
/* Визуально кнопка темы сидела ниже пилюль (<a> vs <button>); поднимаем на 0.75rem */
|
||||
.theme-toggle--nav-end {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding-left: 0.65rem;
|
||||
padding-right: 0.65rem;
|
||||
min-height: calc(0.8125rem * 1.35 + 0.84rem);
|
||||
box-sizing: border-box;
|
||||
transform: translateY(-0.3rem);
|
||||
}
|
||||
|
||||
/* Пункты меню: компактные «пилюли» */
|
||||
.nav-link.nav-pill {
|
||||
@@ -135,11 +302,15 @@ body.modal-open {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
color: #93c5fd;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) {
|
||||
color: #1e40af;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
html[data-theme="light"] .nav-link.nav-pill {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
color: var(--fg);
|
||||
}
|
||||
html[data-theme="light"] .nav-link.nav-pill.nav-pill--active:not(.nav-pill--ext) {
|
||||
color: #1d4ed8;
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
border-color: rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
|
||||
/* Внешние окна: иконка «новое окно» */
|
||||
@@ -168,6 +339,14 @@ body.modal-open {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Страница /documentation: основной блок и подвал на всю ширину окна */
|
||||
.doc-layout-full .app-main,
|
||||
.doc-layout-full .app-footer {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
@@ -192,7 +371,7 @@ body.modal-open {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Верхний ряд: панель управления слева, статистика справа */
|
||||
/* Верхний ряд: CTA «Создать кластер» и статистика по ~50% ширины */
|
||||
.top-dashboard-row {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -202,69 +381,173 @@ body.modal-open {
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.top-dashboard-row {
|
||||
grid-template-columns: 1fr minmax(17rem, 24rem);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.top-dashboard-row .hero-panel {
|
||||
/* Левая карточка — переход к форме создания */
|
||||
.cta-hero-card {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: min(16rem, 42vh);
|
||||
}
|
||||
.cta-hero-inner {
|
||||
text-align: center;
|
||||
padding: 1.35rem 1.25rem;
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.cta-hero-card .page-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.35rem, 2.5vw, 1.65rem);
|
||||
}
|
||||
.cta-hero-lead {
|
||||
margin: 0 0 2.15rem;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
max-width: 22rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.cta-hero-inner .btn-cta-large {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.stats-side-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(85vh, 52rem);
|
||||
overflow: hidden;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-side-card h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* Пояснение: узлы kind и метрики идут через Docker или Podman (CONTAINER_CLI) */
|
||||
.stats-engine-hint {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* Одна карточка «Ошибка» на всю ширину блока статистики (health или /stats) */
|
||||
.stats-failure-wrap {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-dl--compact {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
.stats-failure-card {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 4.75rem;
|
||||
padding: 0.85rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Метрики узлов kind (docker stats) под сводкой */
|
||||
.stats-resources {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--border);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
font-size: 0.78rem;
|
||||
/* Среда: Docker API · Kind · Kubectl · Каталогов — один ряд, 4 колонки */
|
||||
.stats-env-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.65rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stats-resources-heading {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
@media (max-width: 520px) {
|
||||
.stats-env-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.stats-resources-msg {
|
||||
.stats-env-placeholder {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.stats-cluster-res {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.stats-cluster-res:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.stats-cluster-res__title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.stats-cluster-res__note {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
.stats-env-card {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.65rem 0.4rem;
|
||||
}
|
||||
.stats-env-card--ok {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
.stats-env-card--bad {
|
||||
border-color: rgba(239, 68, 68, 0.45);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.stats-env-card--bad .stats-tile__value {
|
||||
color: #f87171;
|
||||
}
|
||||
html[data-theme="light"] .stats-env-card--bad .stats-tile__value {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.stats-env-card__detail {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.3;
|
||||
margin-top: 0.35rem;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
.stats-env-card--metric {
|
||||
border-color: var(--border);
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
html[data-theme="light"] .stats-env-card--metric {
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
}
|
||||
|
||||
/* Метрики: по 4 карточки в ряд (4 метрики — один ряд) */
|
||||
.stats-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.65rem;
|
||||
flex: 1 1 auto;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stats-metrics-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stats-tile {
|
||||
border-radius: 10px;
|
||||
padding: 0.72rem 0.45rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
justify-content: center;
|
||||
min-height: 4.1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
html[data-theme="light"] .stats-tile {
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
}
|
||||
.stats-tile__value {
|
||||
font-size: 1.28rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent);
|
||||
line-height: 1.15;
|
||||
}
|
||||
.stats-tile__label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.045em;
|
||||
color: var(--muted);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Метрики узла (общие стили: донаты на главной и раскрытие под строкой кластера) */
|
||||
.stats-node-res {
|
||||
margin-bottom: 0.45rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
@@ -290,10 +573,124 @@ body.modal-open {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.stats-node-res {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
html[data-theme="light"] .stats-node-res {
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
}
|
||||
|
||||
.btn-cta-large {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: min(22rem, 100%);
|
||||
margin-top: 0;
|
||||
padding: 0.95rem 2.25rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 650;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
a.btn-cta-large:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Сводка-денаты по узлам */
|
||||
.aggregate-donuts-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.donuts-card-title {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.donut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
||||
gap: 1rem 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.donut-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.donut-svg {
|
||||
width: 6.5rem;
|
||||
height: 6.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.donut-track {
|
||||
stroke: var(--border);
|
||||
}
|
||||
.donut-fill {
|
||||
stroke: var(--accent);
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.donut-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
max-width: 11rem;
|
||||
}
|
||||
.donut-meta__title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.donut-meta__val {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
.donut-meta__sub {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.aggregate-donuts-placeholder {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
/* Вторая строка таблицы: раскрытие ресурсов */
|
||||
.cluster-resources-expand-row td {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
.cluster-resources-expand-cell {
|
||||
padding: 0 0.75rem 0.65rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
html[data-theme="light"] .cluster-resources-expand-cell {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
.cluster-nodes-resources {
|
||||
margin: 0;
|
||||
}
|
||||
.cluster-nodes-resources summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
padding: 0.35rem 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
.cluster-nodes-resources__empty {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cluster-row-node-res {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.cluster-create-hero {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.create-cluster-card {
|
||||
@@ -440,11 +837,6 @@ body.modal-open {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.git-hint {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -492,6 +884,9 @@ body.modal-open {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
html[data-theme="light"] .badge {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.badge-ok {
|
||||
border-color: #15803d;
|
||||
color: #4ade80;
|
||||
@@ -508,6 +903,10 @@ body.modal-open {
|
||||
border-color: #6b7280;
|
||||
color: #d1d5db;
|
||||
}
|
||||
.badge-stopped {
|
||||
border-color: #b45309;
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.cluster-name {
|
||||
font-weight: 600;
|
||||
@@ -521,6 +920,9 @@ body.modal-open {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
html[data-theme="light"] .job-details {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
.job-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
@@ -536,6 +938,27 @@ body.modal-open {
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.modal-head--cluster {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.65rem;
|
||||
text-align: center;
|
||||
}
|
||||
.modal-title-text--center {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding-right: 0;
|
||||
flex: none;
|
||||
}
|
||||
.modal-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem 1rem;
|
||||
}
|
||||
.modal-dl-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -543,6 +966,9 @@ body.modal-open {
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
.modal-toolbar .modal-dl-wrap {
|
||||
margin: 0;
|
||||
}
|
||||
.modal-dl-hint {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -562,6 +988,7 @@ body.modal-open {
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
@@ -689,6 +1116,9 @@ button:hover {
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
}
|
||||
html[data-theme="light"] .mono {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
@@ -886,13 +1316,11 @@ button.btn-small {
|
||||
border-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.icon-btn {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.icon-btn--danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
html[data-theme="light"] .icon-btn {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
html[data-theme="light"] .icon-btn--danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Модальное окно «Состояние кластера» */
|
||||
@@ -901,9 +1329,9 @@ button.btn-small {
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
padding: 1.25rem 1rem;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -916,18 +1344,25 @@ button.btn-small {
|
||||
}
|
||||
.modal-box--confirm {
|
||||
max-width: 24rem;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-modal-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-modal-message {
|
||||
margin: 0 0 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-modal-actions {
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem 0.85rem;
|
||||
margin-top: 1.15rem;
|
||||
}
|
||||
.confirm-modal-actions button {
|
||||
margin-top: 0;
|
||||
@@ -938,8 +1373,12 @@ button.btn-small {
|
||||
border-radius: 10px;
|
||||
max-width: 52rem;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
max-height: calc(100dvh - 2.5rem);
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.15rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
margin: auto;
|
||||
}
|
||||
.modal-box h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
@@ -960,11 +1399,9 @@ a.btn-danger {
|
||||
color: #f87171;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
button.btn-danger,
|
||||
a.btn-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
html[data-theme="light"] button.btn-danger,
|
||||
html[data-theme="light"] a.btn-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
button.btn-danger:hover {
|
||||
filter: brightness(1.12);
|
||||
@@ -976,6 +1413,12 @@ button.btn-danger:hover {
|
||||
margin: 0 auto 0.65rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.doc-layout-full .doc-breadcrumbs {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.doc-breadcrumbs__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1017,6 +1460,12 @@ button.btn-danger:hover {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.doc-layout-full .readme-doc-shell {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.readme-doc-loading {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -1135,11 +1584,12 @@ button.btn-danger:hover {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.readme-section-card.markdown-body code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.readme-section-card.markdown-body pre {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
html[data-theme="light"] .readme-section-card.markdown-body code {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
html[data-theme="light"] .readme-section-card.markdown-body pre {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
html[data-theme="light"] .readme-section-card.markdown-body th {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user