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.
This commit is contained in:
Sergey Antropoff
2026-04-04 05:58:11 +03:00
parent 74538423d5
commit 02f4c655b9
17 changed files with 618 additions and 181 deletions
+99 -50
View File
@@ -1,5 +1,6 @@
/**
* Панель управления кластерами kind (REST /api/v1).
* Автообновление списков и health; прогресс и отмена создания кластера.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
@@ -10,11 +11,18 @@
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} */
let autoTimer = null;
var autoTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */
let pollTimer = null;
let createInProgress = false;
var pollTimer = null;
var createInProgress = false;
/** @type {string | null} */
var currentPollJobId = null;
function formatApiError(data, fallback) {
if (!data) return fallback;
@@ -37,10 +45,10 @@
const url = path.startsWith("http") ? path : API + path;
const r = await fetch(url, opts);
const text = await r.text();
let data;
var data;
try {
data = text ? JSON.parse(text) : null;
} catch {
} catch (e) {
data = { raw: text };
}
if (!r.ok) {
@@ -81,6 +89,16 @@
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;
@@ -88,8 +106,8 @@
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> ";
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 ? "да" : "нет");
@@ -102,7 +120,7 @@
}
el.innerHTML = lines;
} catch (e) {
el.className = "status-banner err";
setStatusBannerClass(false, false);
el.textContent = "Не удалось запросить health: " + e.message;
}
}
@@ -161,7 +179,7 @@
sel.onchange = function () {
if (sel.value) verInput.value = sel.value.replace(/^v/, "");
};
} catch {
} catch (e) {
sel.innerHTML = "<option value=\"\">(ошибка загрузки тегов)</option>";
}
}
@@ -170,6 +188,7 @@
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";
}
@@ -253,6 +272,10 @@
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 || "") +
@@ -268,7 +291,7 @@
st +
"</span></td>" +
"<td class=\"jobs-msg-cell\">" +
escapeHtml((j.message || "").slice(0, 160)) +
escapeHtml(cellMsg) +
"</td>";
tbody.appendChild(tr);
});
@@ -348,38 +371,80 @@
});
}
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 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;
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);
if (j.status === "success" || j.status === "failed") {
clearInterval(pollTimer);
pollTimer = null;
createInProgress = false;
setCreateFormDisabled(false);
updateCreateProgressFromJob(j);
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob();
if (msg) {
msg.textContent =
j.status === "success" ? "Кластер создан." : "Ошибка: " + (j.message || "");
}
if (j.status === "success") {
showToast("Кластер создан", false);
} else {
showToast(j.message || "Ошибка создания", true);
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();
@@ -389,10 +454,10 @@
}
};
tick();
pollTimer = setInterval(tick, 2000);
pollTimer = setInterval(tick, JOB_POLL_MS);
}
function refreshAll() {
function refreshLists() {
loadHealth();
loadStats();
loadClusters();
@@ -430,17 +495,12 @@
});
}
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 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);
@@ -456,18 +516,7 @@
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);
}
});
}
autoTimer = setInterval(refreshLists, AUTO_REFRESH_MS);
loadHealth();
loadStats();