Add tracked CMS publishing progress
This commit is contained in:
parent
808d867dd7
commit
0579976ffc
|
|
@ -8,5 +8,6 @@ __pycache__/
|
||||||
*.local
|
*.local
|
||||||
infra/.env
|
infra/.env
|
||||||
projects/*.secrets.json
|
projects/*.secrets.json
|
||||||
|
projects/*.publish.log.jsonl
|
||||||
|
|
||||||
.cms-smoke.png
|
.cms-smoke.png
|
||||||
|
|
|
||||||
|
|
@ -1845,6 +1845,90 @@ select {
|
||||||
vertical-align: bottom;
|
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 {
|
.knowledge-tree {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.28rem;
|
gap: 0.28rem;
|
||||||
|
|
|
||||||
215
admin/admin.js
215
admin/admin.js
|
|
@ -45,6 +45,8 @@ const state = {
|
||||||
page: null,
|
page: null,
|
||||||
projectConfig: null,
|
projectConfig: null,
|
||||||
projectConfigDirty: false,
|
projectConfigDirty: false,
|
||||||
|
publishJob: null,
|
||||||
|
publishResumeAttempted: false,
|
||||||
knowledge: null,
|
knowledge: null,
|
||||||
knowledgeSelectedId: null,
|
knowledgeSelectedId: null,
|
||||||
knowledgeSavedSelection: null,
|
knowledgeSavedSelection: null,
|
||||||
|
|
@ -486,6 +488,19 @@ function setStatus(message) {
|
||||||
el.status.textContent = 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) {
|
function setDirty(isDirty, message = null) {
|
||||||
state.dirty = isDirty;
|
state.dirty = isDirty;
|
||||||
el.dirtyState.dataset.dirty = String(isDirty);
|
el.dirtyState.dataset.dirty = String(isDirty);
|
||||||
|
|
@ -603,15 +618,15 @@ function applyProjectConfigSurface() {
|
||||||
|
|
||||||
async function loadProjectConfig() {
|
async function loadProjectConfig() {
|
||||||
const response = await fetch("/api/project/config");
|
const response = await fetch("/api/project/config");
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await readApiJson(response, "Не удалось загрузить настройки проекта");
|
||||||
|
|
||||||
if (!response.ok || !payload) {
|
|
||||||
throw new Error(payload?.error || "Не удалось загрузить настройки проекта");
|
|
||||||
}
|
|
||||||
|
|
||||||
state.projectConfig = payload;
|
state.projectConfig = payload;
|
||||||
state.projectConfigDirty = false;
|
state.projectConfigDirty = false;
|
||||||
applyProjectConfigSurface();
|
applyProjectConfigSurface();
|
||||||
|
if (!state.publishResumeAttempted) {
|
||||||
|
state.publishResumeAttempted = true;
|
||||||
|
resumeActivePublishJob().catch(() => {});
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -676,14 +691,136 @@ async function checkProjectConnection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishProject() {
|
async function publishProject() {
|
||||||
const response = await fetch("/api/project/publish", { method: "POST" });
|
const response = await fetch("/api/project/publish/start", { method: "POST" });
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await readApiJson(response, "Не удалось запустить публикацию проекта");
|
||||||
|
if (!payload?.ok || !payload.job?.id) throw new Error(payload?.error || "Сервер не вернул ID публикации");
|
||||||
|
|
||||||
if (!response.ok || !payload?.ok) {
|
state.publishJob = payload.job;
|
||||||
throw new Error(payload?.error || "Не удалось опубликовать проект");
|
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) {
|
function publishStatusText(payload) {
|
||||||
|
|
@ -2074,16 +2211,45 @@ async function uploadSiteArchive(file) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file, file.name);
|
formData.append("file", file, file.name);
|
||||||
|
|
||||||
setStatus(`Загружаем архив сайта: ${file.name}...`);
|
const payload = await new Promise((resolve, reject) => {
|
||||||
const response = await fetch("/api/project/upload-site", {
|
const request = new XMLHttpRequest();
|
||||||
method: "POST",
|
request.open("POST", "/api/project/upload-site");
|
||||||
body: formData,
|
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();
|
state.projectConfig = await loadProjectConfig();
|
||||||
renderProjectSettings();
|
renderProjectSettings();
|
||||||
|
|
@ -2255,6 +2421,7 @@ function renderProjectSettings() {
|
||||||
});
|
});
|
||||||
|
|
||||||
el.projectSettingsForm.append(projectCard, deployCard, runtimeCard);
|
el.projectSettingsForm.append(projectCard, deployCard, runtimeCard);
|
||||||
|
updatePublishProgressSurface();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
|
|
@ -5532,8 +5699,7 @@ async function loadKnowledge() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/knowledge");
|
const response = await fetch("/api/knowledge");
|
||||||
if (!response.ok) throw new Error(await response.text());
|
state.knowledge = await readApiJson(response, "Не удалось загрузить базу знаний");
|
||||||
state.knowledge = await response.json();
|
|
||||||
state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null;
|
state.knowledgeSelectedId = readAdminLayoutState().knowledgeSelectedId || state.knowledgeSelectedId || null;
|
||||||
ensureKnowledgeSelection();
|
ensureKnowledgeSelection();
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
@ -5587,11 +5753,8 @@ async function loadPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [pageResponse, templatesResponse] = await Promise.all([fetch("/api/page/home"), fetch("/api/block-templates/home")]);
|
const [pageResponse, templatesResponse] = await Promise.all([fetch("/api/page/home"), fetch("/api/block-templates/home")]);
|
||||||
if (!pageResponse.ok) throw new Error(await pageResponse.text());
|
state.page = await readApiJson(pageResponse, "Не удалось загрузить home.json");
|
||||||
if (!templatesResponse.ok) throw new Error(await templatesResponse.text());
|
state.templates = await readApiJson(templatesResponse, "Не удалось загрузить шаблоны блоков");
|
||||||
|
|
||||||
state.page = await pageResponse.json();
|
|
||||||
state.templates = await templatesResponse.json();
|
|
||||||
ensureStaticElements();
|
ensureStaticElements();
|
||||||
ensureSectionsRegion();
|
ensureSectionsRegion();
|
||||||
ensureSeoModel();
|
ensureSeoModel();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { createReadStream, statSync } from "node:fs";
|
import { createReadStream, statSync } from "node:fs";
|
||||||
import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { extname, join, normalize, relative, resolve } from "node:path";
|
import { extname, join, normalize, relative, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
@ -16,6 +16,9 @@ const host = process.env.HOST || "127.0.0.1";
|
||||||
const activeProjectId = process.env.CMS_PROJECT || "nodedc";
|
const activeProjectId = process.env.CMS_PROJECT || "nodedc";
|
||||||
const projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.json`);
|
const projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.json`);
|
||||||
const projectSecretsPath = join(cmsRoot, "projects", `${activeProjectId}.secrets.json`);
|
const projectSecretsPath = join(cmsRoot, "projects", `${activeProjectId}.secrets.json`);
|
||||||
|
const publishLogPath = join(cmsRoot, "projects", `${activeProjectId}.publish.log.jsonl`);
|
||||||
|
const publishJobs = new Map();
|
||||||
|
const PUBLISH_JOB_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
async function loadProjectConfig() {
|
async function loadProjectConfig() {
|
||||||
const source = await readFile(projectConfigPath, "utf8");
|
const source = await readFile(projectConfigPath, "utf8");
|
||||||
|
|
@ -1725,12 +1728,12 @@ async function unpackSiteArchive({ fileName, fileBuffer }) {
|
||||||
async function readDeployConfig() {
|
async function readDeployConfig() {
|
||||||
const deploy = projectConfig.deploy || {};
|
const deploy = projectConfig.deploy || {};
|
||||||
const method = String(deploy.method || "sftp").trim().toLowerCase();
|
const method = String(deploy.method || "sftp").trim().toLowerCase();
|
||||||
const password = await readDeployPasswordSecret();
|
|
||||||
|
|
||||||
if (!deploy.enabled) throw new Error("Деплой выключен в настройках проекта");
|
if (!deploy.enabled) throw new Error("Деплой выключен в настройках проекта");
|
||||||
if (method !== "sftp") throw new Error("Сейчас поддержан только SFTP. Для McHost используйте метод sftp и порт 22");
|
if (method !== "sftp") throw new Error("Сейчас поддержан только SFTP. Для McHost используйте метод sftp и порт 22");
|
||||||
if (!deploy.host) throw new Error("Укажите FTP / SFTP host");
|
if (!deploy.host) throw new Error("Укажите FTP / SFTP host");
|
||||||
if (!deploy.username) throw new Error("Укажите логин SFTP");
|
if (!deploy.username) throw new Error("Укажите логин SFTP");
|
||||||
|
const password = await readDeployPasswordSecret();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
method,
|
method,
|
||||||
|
|
@ -1760,7 +1763,77 @@ async function shouldSkipRemoteFile(client, localFile, remotePath) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishProject({ rendered = false } = {}) {
|
function publishJobPercent(job) {
|
||||||
|
if (job.state === "completed") return 100;
|
||||||
|
if (!job.totalBytes) return 0;
|
||||||
|
return Math.max(0, Math.min(100, Math.round((job.processedBytes / job.totalBytes) * 1000) / 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicPublishJob(job) {
|
||||||
|
if (!job) return null;
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
state: job.state,
|
||||||
|
rendered: job.rendered,
|
||||||
|
host: job.host,
|
||||||
|
remoteRoot: job.remoteRoot,
|
||||||
|
totalFiles: job.totalFiles,
|
||||||
|
totalBytes: job.totalBytes,
|
||||||
|
totalLabel: formatProjectSize(job.totalBytes),
|
||||||
|
processedFiles: job.processedFiles,
|
||||||
|
processedBytes: job.processedBytes,
|
||||||
|
processedLabel: formatProjectSize(job.processedBytes),
|
||||||
|
uploadedFiles: job.uploadedFiles,
|
||||||
|
uploadedBytes: job.uploadedBytes,
|
||||||
|
uploadedLabel: formatProjectSize(job.uploadedBytes),
|
||||||
|
skippedFiles: job.skippedFiles,
|
||||||
|
skippedBytes: job.skippedBytes,
|
||||||
|
skippedLabel: formatProjectSize(job.skippedBytes),
|
||||||
|
currentFile: job.currentFile,
|
||||||
|
currentFileBytes: job.currentFileBytes,
|
||||||
|
currentFileSize: job.currentFileSize,
|
||||||
|
percent: publishJobPercent(job),
|
||||||
|
error: job.error,
|
||||||
|
startedAt: job.startedAt,
|
||||||
|
updatedAt: job.updatedAt,
|
||||||
|
completedAt: job.completedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPublishJobs() {
|
||||||
|
const cutoff = Date.now() - PUBLISH_JOB_RETENTION_MS;
|
||||||
|
for (const [id, job] of publishJobs) {
|
||||||
|
const timestamp = Date.parse(job.completedAt || job.updatedAt || job.startedAt || 0);
|
||||||
|
if (timestamp && timestamp < cutoff) publishJobs.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePublishJob() {
|
||||||
|
return [...publishJobs.values()].find((job) => !["completed", "failed"].includes(job.state)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestPublishJob() {
|
||||||
|
return [...publishJobs.values()].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt))[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePublishLog(job, event, extra = {}) {
|
||||||
|
const entry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
event,
|
||||||
|
project: activeProjectId,
|
||||||
|
job: publicPublishJob(job),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
await appendFile(publishLogPath, `${JSON.stringify(entry)}\n`, "utf8").catch((error) => {
|
||||||
|
console.error(`Publish log write failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchPublishJob(job, patch = {}) {
|
||||||
|
Object.assign(job, patch, { updatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishProject({ rendered = false, job = null } = {}) {
|
||||||
const deployConfig = await readDeployConfig();
|
const deployConfig = await readDeployConfig();
|
||||||
const files = await collectPublicDeployFiles();
|
const files = await collectPublicDeployFiles();
|
||||||
if (!files.length) throw new Error("Нет публичных файлов для публикации");
|
if (!files.length) throw new Error("Нет публичных файлов для публикации");
|
||||||
|
|
@ -1777,9 +1850,22 @@ async function publishProject({ rendered = false } = {}) {
|
||||||
uploadedFiles: 0,
|
uploadedFiles: 0,
|
||||||
skippedFiles: 0,
|
skippedFiles: 0,
|
||||||
uploadedBytes: 0,
|
uploadedBytes: 0,
|
||||||
|
skippedBytes: 0,
|
||||||
|
processedFiles: 0,
|
||||||
|
processedBytes: 0,
|
||||||
totalBytes: files.reduce((sum, file) => sum + file.size, 0),
|
totalBytes: files.reduce((sum, file) => sum + file.size, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
touchPublishJob(job, {
|
||||||
|
state: "connecting",
|
||||||
|
host: report.host,
|
||||||
|
remoteRoot: report.remoteRoot,
|
||||||
|
totalFiles: report.totalFiles,
|
||||||
|
totalBytes: report.totalBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect({
|
await client.connect({
|
||||||
host: deployConfig.host,
|
host: deployConfig.host,
|
||||||
|
|
@ -1789,30 +1875,156 @@ async function publishProject({ rendered = false } = {}) {
|
||||||
readyTimeout: 30000,
|
readyTimeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (job) touchPublishJob(job, { state: "publishing" });
|
||||||
|
|
||||||
await ensureRemoteDirectory(client, deployConfig.remoteRoot, directoryCache);
|
await ensureRemoteDirectory(client, deployConfig.remoteRoot, directoryCache);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const remotePath = remoteJoin(deployConfig.remoteRoot, file.relativePath);
|
const remotePath = remoteJoin(deployConfig.remoteRoot, file.relativePath);
|
||||||
|
const completedBytes = report.processedBytes;
|
||||||
|
if (job) {
|
||||||
|
touchPublishJob(job, {
|
||||||
|
currentFile: file.relativePath,
|
||||||
|
currentFileBytes: 0,
|
||||||
|
currentFileSize: file.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
await ensureRemoteDirectory(client, remoteParent(remotePath), directoryCache);
|
await ensureRemoteDirectory(client, remoteParent(remotePath), directoryCache);
|
||||||
|
|
||||||
if (await shouldSkipRemoteFile(client, file, remotePath)) {
|
if (await shouldSkipRemoteFile(client, file, remotePath)) {
|
||||||
report.skippedFiles += 1;
|
report.skippedFiles += 1;
|
||||||
|
report.skippedBytes += file.size;
|
||||||
|
report.processedFiles += 1;
|
||||||
|
report.processedBytes += file.size;
|
||||||
|
if (job) {
|
||||||
|
touchPublishJob(job, {
|
||||||
|
skippedFiles: report.skippedFiles,
|
||||||
|
skippedBytes: report.skippedBytes,
|
||||||
|
processedFiles: report.processedFiles,
|
||||||
|
processedBytes: report.processedBytes,
|
||||||
|
currentFileBytes: file.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.fastPut(file.absolutePath, remotePath);
|
await client.fastPut(file.absolutePath, remotePath, {
|
||||||
|
step: (transferred) => {
|
||||||
|
if (!job) return;
|
||||||
|
const currentFileBytes = Math.max(0, Math.min(file.size, Number(transferred || 0)));
|
||||||
|
touchPublishJob(job, {
|
||||||
|
currentFileBytes,
|
||||||
|
processedBytes: completedBytes + currentFileBytes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
report.uploadedFiles += 1;
|
report.uploadedFiles += 1;
|
||||||
report.uploadedBytes += file.size;
|
report.uploadedBytes += file.size;
|
||||||
|
report.processedFiles += 1;
|
||||||
|
report.processedBytes += file.size;
|
||||||
|
if (job) {
|
||||||
|
touchPublishJob(job, {
|
||||||
|
uploadedFiles: report.uploadedFiles,
|
||||||
|
uploadedBytes: report.uploadedBytes,
|
||||||
|
processedFiles: report.processedFiles,
|
||||||
|
processedBytes: report.processedBytes,
|
||||||
|
currentFileBytes: file.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
report.totalLabel = formatProjectSize(report.totalBytes);
|
report.totalLabel = formatProjectSize(report.totalBytes);
|
||||||
report.uploadedLabel = formatProjectSize(report.uploadedBytes);
|
report.uploadedLabel = formatProjectSize(report.uploadedBytes);
|
||||||
|
report.skippedLabel = formatProjectSize(report.skippedBytes);
|
||||||
return report;
|
return report;
|
||||||
} finally {
|
} finally {
|
||||||
await client.end().catch(() => {});
|
await client.end().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startPublishJob({ rendered = false } = {}) {
|
||||||
|
cleanupPublishJobs();
|
||||||
|
const activeJob = activePublishJob();
|
||||||
|
if (activeJob) return { job: publicPublishJob(activeJob), reused: true };
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const job = {
|
||||||
|
id: `${Date.now().toString(36)}-${randomBytes(5).toString("hex")}`,
|
||||||
|
state: "queued",
|
||||||
|
rendered,
|
||||||
|
host: "",
|
||||||
|
remoteRoot: "",
|
||||||
|
totalFiles: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
processedFiles: 0,
|
||||||
|
processedBytes: 0,
|
||||||
|
uploadedFiles: 0,
|
||||||
|
uploadedBytes: 0,
|
||||||
|
skippedFiles: 0,
|
||||||
|
skippedBytes: 0,
|
||||||
|
currentFile: "",
|
||||||
|
currentFileBytes: 0,
|
||||||
|
currentFileSize: 0,
|
||||||
|
error: "",
|
||||||
|
startedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
completedAt: "",
|
||||||
|
};
|
||||||
|
publishJobs.set(job.id, job);
|
||||||
|
writePublishLog(job, "started");
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
touchPublishJob(job, { state: "scanning" });
|
||||||
|
const report = await publishProject({ rendered, job });
|
||||||
|
touchPublishJob(job, {
|
||||||
|
state: "completed",
|
||||||
|
processedFiles: report.totalFiles,
|
||||||
|
processedBytes: report.totalBytes,
|
||||||
|
uploadedFiles: report.uploadedFiles,
|
||||||
|
uploadedBytes: report.uploadedBytes,
|
||||||
|
skippedFiles: report.skippedFiles,
|
||||||
|
skippedBytes: report.skippedBytes,
|
||||||
|
currentFile: "",
|
||||||
|
currentFileBytes: 0,
|
||||||
|
currentFileSize: 0,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await writePublishLog(job, "completed");
|
||||||
|
} catch (error) {
|
||||||
|
touchPublishJob(job, {
|
||||||
|
state: "failed",
|
||||||
|
error: error?.message || String(error),
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
console.error(`Publish job ${job.id} failed`, error);
|
||||||
|
await writePublishLog(job, "failed", { stack: error?.stack || "" });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { job: publicPublishJob(job), reused: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentPublishLog(limit = 20) {
|
||||||
|
const source = await readFile(publishLogPath, "utf8").catch((error) => {
|
||||||
|
if (error?.code === "ENOENT") return "";
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return source
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(-Math.max(1, Math.min(100, Number(limit || 20))))
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return { timestamp: "", event: "invalid-log-entry", message: line };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
async function maybePublishAfterRender(renderResult) {
|
async function maybePublishAfterRender(renderResult) {
|
||||||
if (!projectConfig.deploy?.enabled) {
|
if (!projectConfig.deploy?.enabled) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -2605,6 +2817,28 @@ const server = createServer(async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/project/publish/start") {
|
||||||
|
if (!ensureSiteReady(res)) return;
|
||||||
|
sendJson(res, 202, { ok: true, ...startPublishJob() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/project/publish/status") {
|
||||||
|
const id = String(url.searchParams.get("jobId") || "").trim();
|
||||||
|
const job = id ? publishJobs.get(id) : latestPublishJob();
|
||||||
|
if (!job) {
|
||||||
|
sendJson(res, 404, { ok: false, error: "Публикация не найдена" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { ok: true, job: publicPublishJob(job) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/project/publish/log") {
|
||||||
|
sendJson(res, 200, { ok: true, entries: await recentPublishLog(url.searchParams.get("limit")) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/api/project/upload-site") {
|
if (req.method === "POST" && url.pathname === "/api/project/upload-site") {
|
||||||
if (!isMultipartRequest(req)) {
|
if (!isMultipartRequest(req)) {
|
||||||
sendJson(res, 400, { ok: false, error: "Ожидается multipart/form-data с ZIP-архивом сайта" });
|
sendJson(res, 400, { ok: false, error: "Ожидается multipart/form-data с ZIP-архивом сайта" });
|
||||||
|
|
@ -2764,6 +2998,7 @@ const server = createServer(async (req, res) => {
|
||||||
|
|
||||||
await serveStatic(req, res);
|
await serveStatic(req, res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`${req.method || "REQUEST"} ${req.url || "/"} failed`, error);
|
||||||
sendJson(res, 500, { ok: false, error: error.message });
|
sendJson(res, 500, { ok: false, error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue