Веб-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
+1
View File
@@ -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
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"));
}
+59
View File
@@ -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
View File
@@ -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);
}