Панель: журнал заданий, pull --progress plain, PTY/EIO, старт/стоп в фоне, очистка jobs

- Фоновые stop/start с job_id и poll; отмена с kill; docker pull plain + снятие ANSI
- Лимиты журнала API/буфера; список jobs без progress_log; DELETE /jobs
- UI: опрос чаще, подсказка при пустом логе, кнопка очистки завершённых
This commit is contained in:
Sergey Antropoff
2026-04-04 07:04:46 +03:00
parent 6d4bc65c8a
commit 8bd44adbb0
10 changed files with 808 additions and 200 deletions
+161 -35
View File
@@ -12,16 +12,18 @@
const body = document.body;
const API = (body.dataset.apiBase || "/api/v1").replace(/\/$/, "");
/** Интервал опроса списков и среды (мс) */
/** Интервал автообновления таблиц и среды на панели (мс) */
var AUTO_REFRESH_MS = 3500;
/** Интервал опроса задания создания (мс) */
var JOB_POLL_MS = 1500;
/** Опрос GET /jobs/{id} для активного задания (мс) — чаще, чтобы журнал обновлялся живее */
var JOB_POLL_ACTIVE_MS = 650;
/** @type {ReturnType<typeof setInterval> | null} */
var autoTimer = null;
/** @type {ReturnType<typeof setInterval> | null} */
var pollTimer = null;
var createInProgress = false;
/** Блокировать форму «Создать» только при создании из формы, не при старт/стоп из таблицы. */
var pollLockCreateForm = false;
/** @type {string | null} */
var currentPollJobId = null;
/** Имя кластера в открытой модалке «Состояние» (для скачивания kubeconfig). */
@@ -69,6 +71,32 @@
return d.innerHTML;
}
function pad2(n) {
return n < 10 ? "0" + n : String(n);
}
/**
* Краткий человекочитаемый вид даты/времени в UTC для таблицы заданий (ДД.ММ.ГГ ЧЧ:ММ UTC).
* @param {string} iso
*/
function formatJobTimeUtc(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return (
pad2(d.getUTCDate()) +
"." +
pad2(d.getUTCMonth() + 1) +
"." +
String(d.getUTCFullYear()).slice(-2) +
" " +
pad2(d.getUTCHours()) +
":" +
pad2(d.getUTCMinutes()) +
" UTC"
);
}
/**
* Скачать kubeconfig кластера (GET /clusters/{name}/kubeconfig).
* @param {string} clusterName
@@ -493,6 +521,43 @@
}
}
/**
* Удалить из памяти сервера только завершённые задания (успех / ошибка / отмена).
*/
async function clearCompletedJobs() {
const ok = await openConfirmModal({
title: "Очистить завершённые задания?",
message:
"Из списка на сервере будут удалены только завершённые записи. Текущее активное задание не затрагивается.",
confirmLabel: "Очистить",
danger: false,
});
if (!ok) return;
const msg = document.getElementById("jobs-msg");
try {
const r = await fetch(API + "/jobs", { method: "DELETE", headers: { Accept: "application/json" } });
const text = await r.text();
var data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
var n = data.removed != null ? data.removed : 0;
if (msg) msg.textContent = n ? "Удалено записей: " + n : "Нечего очищать.";
showToast(n ? "Удалено завершённых заданий: " + n : "Нет завершённых заданий для очистки", false);
await loadJobs();
await loadStats();
} catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message;
showToast(e.message, true);
}
}
async function loadJobs() {
const tbody = document.querySelector("#tbl-jobs tbody");
const msg = document.getElementById("jobs-msg");
@@ -503,7 +568,10 @@
rows.forEach(function (j) {
const tr = document.createElement("tr");
const st = escapeHtml(j.status || "");
var kindTag = j.kind === "start_cluster" ? "[старт] " : "";
var kindTag = "";
if (j.kind === "start_cluster") kindTag = "[старт] ";
else if (j.kind === "stop_containers") kindTag = "[стоп] ";
else if (j.kind === "start_containers") kindTag = "[запуск узлов] ";
var cellMsg = kindTag + (j.message || "").slice(0, 140);
if ((j.status === "running" || j.status === "queued") && j.progress_stage) {
cellMsg =
@@ -511,11 +579,15 @@
j.progress_stage +
(j.progress_percent != null ? " (" + j.progress_percent + "%)" : "");
}
const iso = j.created_at_utc || "";
const timeLabel = formatJobTimeUtc(iso);
tr.innerHTML =
"<td><time datetime=\"" +
escapeHtml(j.created_at_utc || "") +
escapeHtml(iso) +
"\" title=\"" +
escapeHtml(iso ? "UTC: " + iso : "") +
"\">" +
escapeHtml(j.created_at_utc || "") +
escapeHtml(timeLabel) +
"</time></td>" +
"<td>" +
escapeHtml(j.cluster_name || "—") +
@@ -644,14 +716,31 @@
message:
"Кластер «" +
name +
"»: контейнеры будут остановлены (docker/podman stop). Запись в kind сохранится — позже можно снова нажать «Старт».",
"»: узлы будут остановлены. Сведения о кластере сохранятся — позже можно снова нажать «Старт».",
confirmLabel: "Остановить",
danger: false,
});
if (!ok) return;
const url = API + "/clusters/" + encodeURIComponent(name) + "/stop";
try {
const res = await api("/clusters/" + encodeURIComponent(name) + "/stop", { method: "POST" });
showToast(String(res.summary || "Узлы остановлены"), false);
const r = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
const text = await r.text();
var data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
if (r.status === 202 && data.job_id) {
setProgressHint(name, "stop");
pollJob(data.job_id, { lockCreateForm: false });
return;
}
if (!r.ok) {
showToast(formatApiError(data, text || r.statusText), true);
return;
}
showToast(String(data.summary || "Готово"), false);
await loadClusters();
await loadStats();
await loadHealth();
@@ -674,8 +763,10 @@
data = {};
}
if (r.status === 202 && data.job_id) {
setProgressHint(name);
pollJob(data.job_id);
var sm = data.mode || "kind_config";
if (sm === "containers") setProgressHint(name, "start_containers");
else setProgressHint(name, "start_config");
pollJob(data.job_id, { lockCreateForm: false });
return;
}
if (!r.ok) {
@@ -741,21 +832,26 @@
}
/**
* Обновить текст подсказки над прогрессом (создание с нуля или старт по конфигу).
* Подсказка над прогресс-баром (без технических имён команд).
* @param {string | null} clusterName
* @param {"create"|"start_config"|"start_containers"|"stop"} [mode]
*/
function setProgressHint(clusterName) {
function setProgressHint(clusterName, mode) {
const hint = document.getElementById("create-progress-hint");
if (!hint) return;
if (clusterName) {
hint.innerHTML =
"Кластер <code>" +
escapeHtml(clusterName) +
"</code>: подъём по конфигу или длительный <code>kind create</code> — ниже журнал (pull образов и ноды).";
mode = mode || "create";
var named = clusterName ? "«" + clusterName + "»" : "";
if (mode === "stop") {
hint.textContent = "Останавливаем кластер " + named + ". Ход операции — в журнале ниже.";
} else if (mode === "start_containers") {
hint.textContent = "Запускаем узлы кластера " + named + ". Ход операции — в журнале ниже.";
} else if (mode === "start_config") {
hint.textContent = "Поднимаем кластер " + named + " по сохранённой конфигурации. Журнал ниже.";
} else {
hint.innerHTML =
"Создание кластера: шаг <code>kind create</code> может занять несколько минут при первом pull образов. " +
"Ниже — журнал в реальном времени.";
hint.textContent =
"Создаём кластер" +
(named ? " " + named : "") +
". Сначала при необходимости скачивается образ, затем создаются узлы — всё в журнале ниже.";
}
}
@@ -781,7 +877,10 @@
}
currentPollJobId = null;
createInProgress = false;
setCreateFormDisabled(false);
if (pollLockCreateForm) {
setCreateFormDisabled(false);
pollLockCreateForm = false;
}
showCreateProgress(false);
}
@@ -789,13 +888,19 @@
if (!currentPollJobId) return;
try {
await api("/jobs/" + encodeURIComponent(currentPollJobId) + "/cancel", { method: "POST" });
showToast("Запрос отмены отправлен (между этапами)", false);
showToast("Запрос на прерывание отправлен", false);
} catch (e) {
showToast(e.message || "Не удалось отменить", true);
}
}
function pollJob(jobId) {
/**
* @param {string} jobId
* @param {{ lockCreateForm?: boolean }} [opts]
*/
function pollJob(jobId, opts) {
opts = opts || {};
pollLockCreateForm = !!opts.lockCreateForm;
const details = document.getElementById("job-details");
const msg = document.getElementById("create-msg");
const logEl = document.getElementById("job-log-panel");
@@ -807,7 +912,9 @@
if (pollTimer) clearInterval(pollTimer);
createInProgress = true;
currentPollJobId = jobId;
setCreateFormDisabled(true);
if (pollLockCreateForm) {
setCreateFormDisabled(true);
}
showCreateProgress(true);
updateCreateProgressFromJob({ progress_percent: 0, progress_stage: "В очереди…", status: "queued" });
@@ -817,23 +924,35 @@
const preEl = document.getElementById("job-json");
if (preEl) preEl.textContent = JSON.stringify(j, null, 2);
updateCreateProgressFromJob(j);
if (logEl && j.progress_log && j.progress_log.length) {
logEl.textContent = j.progress_log.join("\n");
logEl.scrollTop = logEl.scrollHeight;
if (logEl) {
if (j.progress_log && j.progress_log.length) {
logEl.removeAttribute("data-placeholder");
logEl.textContent = j.progress_log.join("\n");
logEl.scrollTop = logEl.scrollHeight;
} else if (j.status === "running" || j.status === "queued") {
logEl.setAttribute("data-placeholder", "1");
logEl.textContent =
"Журнал подгружается… Строки скачивания и создания узлов появляются по мере вывода команды.";
}
}
if (j.status === "success" || j.status === "failed" || j.status === "cancelled") {
stopPollJob();
setProgressHint(null);
setProgressHint(null, "create");
if (msg) {
if (j.status === "success") {
msg.textContent =
j.kind === "start_cluster" ? "Кластер поднят по сохранённому конфигу." : "Кластер создан.";
if (j.kind === "start_cluster") msg.textContent = "Кластер поднят по сохранённому конфигу.";
else if (j.kind === "stop_containers") msg.textContent = "Узлы кластера остановлены.";
else if (j.kind === "start_containers") msg.textContent = "Узлы кластера запущены.";
else msg.textContent = "Кластер создан.";
} else if (j.status === "cancelled") msg.textContent = j.message || "Операция отменена.";
else msg.textContent = "Ошибка: " + (j.message || "");
}
if (j.status === "success") {
showToast(j.kind === "start_cluster" ? "Кластер запущен" : "Кластер создан", false);
if (j.kind === "start_cluster") showToast("Кластер запущен", false);
else if (j.kind === "stop_containers") showToast("Узлы остановлены", false);
else if (j.kind === "start_containers") showToast("Узлы запущены", false);
else showToast("Кластер создан", false);
} else if (j.status === "cancelled") showToast(j.message || "Отменено", false);
else showToast(j.message || "Ошибка", true);
await loadClusters();
@@ -846,7 +965,7 @@
}
};
tick();
pollTimer = setInterval(tick, JOB_POLL_MS);
pollTimer = setInterval(tick, JOB_POLL_ACTIVE_MS);
}
function refreshLists() {
@@ -866,7 +985,7 @@
const details = document.getElementById("job-details");
if (msg) msg.textContent = "";
if (details) details.classList.add("hidden");
setProgressHint(null);
setProgressHint(null, "create");
const fd = new FormData(form);
const body = {
name: String(fd.get("name") || "").trim(),
@@ -880,7 +999,7 @@
body: JSON.stringify(body),
});
if (msg) msg.textContent = "Задание: " + res.job_id;
pollJob(res.job_id);
pollJob(res.job_id, { lockCreateForm: true });
} catch (e) {
if (msg) msg.textContent = "Ошибка: " + e.message;
showToast(e.message, true);
@@ -895,6 +1014,13 @@
});
}
const btnClearJobs = document.getElementById("btn-jobs-clear");
if (btnClearJobs) {
btnClearJobs.addEventListener("click", function () {
clearCompletedJobs();
});
}
const mClose = document.getElementById("modal-close");
if (mClose) mClose.addEventListener("click", closeModal);
+17 -1
View File
@@ -418,7 +418,7 @@ body.modal-open {
}
.job-log-panel {
margin: 0;
max-height: min(40vh, 280px);
max-height: min(55vh, 420px);
overflow: auto;
padding: 0.5rem 0.65rem;
font-size: 0.78rem;
@@ -430,6 +430,16 @@ body.modal-open {
word-break: break-word;
}
/* Заголовок блока заданий + кнопка очистки */
.jobs-card-toolbar {
margin-bottom: 0.65rem;
}
.jobs-card-title {
margin: 0;
font-size: 1.15rem;
line-height: 1.3;
}
.git-hint {
font-size: 0.85rem;
margin: 0 0 0.65rem;
@@ -690,6 +700,12 @@ table {
font-size: 0.9rem;
}
th .th-hint {
font-weight: 500;
opacity: 0.7;
font-size: 0.82em;
}
th,
td {
border-bottom: 1px solid var(--border);