Add tracked CMS publishing progress
This commit is contained in:
@@ -1845,6 +1845,90 @@ select {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.publish-progress-card {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.publish-progress-card[data-state="completed"] {
|
||||
border-color: rgba(210, 255, 220, 0.32);
|
||||
}
|
||||
|
||||
.publish-progress-card[data-state="failed"] {
|
||||
border-color: rgba(255, 120, 120, 0.42);
|
||||
}
|
||||
|
||||
.publish-progress-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.publish-progress-head h3 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.publish-progress-head strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.publish-progress-track {
|
||||
position: relative;
|
||||
height: 0.65rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--launcher-radius-circle);
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.publish-progress-track span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transition: width 180ms linear;
|
||||
}
|
||||
|
||||
.publish-progress-card[data-state="failed"] .publish-progress-track span {
|
||||
background: rgba(255, 120, 120, 0.86);
|
||||
}
|
||||
|
||||
.publish-progress-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.publish-progress-current,
|
||||
.publish-progress-error {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.publish-progress-error {
|
||||
color: #ffd2d2;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.publish-progress-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.knowledge-tree {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
|
||||
+189
-26
@@ -45,6 +45,8 @@ const state = {
|
||||
page: null,
|
||||
projectConfig: null,
|
||||
projectConfigDirty: false,
|
||||
publishJob: null,
|
||||
publishResumeAttempted: false,
|
||||
knowledge: null,
|
||||
knowledgeSelectedId: null,
|
||||
knowledgeSavedSelection: null,
|
||||
@@ -486,6 +488,19 @@ function setStatus(message) {
|
||||
el.status.textContent = message;
|
||||
}
|
||||
|
||||
async function readApiJson(response, fallbackError = "Ошибка запроса") {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (response.status === 401 && payload?.loginUrl) {
|
||||
setStatus("Сессия CMS истекла. Открываем повторный вход...");
|
||||
window.location.assign(payload.loginUrl);
|
||||
throw new Error("Требуется повторный вход в CMS");
|
||||
}
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || fallbackError);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function setDirty(isDirty, message = null) {
|
||||
state.dirty = isDirty;
|
||||
el.dirtyState.dataset.dirty = String(isDirty);
|
||||
@@ -603,15 +618,15 @@ function applyProjectConfigSurface() {
|
||||
|
||||
async function loadProjectConfig() {
|
||||
const response = await fetch("/api/project/config");
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || "Не удалось загрузить настройки проекта");
|
||||
}
|
||||
const payload = await readApiJson(response, "Не удалось загрузить настройки проекта");
|
||||
|
||||
state.projectConfig = payload;
|
||||
state.projectConfigDirty = false;
|
||||
applyProjectConfigSurface();
|
||||
if (!state.publishResumeAttempted) {
|
||||
state.publishResumeAttempted = true;
|
||||
resumeActivePublishJob().catch(() => {});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -676,14 +691,136 @@ async function checkProjectConnection() {
|
||||
}
|
||||
|
||||
async function publishProject() {
|
||||
const response = await fetch("/api/project/publish", { method: "POST" });
|
||||
const payload = await response.json().catch(() => null);
|
||||
const response = await fetch("/api/project/publish/start", { method: "POST" });
|
||||
const payload = await readApiJson(response, "Не удалось запустить публикацию проекта");
|
||||
if (!payload?.ok || !payload.job?.id) throw new Error(payload?.error || "Сервер не вернул ID публикации");
|
||||
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(payload?.error || "Не удалось опубликовать проект");
|
||||
state.publishJob = payload.job;
|
||||
updatePublishProgressSurface();
|
||||
await pollPublishJob(payload.job.id);
|
||||
}
|
||||
|
||||
function publishJobRunning(job = state.publishJob) {
|
||||
return Boolean(job && !["completed", "failed"].includes(job.state));
|
||||
}
|
||||
|
||||
function publishStateLabel(job) {
|
||||
const labels = {
|
||||
queued: "Публикация поставлена в очередь",
|
||||
scanning: "Считаем файлы и общий объём",
|
||||
connecting: "Подключаемся к хостингу",
|
||||
publishing: "Передаём файлы по SFTP",
|
||||
completed: "Публикация завершена",
|
||||
failed: "Публикация остановлена с ошибкой",
|
||||
};
|
||||
return labels[job?.state] || "Подготавливаем публикацию";
|
||||
}
|
||||
|
||||
function updatePublishButtonState() {
|
||||
if (!el.publish) return;
|
||||
const running = publishJobRunning();
|
||||
const enabled = Boolean(state.projectConfig?.deploy?.enabled);
|
||||
el.publish.disabled = running || !enabled || !siteReady() || state.projectConfigDirty;
|
||||
el.publish.textContent = running ? `${Number(state.publishJob?.percent || 0).toLocaleString("ru-RU")}%` : "Опубликовать";
|
||||
}
|
||||
|
||||
function createPublishProgressPanel() {
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "settings-card publish-progress-card";
|
||||
panel.dataset.publishProgress = "true";
|
||||
panel.innerHTML = `
|
||||
<div class="publish-progress-head">
|
||||
<div>
|
||||
<div class="section-kicker">Публикация</div>
|
||||
<h3 data-publish-title></h3>
|
||||
</div>
|
||||
<strong data-publish-percent></strong>
|
||||
</div>
|
||||
<div class="publish-progress-track" role="progressbar" aria-label="Прогресс публикации" aria-valuemin="0" aria-valuemax="100">
|
||||
<span data-publish-bar></span>
|
||||
</div>
|
||||
<div class="publish-progress-stats">
|
||||
<span data-publish-volume></span>
|
||||
<span data-publish-files></span>
|
||||
<span data-publish-uploaded></span>
|
||||
<span data-publish-skipped></span>
|
||||
</div>
|
||||
<p class="publish-progress-current" data-publish-current></p>
|
||||
<p class="publish-progress-error hidden" data-publish-error></p>
|
||||
`;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function updatePublishProgressSurface() {
|
||||
updatePublishButtonState();
|
||||
const job = state.publishJob;
|
||||
if (!job || !el.projectSettingsForm || el.projectSettingsForm.classList.contains("hidden")) return;
|
||||
|
||||
let panel = el.projectSettingsForm.querySelector("[data-publish-progress]");
|
||||
if (!panel) {
|
||||
panel = createPublishProgressPanel();
|
||||
el.projectSettingsForm.prepend(panel);
|
||||
}
|
||||
|
||||
setStatus(publishStatusText(payload).trim() || "Проект опубликован.");
|
||||
const percent = Number(job.percent || 0);
|
||||
panel.dataset.state = job.state || "queued";
|
||||
panel.querySelector("[data-publish-title]").textContent = publishStateLabel(job);
|
||||
panel.querySelector("[data-publish-percent]").textContent = `${percent.toLocaleString("ru-RU")}%`;
|
||||
const track = panel.querySelector(".publish-progress-track");
|
||||
track.setAttribute("aria-valuenow", String(percent));
|
||||
panel.querySelector("[data-publish-bar]").style.width = `${percent}%`;
|
||||
panel.querySelector("[data-publish-volume]").textContent = `${job.processedLabel || "0 Б"} из ${job.totalLabel || "0 Б"}`;
|
||||
panel.querySelector("[data-publish-files]").textContent = `Проверено: ${Number(job.processedFiles || 0).toLocaleString("ru-RU")}/${Number(job.totalFiles || 0).toLocaleString("ru-RU")}`;
|
||||
panel.querySelector("[data-publish-uploaded]").textContent = `Загружено: ${Number(job.uploadedFiles || 0).toLocaleString("ru-RU")}`;
|
||||
panel.querySelector("[data-publish-skipped]").textContent = `Без изменений: ${Number(job.skippedFiles || 0).toLocaleString("ru-RU")}`;
|
||||
panel.querySelector("[data-publish-current]").textContent = job.currentFile ? `Сейчас: ${job.currentFile}` : job.host ? `${job.host}/${job.remoteRoot || ""}` : "";
|
||||
const error = panel.querySelector("[data-publish-error]");
|
||||
error.textContent = job.error || "";
|
||||
error.classList.toggle("hidden", !job.error);
|
||||
}
|
||||
|
||||
function publishProgressStatus(job) {
|
||||
const current = job.currentFile ? ` · ${job.currentFile}` : "";
|
||||
return `${publishStateLabel(job)}: ${Number(job.percent || 0).toLocaleString("ru-RU")}% · ${job.processedLabel || "0 Б"}/${job.totalLabel || "0 Б"}${current}`;
|
||||
}
|
||||
|
||||
async function fetchPublishJob(jobId = "") {
|
||||
const query = jobId ? `?jobId=${encodeURIComponent(jobId)}` : "";
|
||||
const response = await fetch(`/api/project/publish/status${query}`);
|
||||
if (response.status === 404) return null;
|
||||
const payload = await readApiJson(response, "Не удалось получить состояние публикации");
|
||||
return payload.job || null;
|
||||
}
|
||||
|
||||
async function pollPublishJob(jobId) {
|
||||
while (true) {
|
||||
const job = await fetchPublishJob(jobId);
|
||||
if (!job) throw new Error("Сервер потерял состояние публикации");
|
||||
state.publishJob = job;
|
||||
updatePublishProgressSurface();
|
||||
setStatus(publishProgressStatus(job));
|
||||
|
||||
if (job.state === "completed") {
|
||||
setStatus(publishStatusText({ publish: job }).trim() || "Проект опубликован.");
|
||||
return job;
|
||||
}
|
||||
if (job.state === "failed") {
|
||||
throw new Error(`Публикация остановлена: ${job.error || "неизвестная ошибка"}. Файл: ${job.currentFile || "не определён"}`);
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 700));
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeActivePublishJob() {
|
||||
const job = await fetchPublishJob();
|
||||
if (!job) return;
|
||||
state.publishJob = job;
|
||||
updatePublishProgressSurface();
|
||||
if (publishJobRunning(job)) {
|
||||
await pollPublishJob(job.id);
|
||||
} else if (job.state === "failed") {
|
||||
setStatus(`Последняя публикация завершилась с ошибкой: ${job.error || "неизвестная ошибка"}. Файл: ${job.currentFile || "не определён"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function publishStatusText(payload) {
|
||||
@@ -2074,16 +2211,45 @@ async function uploadSiteArchive(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, file.name);
|
||||
|
||||
setStatus(`Загружаем архив сайта: ${file.name}...`);
|
||||
const response = await fetch("/api/project/upload-site", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
const payload = await new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", "/api/project/upload-site");
|
||||
request.responseType = "json";
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
setStatus(`Загружаем архив сайта: ${file.name}...`);
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 1000) / 10;
|
||||
setStatus(`Загружаем архив сайта: ${percent.toLocaleString("ru-RU")}% · ${file.name}`);
|
||||
});
|
||||
request.upload.addEventListener("load", () => {
|
||||
setStatus(`Архив ${file.name} передан. Проверяем и распаковываем на сервере...`);
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
const responsePayload = request.response && typeof request.response === "object"
|
||||
? request.response
|
||||
: (() => {
|
||||
try {
|
||||
return JSON.parse(request.responseText || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (request.status === 401 && responsePayload?.loginUrl) {
|
||||
window.location.assign(responsePayload.loginUrl);
|
||||
reject(new Error("Требуется повторный вход в CMS"));
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300 || !responsePayload?.ok) {
|
||||
reject(new Error(responsePayload?.error || "Не удалось загрузить архив сайта"));
|
||||
return;
|
||||
}
|
||||
resolve(responsePayload);
|
||||
});
|
||||
request.addEventListener("error", () => reject(new Error("Соединение оборвалось во время загрузки архива")));
|
||||
request.send(formData);
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok || !payload?.ok) {
|
||||
throw new Error(payload?.error || "Не удалось загрузить архив сайта");
|
||||
}
|
||||
|
||||
state.projectConfig = await loadProjectConfig();
|
||||
renderProjectSettings();
|
||||
@@ -2255,6 +2421,7 @@ function renderProjectSettings() {
|
||||
});
|
||||
|
||||
el.projectSettingsForm.append(projectCard, deployCard, runtimeCard);
|
||||
updatePublishProgressSurface();
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
@@ -5532,8 +5699,7 @@ async function loadKnowledge() {
|
||||
}
|
||||
|
||||
const response = await fetch("/api/knowledge");
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
state.knowledge = await response.json();
|
||||
state.knowledge = await readApiJson(response, "Не удалось загрузить базу знаний");
|
||||
state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null;
|
||||
ensureKnowledgeSelection();
|
||||
renderAll();
|
||||
@@ -5587,11 +5753,8 @@ async function loadPage() {
|
||||
}
|
||||
|
||||
const [pageResponse, templatesResponse] = await Promise.all([fetch("/api/page/home"), fetch("/api/block-templates/home")]);
|
||||
if (!pageResponse.ok) throw new Error(await pageResponse.text());
|
||||
if (!templatesResponse.ok) throw new Error(await templatesResponse.text());
|
||||
|
||||
state.page = await pageResponse.json();
|
||||
state.templates = await templatesResponse.json();
|
||||
state.page = await readApiJson(pageResponse, "Не удалось загрузить home.json");
|
||||
state.templates = await readApiJson(templatesResponse, "Не удалось загрузить шаблоны блоков");
|
||||
ensureStaticElements();
|
||||
ensureSectionsRegion();
|
||||
ensureSeoModel();
|
||||
|
||||
Reference in New Issue
Block a user