Веб-UI FastAPI, REST API v1, интерактивный setup без env.example
- Дашборд (Jinja2 + static), управление кластерами kind, задания и kubeconfig. - API: health, stats, clusters CRUD, versions, jobs; документация app/docs/api_routes.md. - Docker Compose: том app, uvicorn reload, KIND_K8S_PATCH_KUBECONFIG по умолчанию 1. - setup_env_interactive.py: список переменных в скрипте, удалён env.example. - Makefile: явный префикс docker/podman; прочие правки CLI и ядра кластеров.
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Панель управления кластерами kind (REST /api/v1).
|
||||
*
|
||||
* Автор: Сергей Антропов
|
||||
* Сайт: https://devops.org.ru
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const body = document.body;
|
||||
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let autoTimer = null;
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let pollTimer = null;
|
||||
let createInProgress = false;
|
||||
|
||||
function formatApiError(data, fallback) {
|
||||
if (!data) return fallback;
|
||||
if (typeof data.detail === "string") return data.detail;
|
||||
if (Array.isArray(data.detail)) {
|
||||
return data.detail
|
||||
.map(function (x) {
|
||||
return (x.msg || x) + (x.loc ? " (" + x.loc.join(".") + ")" : "");
|
||||
})
|
||||
.join("; ");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {RequestInit} [opts]
|
||||
*/
|
||||
async function api(path, opts) {
|
||||
const url = path.startsWith("http") ? path : API + path;
|
||||
const r = await fetch(url, opts);
|
||||
const text = await r.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = { raw: text };
|
||||
}
|
||||
if (!r.ok) {
|
||||
const msg = formatApiError(data, text || r.statusText);
|
||||
const err = new Error(msg);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(message, isError) {
|
||||
const el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.classList.remove("hidden", "toast-error", "toast-ok");
|
||||
el.classList.add(isError ? "toast-error" : "toast-ok");
|
||||
clearTimeout(el._hideT);
|
||||
el._hideT = setTimeout(function () {
|
||||
el.classList.add("hidden");
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
function setBusy(section, busy) {
|
||||
const el = document.querySelector("[data-busy='" + section + "']");
|
||||
if (!el) return;
|
||||
if (busy) {
|
||||
el.setAttribute("aria-busy", "true");
|
||||
} else {
|
||||
el.removeAttribute("aria-busy");
|
||||
}
|
||||
el.classList.toggle("is-loading", busy);
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
const el = document.getElementById("status-banner");
|
||||
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;
|
||||
el.className = "status-banner " + (ok ? "ok" : "degraded");
|
||||
let 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;
|
||||
} catch (e) {
|
||||
el.className = "status-banner err";
|
||||
el.textContent = "Не удалось запросить health: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const dl = document.getElementById("stats-dl");
|
||||
const errEl = document.getElementById("stats-err");
|
||||
if (!dl) return;
|
||||
errEl.classList.add("hidden");
|
||||
dl.innerHTML = "";
|
||||
try {
|
||||
const s = await api("/stats");
|
||||
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 : "—"],
|
||||
["Заданий в памяти", s.jobs_total],
|
||||
["Заданий с ошибкой", s.jobs_recent_failed],
|
||||
];
|
||||
rows.forEach(function (kv) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = kv[0];
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = String(kv[1]);
|
||||
dl.appendChild(dt);
|
||||
dl.appendChild(dd);
|
||||
});
|
||||
} catch (e) {
|
||||
errEl.textContent = "Статистика: " + e.message;
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const sel = document.getElementById("version-select");
|
||||
const verInput = document.getElementById("kubernetes_version");
|
||||
if (!sel || !verInput) return;
|
||||
sel.innerHTML = "<option value=\"\">— загрузка —</option>";
|
||||
try {
|
||||
const data = await api("/versions");
|
||||
sel.innerHTML = "";
|
||||
if (!data.tags || !data.tags.length) {
|
||||
sel.innerHTML = "<option value=\"\">(список пуст — введите версию вручную)</option>";
|
||||
return;
|
||||
}
|
||||
const opt0 = document.createElement("option");
|
||||
opt0.value = "";
|
||||
opt0.textContent = "— выберите тег —";
|
||||
sel.appendChild(opt0);
|
||||
data.tags.slice(0, 100).forEach(function (t) {
|
||||
const o = document.createElement("option");
|
||||
o.value = t;
|
||||
o.textContent = t;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.onchange = function () {
|
||||
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
|
||||
};
|
||||
} catch {
|
||||
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
|
||||
}
|
||||
}
|
||||
|
||||
function jobBadgeClass(status) {
|
||||
if (status === "success") return "badge badge-ok";
|
||||
if (status === "failed") return "badge badge-err";
|
||||
if (status === "running") return "badge badge-run";
|
||||
return "badge";
|
||||
}
|
||||
|
||||
async function loadClusters() {
|
||||
const tbody = document.querySelector("#tbl-clusters tbody");
|
||||
const msg = document.getElementById("list-msg");
|
||||
if (!tbody) return;
|
||||
setBusy("clusters", true);
|
||||
tbody.innerHTML = "";
|
||||
if (msg) msg.textContent = "";
|
||||
try {
|
||||
const rows = await api("/clusters");
|
||||
rows.forEach(function (c) {
|
||||
const tr = document.createElement("tr");
|
||||
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);
|
||||
const dlHref = API + "/clusters/" + encodeURIComponent(c.name) + "/kubeconfig";
|
||||
tr.innerHTML =
|
||||
"<td><code class=\"cluster-name\">" +
|
||||
nameEsc +
|
||||
"</code></td>" +
|
||||
"<td>" +
|
||||
(c.registered_in_kind ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
(c.has_local_kubeconfig ? "<span class=\"badge badge-ok\">да</span>" : "<span class=\"badge\">нет</span>") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(String(ver)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(String(wn)) +
|
||||
"</td>" +
|
||||
"<td class=\"actions\"></td>";
|
||||
const td = tr.querySelector(".actions");
|
||||
const b1 = document.createElement("button");
|
||||
b1.type = "button";
|
||||
b1.className = "btn-small";
|
||||
b1.textContent = "Состояние";
|
||||
b1.addEventListener("click", function () {
|
||||
openWorkloadsModal(c.name);
|
||||
});
|
||||
td.appendChild(b1);
|
||||
if (c.has_local_kubeconfig) {
|
||||
const a = document.createElement("a");
|
||||
a.href = dlHref;
|
||||
a.className = "btn-secondary btn-small";
|
||||
a.download = "kubeconfig-" + c.name + ".yaml";
|
||||
a.textContent = "kubeconfig";
|
||||
a.title = "Скачать kubeconfig";
|
||||
td.appendChild(a);
|
||||
}
|
||||
const b2 = document.createElement("button");
|
||||
b2.type = "button";
|
||||
b2.className = "btn-small btn-danger";
|
||||
b2.textContent = "Удалить";
|
||||
b2.addEventListener("click", function () {
|
||||
deleteCluster(c.name);
|
||||
});
|
||||
td.appendChild(b2);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (!rows.length && msg) msg.textContent = "Кластеров пока нет.";
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка списка: " + e.message;
|
||||
} finally {
|
||||
setBusy("clusters", false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
const tbody = document.querySelector("#tbl-jobs tbody");
|
||||
const msg = document.getElementById("jobs-msg");
|
||||
if (!tbody) return;
|
||||
setBusy("jobs", true);
|
||||
tbody.innerHTML = "";
|
||||
if (msg) msg.textContent = "";
|
||||
try {
|
||||
const rows = await api("/jobs?limit=30");
|
||||
rows.forEach(function (j) {
|
||||
const tr = document.createElement("tr");
|
||||
const st = escapeHtml(j.status || "");
|
||||
tr.innerHTML =
|
||||
"<td><time datetime=\"" +
|
||||
escapeHtml(j.created_at_utc || "") +
|
||||
"\">" +
|
||||
escapeHtml(j.created_at_utc || "") +
|
||||
"</time></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(j.cluster_name || "—") +
|
||||
"</td>" +
|
||||
"<td><span class=\"" +
|
||||
jobBadgeClass(j.status) +
|
||||
"\">" +
|
||||
st +
|
||||
"</span></td>" +
|
||||
"<td class=\"jobs-msg-cell\">" +
|
||||
escapeHtml((j.message || "").slice(0, 160)) +
|
||||
"</td>";
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
if (!rows.length && msg) {
|
||||
msg.textContent = "Заданий ещё не было (или контейнер перезапускали).";
|
||||
}
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Задания: " + e.message;
|
||||
} finally {
|
||||
setBusy("jobs", false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openWorkloadsModal(name) {
|
||||
const overlay = document.getElementById("modal-overlay");
|
||||
const sub = document.getElementById("modal-sub");
|
||||
const nodes = document.getElementById("modal-nodes");
|
||||
const pods = document.getElementById("modal-pods");
|
||||
const spin = document.getElementById("modal-spinner");
|
||||
if (!overlay) return;
|
||||
document.getElementById("modal-title").textContent = "Кластер «" + name + "»";
|
||||
sub.textContent = "";
|
||||
nodes.textContent = "";
|
||||
pods.textContent = "";
|
||||
if (spin) spin.classList.remove("hidden");
|
||||
overlay.classList.remove("hidden");
|
||||
document.body.classList.add("modal-open");
|
||||
try {
|
||||
const w = await api("/clusters/" + encodeURIComponent(name) + "/workloads");
|
||||
if (w.error) {
|
||||
sub.textContent = w.error;
|
||||
return;
|
||||
}
|
||||
sub.textContent = "kubectl: узлы rc=" + w.nodes_rc + ", поды rc=" + w.pods_rc;
|
||||
nodes.textContent = w.nodes_output || "(пусто)";
|
||||
pods.textContent = w.pods_output || "(пусто)";
|
||||
} catch (e) {
|
||||
sub.textContent = "Ошибка: " + e.message;
|
||||
} finally {
|
||||
if (spin) spin.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const overlay = document.getElementById("modal-overlay");
|
||||
if (overlay) overlay.classList.add("hidden");
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
|
||||
async function deleteCluster(name) {
|
||||
if (!confirm("Удалить кластер «" + name + "» и папку clusters/" + name + "?")) return;
|
||||
const msg = document.getElementById("list-msg");
|
||||
if (msg) msg.textContent = "Удаление…";
|
||||
try {
|
||||
const res = await api("/clusters/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
if (msg) msg.textContent = res.summary || "Готово.";
|
||||
showToast("Кластер «" + name + "» удалён", false);
|
||||
await loadClusters();
|
||||
await loadStats();
|
||||
await loadJobs();
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка удаления: " + e.message;
|
||||
showToast(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function setCreateFormDisabled(disabled) {
|
||||
const form = document.getElementById("form-create");
|
||||
if (!form) return;
|
||||
const btn = form.querySelector('[type="submit"]');
|
||||
if (btn) {
|
||||
btn.disabled = disabled;
|
||||
btn.textContent = disabled ? "Создание…" : "Создать кластер";
|
||||
}
|
||||
form.querySelectorAll("input, select").forEach(function (el) {
|
||||
el.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function pollJob(jobId) {
|
||||
const pre = document.getElementById("job-status");
|
||||
const details = document.getElementById("job-details");
|
||||
const msg = document.getElementById("create-msg");
|
||||
if (pre) pre.classList.add("hidden");
|
||||
if (details) {
|
||||
details.classList.remove("hidden");
|
||||
details.open = true;
|
||||
}
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
createInProgress = true;
|
||||
setCreateFormDisabled(true);
|
||||
|
||||
const tick = async function () {
|
||||
try {
|
||||
const j = await api("/jobs/" + jobId);
|
||||
const preEl = document.getElementById("job-json");
|
||||
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
|
||||
if (j.status === "success" || j.status === "failed") {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
createInProgress = false;
|
||||
setCreateFormDisabled(false);
|
||||
if (msg) {
|
||||
msg.textContent =
|
||||
j.status === "success" ? "Кластер создан." : "Ошибка: " + (j.message || "");
|
||||
}
|
||||
if (j.status === "success") {
|
||||
showToast("Кластер создан", false);
|
||||
} else {
|
||||
showToast(j.message || "Ошибка создания", true);
|
||||
}
|
||||
await loadClusters();
|
||||
await loadStats();
|
||||
await loadJobs();
|
||||
}
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
|
||||
}
|
||||
};
|
||||
tick();
|
||||
pollTimer = setInterval(tick, 2000);
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadHealth();
|
||||
loadStats();
|
||||
loadClusters();
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
function init() {
|
||||
const form = document.getElementById("form-create");
|
||||
if (form) {
|
||||
form.addEventListener("submit", async function (ev) {
|
||||
ev.preventDefault();
|
||||
if (createInProgress) return;
|
||||
const msg = document.getElementById("create-msg");
|
||||
const details = document.getElementById("job-details");
|
||||
if (msg) msg.textContent = "";
|
||||
if (details) details.classList.add("hidden");
|
||||
const fd = new FormData(form);
|
||||
const body = {
|
||||
name: String(fd.get("name") || "").trim(),
|
||||
kubernetes_version: String(fd.get("kubernetes_version") || "").trim(),
|
||||
workers: parseInt(String(fd.get("workers") || "0"), 10),
|
||||
};
|
||||
try {
|
||||
const res = await api("/clusters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (msg) msg.textContent = "Задание: " + res.job_id;
|
||||
pollJob(res.job_id);
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = "Ошибка: " + e.message;
|
||||
showToast(e.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const bStats = document.getElementById("btn-refresh-stats");
|
||||
if (bStats) bStats.addEventListener("click", function () { loadHealth(); loadStats(); });
|
||||
|
||||
const bList = document.getElementById("btn-refresh-list");
|
||||
if (bList) bList.addEventListener("click", loadClusters);
|
||||
|
||||
const bJobs = document.getElementById("btn-refresh-jobs");
|
||||
if (bJobs) bJobs.addEventListener("click", loadJobs);
|
||||
|
||||
const bAll = document.getElementById("btn-refresh-all");
|
||||
if (bAll) bAll.addEventListener("click", refreshAll);
|
||||
|
||||
const mClose = document.getElementById("modal-close");
|
||||
if (mClose) mClose.addEventListener("click", closeModal);
|
||||
|
||||
const overlay = document.getElementById("modal-overlay");
|
||||
if (overlay) {
|
||||
overlay.addEventListener("click", function (ev) {
|
||||
if (ev.target === overlay) closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") closeModal();
|
||||
});
|
||||
|
||||
const auto = document.getElementById("auto-refresh");
|
||||
if (auto) {
|
||||
auto.addEventListener("change", function (ev) {
|
||||
if (autoTimer) {
|
||||
clearInterval(autoTimer);
|
||||
autoTimer = null;
|
||||
}
|
||||
if (ev.target.checked) {
|
||||
autoTimer = setInterval(refreshAll, 15000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadHealth();
|
||||
loadStats();
|
||||
loadVersions();
|
||||
loadClusters();
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,504 @@
|
||||
/* Минимальные стили веб-интерфейса kind-k8s-develop.
|
||||
Автор: Сергей Антропов — https://devops.org.ru */
|
||||
|
||||
:root {
|
||||
color-scheme: light 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;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Пропуск к основному содержимому (a11y) */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0.5rem;
|
||||
z-index: 200;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.skip-link:focus {
|
||||
left: 0.5rem;
|
||||
outline: 2px solid var(--fg);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Верхняя навигация */
|
||||
.top-nav {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
}
|
||||
.top-nav-inner {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 0.65rem 1.25rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-logo {
|
||||
color: var(--accent);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.nav-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.nav-tag {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1rem;
|
||||
}
|
||||
.nav-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.nav-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.page-lead {
|
||||
margin: 0;
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Тост уведомлений */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 150;
|
||||
max-width: min(22rem, calc(100vw - 2rem));
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.toast-ok {
|
||||
border-color: #15803d;
|
||||
}
|
||||
.toast-error {
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Бейджи статусов */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.badge-ok {
|
||||
border-color: #15803d;
|
||||
color: #4ade80;
|
||||
}
|
||||
.badge-err {
|
||||
border-color: #b91c1c;
|
||||
color: #f87171;
|
||||
}
|
||||
.badge-run {
|
||||
border-color: #b45309;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.cluster-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Состояние загрузки таблиц */
|
||||
/* Пока идёт запрос к API, секция слегка приглушается */
|
||||
[data-busy].is-loading {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.job-details {
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.job-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.job-json-pre {
|
||||
max-height: 12rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Спиннер в модалке */
|
||||
.modal-head {
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.modal-title-text {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
flex: 1;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.modal-sub {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.modal-section-title {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.modal-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.spinner {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.65rem 0 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row.spread {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.6rem;
|
||||
border-radius: 6px;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.45rem 0.35rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Плашка состояния среды (Docker/Podman, kind, kubectl) */
|
||||
.status-banner {
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status-banner.ok {
|
||||
border-color: #15803d;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.status-banner.degraded {
|
||||
border-color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
.status-banner.err {
|
||||
border-color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.stats-dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.stats-dl dt {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stats-dl dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button.btn-secondary,
|
||||
a.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-top: 0;
|
||||
margin-right: 0.35rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
button.btn-small {
|
||||
margin-top: 0;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
td.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
td.actions button {
|
||||
margin-top: 0;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Модальное окно «Состояние кластера» */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
max-width: 52rem;
|
||||
width: 100%;
|
||||
padding: 1rem 1.15rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.modal-box h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.modal-box pre {
|
||||
max-height: 40vh;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.modal-close {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button.btn-danger,
|
||||
a.btn-danger {
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
button.btn-danger,
|
||||
a.btn-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
button.btn-danger:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
Reference in New Issue
Block a user