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:
+99
-50
@@ -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();
|
||||
|
||||
+160
-3
@@ -88,14 +88,80 @@ body.modal-open {
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 1rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.nav-link {
|
||||
|
||||
/* Пункты меню: компактные «пилюли» */
|
||||
.nav-link.nav-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: var(--fg);
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.nav-link.nav-pill:hover {
|
||||
text-decoration: none;
|
||||
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);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-link.nav-pill:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Текущее приложение — акцентная пилюля */
|
||||
.nav-link.nav-pill--home {
|
||||
background: linear-gradient(145deg, rgba(59, 130, 246, 0.28), rgba(59, 130, 246, 0.1));
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.nav-link.nav-pill--home {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link.nav-pill--home:hover {
|
||||
color: var(--accent);
|
||||
background: linear-gradient(145deg, rgba(59, 130, 246, 0.35), rgba(59, 130, 246, 0.15));
|
||||
}
|
||||
|
||||
/* Внешние окна: иконка «новое окно» */
|
||||
.nav-link.nav-pill--ext::after {
|
||||
content: "↗";
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.72em;
|
||||
opacity: 0.72;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Обычная ссылка без пилюли (если понадобится) */
|
||||
.nav-link:not(.nav-pill) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.nav-link:hover {
|
||||
.nav-link:not(.nav-pill):hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -114,6 +180,93 @@ body.modal-open {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
text-align: center;
|
||||
}
|
||||
.footer-line {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.footer-copyright {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
.footer-copyright a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Одна карточка: заголовок + описание + строка состояния среды */
|
||||
.hero-panel {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hero-panel-main {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.hero-panel .page-title {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.hero-panel .page-lead {
|
||||
margin: 0;
|
||||
max-width: 48rem;
|
||||
}
|
||||
.hero-panel-status {
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.hero-panel-status.ok {
|
||||
border-color: #15803d;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.hero-panel-status.degraded {
|
||||
border-color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
.hero-panel-status.err {
|
||||
border-color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
/* Прогресс создания кластера */
|
||||
.create-progress-wrap {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.create-progress-hint {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.progress-track {
|
||||
height: 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--accent), #60a5fa);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
.progress-label {
|
||||
margin: 0.45rem 0 0.65rem;
|
||||
font-size: 0.88rem;
|
||||
min-height: 1.35rem;
|
||||
}
|
||||
#create-progress-wrap .btn-secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.git-hint {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
|
||||
.page-intro {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -173,6 +326,10 @@ body.modal-open {
|
||||
border-color: #b45309;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.badge-cancelled {
|
||||
border-color: #6b7280;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.cluster-name {
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user