Панель: журнал заданий, 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:
+161
-35
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user