Files
KindClustersDashboard/app/static/js/dashboard.js
T
Sergey Antropoff 02f4c655b9 UI: автообновление, прогресс, отмена; порт 8080; меню-пилюли и отдельные окна
- Порт хоста по умолчанию 8080 (Chrome ERR_UNSAFE_PORT на 6000); compose, setup, config, README.
- Дашборд: одна hero-карточка, прогресс создания, POST /jobs/{id}/cancel, JobView progress_*.
- job_store: отмена и прогресс (thread-safe); cluster_lifecycle этапы и откат.
- Навигация: стили nav-pill; Swagger/ReDoc/Health через window.open.
- main.py: TemplateResponse(request, …) для Starlette.
- Документация: README, app/docs (api_routes, README); Makefile ps; .gitignore clusters.
2026-04-04 05:58:11 +03:00

534 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Панель управления кластерами kind (REST /api/v1).
* Автообновление списков и health; прогресс и отмена создания кластера.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Интервал опроса списков и среды (мс) */
var AUTO_REFRESH_MS = 3500;
/** Интервал опроса задания создания (мс) */
var JOB_POLL_MS = 1500;
/** @type {ReturnType<typeof setInterval> | null} */
var autoTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */
var pollTimer = null;
var createInProgress = false;
/** @type {string | null} */
var currentPollJobId = null;
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();
var data;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
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);
}
function setStatusBannerClass(ok, degraded) {
const el = document.getElementById("status-banner");
if (!el) return;
var cls = "hero-panel-status muted ";
if (ok) cls += "ok";
else if (degraded) cls += "degraded";
else cls += "err";
el.className = cls;
}
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;
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;
} catch (e) {
setStatusBannerClass(false, false);
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 (e) {
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";
if (status === "cancelled") return "badge badge-cancelled";
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 || "");
var cellMsg = (j.message || "").slice(0, 160);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
cellMsg = j.progress_stage + (j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
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(cellMsg) +
"</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 showCreateProgress(show) {
const wrap = document.getElementById("create-progress-wrap");
if (!wrap) return;
wrap.classList.toggle("hidden", !show);
wrap.setAttribute("aria-hidden", show ? "false" : "true");
}
function updateCreateProgressFromJob(j) {
const bar = document.getElementById("create-progress-bar");
const track = document.getElementById("create-progress-track");
const label = document.getElementById("create-progress-label");
var pct = j.progress_percent != null ? Number(j.progress_percent) : 0;
if (isNaN(pct)) pct = 0;
pct = Math.max(0, Math.min(100, pct));
var stage = j.progress_stage || (j.status === "queued" ? "В очереди…" : "…");
if (bar) bar.style.width = pct + "%";
if (track) {
track.setAttribute("aria-valuenow", String(pct));
}
if (label) label.textContent = stage;
}
function stopPollJob() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
currentPollJobId = null;
createInProgress = false;
setCreateFormDisabled(false);
showCreateProgress(false);
}
async function requestCancelJob() {
if (!currentPollJobId) return;
try {
await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
showToast("Запрос отмены отправлен (между этапами)", false);
} catch (e) {
showToast(e.message || "Не удалось отменить", true);
}
}
function pollJob(jobId) {
const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg");
if (details) {
details.classList.remove("hidden");
details.open = false;
}
if (pollTimer) clearInterval(pollTimer);
createInProgress = true;
currentPollJobId = jobId;
setCreateFormDisabled(true);
showCreateProgress(true);
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
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);
updateCreateProgressFromJob(j);
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob();
if (msg) {
if (j.status === "success") msg.textContent = "Кластер создан.";
else if (j.status === "cancelled") msg.textContent = j.message || "Создание отменено.";
else msg.textContent = "Ошибка: " + (j.message || "");
}
if (j.status === "success") showToast("Кластер создан", false);
else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка создания", true);
await loadClusters();
await loadStats();
await loadJobs();
}
} catch (e) {
if (msg) msg.textContent = "Ошибка опроса задания: " + e.message;
}
};
tick();
pollTimer = setInterval(tick, JOB_POLL_MS);
}
function refreshLists() {
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 btnCancel = document.getElementById("btn-cancel-create");
if (btnCancel) {
btnCancel.addEventListener("click", function () {
requestCancelJob();
});
}
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();
});
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
loadHealth();
loadStats();
loadVersions();
loadClusters();
loadJobs();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();