diff --git a/.gitignore b/.gitignore index 257ad5e..455af94 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ __pycache__/ *.local infra/.env projects/*.secrets.json +projects/*.publish.log.jsonl .cms-smoke.png diff --git a/admin/admin.css b/admin/admin.css index d90ae74..c223ffe 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -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; diff --git a/admin/admin.js b/admin/admin.js index eec608d..355c470 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -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 = ` +
+
+
Публикация
+

+
+ +
+
+ +
+
+ + + + +
+

+ + `; + 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(); diff --git a/server/admin-server.mjs b/server/admin-server.mjs index 00bf617..6530f51 100644 --- a/server/admin-server.mjs +++ b/server/admin-server.mjs @@ -1,6 +1,6 @@ import { createServer } from "node:http"; 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 { fileURLToPath } from "node:url"; 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 projectConfigPath = join(cmsRoot, "projects", `${activeProjectId}.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() { const source = await readFile(projectConfigPath, "utf8"); @@ -1725,12 +1728,12 @@ async function unpackSiteArchive({ fileName, fileBuffer }) { async function readDeployConfig() { const deploy = projectConfig.deploy || {}; const method = String(deploy.method || "sftp").trim().toLowerCase(); - const password = await readDeployPasswordSecret(); if (!deploy.enabled) throw new Error("Деплой выключен в настройках проекта"); if (method !== "sftp") throw new Error("Сейчас поддержан только SFTP. Для McHost используйте метод sftp и порт 22"); if (!deploy.host) throw new Error("Укажите FTP / SFTP host"); if (!deploy.username) throw new Error("Укажите логин SFTP"); + const password = await readDeployPasswordSecret(); return { 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 files = await collectPublicDeployFiles(); if (!files.length) throw new Error("Нет публичных файлов для публикации"); @@ -1777,9 +1850,22 @@ async function publishProject({ rendered = false } = {}) { uploadedFiles: 0, skippedFiles: 0, uploadedBytes: 0, + skippedBytes: 0, + processedFiles: 0, + processedBytes: 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 { await client.connect({ host: deployConfig.host, @@ -1789,30 +1875,156 @@ async function publishProject({ rendered = false } = {}) { readyTimeout: 30000, }); + if (job) touchPublishJob(job, { state: "publishing" }); + await ensureRemoteDirectory(client, deployConfig.remoteRoot, directoryCache); for (const file of files) { 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); if (await shouldSkipRemoteFile(client, file, remotePath)) { 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; } - 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.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.uploadedLabel = formatProjectSize(report.uploadedBytes); + report.skippedLabel = formatProjectSize(report.skippedBytes); return report; } finally { 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) { if (!projectConfig.deploy?.enabled) { return { @@ -2605,6 +2817,28 @@ const server = createServer(async (req, res) => { 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 (!isMultipartRequest(req)) { sendJson(res, 400, { ok: false, error: "Ожидается multipart/form-data с ZIP-архивом сайта" }); @@ -2764,6 +2998,7 @@ const server = createServer(async (req, res) => { await serveStatic(req, res); } catch (error) { + console.error(`${req.method || "REQUEST"} ${req.url || "/"} failed`, error); sendJson(res, 500, { ok: false, error: error.message }); } });