Веб-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:
Sergey Antropoff
2026-04-04 05:39:53 +03:00
parent ae961ef5fe
commit e46a62cfdb
31 changed files with 2507 additions and 393 deletions
+484
View File
@@ -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();
}
})();