From 6c213ef49ed8f132e78178d62c9ecf27064d597c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Fri, 15 May 2026 01:17:47 +0300 Subject: [PATCH] feat(community): live install log panel in FREE UI + install-log API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poll community-install-last.log after «Установить PRO» with GET /api/community/install-log; preserve log DOM across banner redraws. Co-authored-by: Cursor --- README.md | 4 ++ package.json | 2 +- public/app.js | 141 +++++++++++++++++++++++++++++++++++++++++++++ public/styles.css | 41 +++++++++++++ scripts/install.sh | 3 +- server.js | 110 +++++++++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e06fa3b..0d5624a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ cd /opt/amnezia-admin && chmod +x scripts/install.sh && sudo SKIP_DOWNLOAD=1 bas | `COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL` | `https://raw.githubusercontent.com/andrey271192/amnezia_web-pro/main/scripts/install.sh` | Сырой URL `scripts/install.sh` в **вашем** приватном репозитории PRO | | `COMMUNITY_INSTALL_FETCH_MS` | `60000` | Таймаут HTTP при скачивании установщика из GitHub | | `PRIVATE_INSTALL_SCRIPT_MAX_BYTES` | `2097152` | Максимальный размер скачанного скрипта (байты) | +| `COMMUNITY_INSTALL_LOG_TAIL_BYTES` | `98304` | Первый ответ **`GET /api/community/install-log`** без `since` отдаёт **хвост** последних N байт (для живого блока в UI при установке PRO). Диапазон 1 KiB … 512 KiB через env. | +| `COMMUNITY_INSTALL_LOG_API_CHUNK_MAX` | `524288` | Максимум байт **за один** запрос «догонять» журнал (~512 KiB; верхний предел через env ограничен). | | `COMMUNITY_DISABLE_DOCKER_CLI_HELPER` | _(не задано)_ | Если `1`, установка из UI снова только «bash внутри панели» (часто конфликт **:8080** с PRO). По умолчанию используется одноразовый контейнер **docker:cli**, который **снимает FREE** и зависший PRO перед `install.sh`. | | `COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO` | `0` | `1` — не выполнять `docker rm` FREE/лендинга перед install (если знаете, что делаете). | | `COMMUNITY_PRO_INSTALL_HELPER_IMAGE` | `docker:26-cli` | Образ с бинарём `docker` для фонового контейнера (нужен доступ к демону по сокету). | @@ -92,6 +94,8 @@ curl -fsSL https://raw.githubusercontent.com/andrey271192/amnezia_web/main/scrip Форма показывается только если в контейнере **`ALLOW_COMMUNITY_GITHUB_ACTIVATION=1`** (и редакция **community**). Переменную нужно хотя бы один раз задать с **`export`** (см. выше). Начиная с актуального **`install.sh`**, при следующем обновлении панели **`ALLOW_COMMUNITY_*`**, **`COMMUNITY_UPGRADE_*`**, **`AMNEZIA_EDITION`** и ряд других значений **подтягиваются из предыдущего контейнера `amnezia-admin`**, чтобы не терять настройку. +После успешной кнопки **«Установить PRO»** в той же панели открывается **живой блок «Журнал установки»** (поллинг каждые 2 с к **`GET /api/community/install-log`**; для запросов нужна сессия в браузере). Пока старый контейнер FREE снят и новый образ ещё не поднял ту же вкладку, поток журнала в браузере **прерывается** — откройте адрес панели снова после старта PRO; файл **`community-install-last.log`** на томе данных тот же. Кнопка **«Пауза»** останавливает опрос журнала. + ### `Bind for 0.0.0.0:8080 failed: port is already allocated` у `amnezia-admin-pro` На **8080** уже слушает **FREE**‑панель (**`amnezia-admin`**). Пока она работает, второй контейнер панели (PRO) на тот же порт не поднимется. **Кнопка «Установить PRO» из UI по умолчанию** использует одноразовый контейнер **`docker:cli`** (пауза → **`docker rm`** FREE / лендинг / возможный **`amnezia-admin-pro` в Created** → затем ваш **`install.sh`**). Иначе вручную: **`docker rm -f amnezia-admin`**, затем установщик PRO. Отключить авто-снос: **`COMMUNITY_DISABLE_DOCKER_CLI_HELPER=1`**. diff --git a/package.json b/package.json index 441bf2b..178a7e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amnezia-admin", - "version": "1.2.0", + "version": "1.2.1", "private": true, "description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).", "license": "MIT", diff --git a/public/app.js b/public/app.js index 37d4769..50524d5 100644 --- a/public/app.js +++ b/public/app.js @@ -63,6 +63,143 @@ let uiHidden = { users: false, warp: false, cascade: false }; const editionBanner = document.querySelector("#edition-banner"); const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || ""; +/** POLL для живого журнала установки PRO (@type {number | null}) */ +let communityInstallLogInterval = null; +/** Следующий offset для GET /api/community/install-log (@type {number | null}) */ +let communityInstallLogSince = null; +/** Аккумулированный текст (дополняется кусками) */ +let communityInstallLogShown = ""; + +function stopCommunityInstallLogPolling() { + if (communityInstallLogInterval !== null) { + clearInterval(communityInstallLogInterval); + communityInstallLogInterval = null; + } +} + +async function pollCommunityInstallLogOnce() { + const pre = editionBanner?.querySelector(".edition-banner-install-pre"); + const statusLine = editionBanner?.querySelector(".edition-banner-install-status"); + if (!pre || !editionBanner?.querySelector(".edition-banner-install-log")) { + stopCommunityInstallLogPolling(); + return; + } + + try { + const url = + communityInstallLogSince === null + ? "/api/community/install-log" + : `/api/community/install-log?since=${encodeURIComponent(String(communityInstallLogSince))}`; + const r = await fetch(url, { credentials: "same-origin" }); + const j = await r.json().catch(() => null); + if (!r.ok) { + if (statusLine) { + statusLine.textContent = + typeof j?.error === "string" ? j.error : `Ошибка ${r.status}`; + statusLine.classList.add("err"); + } + return; + } + if (statusLine) { + statusLine.classList.remove("err"); + if (!j.exists) { + statusLine.textContent = + communityInstallLogSince === null ? "Журнал пока пустой — секунду…" : "Обновление…"; + } else if (communityInstallLogSince === null && j.totalBytes > j.since1) { + statusLine.textContent = `Хвост журнала (${fmtBytesRu(Math.max(j.since1 - j.since0, 0))} из ~${fmtBytesRu(j.totalBytes)}) · автообновление…`; + } else { + statusLine.textContent = `Размер журнала ≈ ${fmtBytesRu(j.totalBytes)} · автообновление каждые 2 с…`; + } + } + + const chunk = typeof j.chunk === "string" ? j.chunk : ""; + if (!j.exists) return; + + const replace = Boolean(j.resetSuggested || communityInstallLogSince === null); + if (replace) communityInstallLogShown = chunk; + else communityInstallLogShown += chunk; + + pre.textContent = communityInstallLogShown; + communityInstallLogSince = typeof j.since1 === "number" ? j.since1 : communityInstallLogSince; + pre.scrollTop = pre.scrollHeight; + } catch (_e) { + if (statusLine) { + statusLine.textContent = "Не удалось запросить журнал (сеть)."; + statusLine.classList.add("err"); + } + } +} + +function fmtBytesRu(n) { + const x = Number(n); + if (!Number.isFinite(x)) return ""; + if (x < 1024) return `${Math.round(x)} B`; + if (x < 1024 * 1024) return `${Math.round(x / 102.4) / 10} KiB`; + return `${Math.round(x / (1024 * 102.4)) / 10} MiB`; +} + +function ensureEditionBannerInstallLogPanel() { + if (!editionBanner) return null; + let wrap = editionBanner.querySelector(".edition-banner-install-log"); + if (!wrap) { + wrap = document.createElement("div"); + wrap.className = "edition-banner-install-log"; + const hdr = document.createElement("div"); + hdr.className = "edition-banner-install-log-head"; + hdr.innerHTML = + 'Журнал установки '; + + const statusLine = document.createElement("div"); + statusLine.className = "edition-banner-install-status muted small"; + statusLine.textContent = "Запрос журнала…"; + + const pre = document.createElement("pre"); + pre.className = "edition-banner-install-pre monospace"; + + hdr.querySelector(".edition-install-log-pause")?.addEventListener("click", () => { + const btn = hdr.querySelector(".edition-install-log-pause"); + if (!btn || !editionBanner.contains(wrap)) return; + const paused = btn.dataset.paused === "1"; + if (paused) { + btn.dataset.paused = ""; + btn.textContent = "Пауза"; + startCommunityInstallLogPollingResume(); + } else { + btn.dataset.paused = "1"; + btn.textContent = "Продолжить"; + stopCommunityInstallLogPolling(); + } + }); + + wrap.append(hdr, statusLine, pre); + editionBanner.querySelector(".edition-banner-activation")?.appendChild(wrap); + } + return wrap; +} + +function startCommunityInstallLogPollingResume() { + stopCommunityInstallLogPolling(); + void pollCommunityInstallLogOnce(); + communityInstallLogInterval = window.setInterval(() => { + void pollCommunityInstallLogOnce(); + }, 2000); +} + +/** Сброс и показ живого журнала (после «Установить PRO»). */ +function startCommunityInstallLogPollingFresh() { + communityInstallLogSince = null; + communityInstallLogShown = ""; + const wrap = ensureEditionBannerInstallLogPanel(); + const pre = wrap?.querySelector(".edition-banner-install-pre"); + const st = wrap?.querySelector(".edition-banner-install-status"); + if (pre) pre.textContent = ""; + if (st) { + st.classList.remove("err"); + st.textContent = "Запрос журнала…"; + } + startCommunityInstallLogPollingResume(); +} + /** Состояние редакции панели (community = просмотр + удаление; остальные мутации — PRO). */ let editionState = { tier: "pro", @@ -104,6 +241,7 @@ function applyEditionPayload(data) { } if (editionBanner) { if (editionState.tier === "community") { + const preservedInstallLog = editionBanner.querySelector(".edition-banner-install-log"); editionBanner.classList.remove("hidden"); editionBanner.innerHTML = ""; const wrap = document.createElement("div"); @@ -184,6 +322,7 @@ function applyEditionPayload(data) { j.message || "Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу)."; inp.value = ""; + startCommunityInstallLogPollingFresh(); } catch (e) { msg.className = "edition-banner-act-msg status err"; msg.textContent = String(e?.message || e); @@ -193,9 +332,11 @@ function applyEditionPayload(data) { row.append(inp, go); act.append(cap, row, msg); + if (preservedInstallLog) act.appendChild(preservedInstallLog); editionBanner.appendChild(act); } } else { + stopCommunityInstallLogPolling(); editionBanner.classList.add("hidden"); editionBanner.innerHTML = ""; } diff --git a/public/styles.css b/public/styles.css index b4a2530..1c6a3ac 100644 --- a/public/styles.css +++ b/public/styles.css @@ -995,3 +995,44 @@ tr:last-child td { .edition-act-ok { color: #a7f3d0 !important; } + +.edition-banner-install-log { + margin-top: 0.75rem; +} + +.edition-banner-install-log-head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 0.35rem; + font-size: 0.78rem; +} + +.edition-banner-install-status.small { + font-size: 0.72rem; + margin-bottom: 0.35rem; +} + +.edition-banner-install-status.err { + color: #fca5a5 !important; +} + +.edition-banner-install-pre { + max-height: 240px; + overflow: auto; + margin: 0; + padding: 0.5rem 0.65rem; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.75); + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + font-size: 11px; + line-height: 1.4; +} + +.edition-banner-install-pre.monospace { + font-family: "JetBrains Mono", ui-monospace, monospace; +} diff --git a/scripts/install.sh b/scripts/install.sh index c7e6dbb..22d25ba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -127,6 +127,7 @@ for __reuse_var in UI_HIDE_SECTIONS UI_HIDE_USERS UI_HIDE_WARP UI_HIDE_CASCADE W COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH AMNEZIA_EDITION \ COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE \ FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL \ + COMMUNITY_INSTALL_LOG_TAIL_BYTES COMMUNITY_INSTALL_LOG_API_CHUNK_MAX \ EXPORT_CONFIG_SECRET CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do if [[ -z "${!__reuse_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then PREV_VAL="" @@ -202,7 +203,7 @@ elif [[ -f "${INSTALL_DIR}/.amnezia-panel-edition" ]]; then echo "→ AMNEZIA_EDITION из ${INSTALL_DIR}/.amnezia-panel-edition: ${__PE}" fi fi -for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL; do +for __ce_var in COMMUNITY_UPGRADE_URL COMMUNITY_UPGRADE_PITCH ALLOW_COMMUNITY_GITHUB_ACTIVATION COMMUNITY_PRIVATE_INSTALL_SCRIPT_URL COMMUNITY_INSTALL_FETCH_MS PRIVATE_INSTALL_SCRIPT_MAX_BYTES COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE FREE_PANEL_CONTAINER_FOR_PRO_INSTALL FREE_LANDING_CONTAINER_FOR_PRO_INSTALL STALE_PRO_PANEL_CONTAINER_FOR_PRO_INSTALL COMMUNITY_INSTALL_LOG_TAIL_BYTES COMMUNITY_INSTALL_LOG_API_CHUNK_MAX; do if [[ -n "${!__ce_var:-}" ]]; then RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" ) fi diff --git a/server.js b/server.js index 4ca34ba..e34025c 100644 --- a/server.js +++ b/server.js @@ -1629,6 +1629,116 @@ cd /mnt/stage && exec bash ./install.sh >> /mnt/data/community-install-last.log } }); +const COMMUNITY_INSTALL_LOG_BASENAME = "community-install-last.log"; + +function clampCommunityInstallLogRange(name, fallback, minV, maxV) { + const x = Number(process.env[name]); + if (!Number.isFinite(x)) return fallback; + return Math.min(Math.max(Math.trunc(x), minV), maxV); +} + +/** + * Прочитать кусок `community-install-last.log`: без `since` — хвост (для первого экрана); + * с `since` — блок байт начиная с offset (до CHUNK байт за запрос). + */ +app.get("/api/community/install-log", requireAuth, (req, res) => { + if (!IS_COMMUNITY || !ALLOW_COMMUNITY_GITHUB_ACTIVATION) { + return res.status(403).json({ error: "Недоступно." }); + } + const TAIL_BYTES = clampCommunityInstallLogRange( + "COMMUNITY_INSTALL_LOG_TAIL_BYTES", + 96 * 1024, + 1024, + 512 * 1024, + ); + const MAX_CHUNK = clampCommunityInstallLogRange( + "COMMUNITY_INSTALL_LOG_API_CHUNK_MAX", + 512 * 1024, + 4096, + 2 * 1024 * 1024, + ); + + const logPath = path.join(DATA_DIR, COMMUNITY_INSTALL_LOG_BASENAME); + + const sendAbsent = () => + res.json({ + exists: false, + totalBytes: 0, + chunk: "", + since0: 0, + since1: 0, + resetSuggested: false, + }); + + if (!fs.existsSync(logPath)) { + sendAbsent(); + return; + } + + let stat; + try { + stat = fs.statSync(logPath); + if (!stat.isFile()) { + sendAbsent(); + return; + } + } catch { + return res.status(500).json({ error: "Не удалось прочитать журнал установки." }); + } + + const totalBytes = Number(stat.size) || 0; + let sinceParsed = NaN; + const q = req.query.since; + if (typeof q === "string" && q.trim() !== "") { + sinceParsed = Number(q.trim()); + } + let resetSuggested = false; + let sinceByte; + if (!Number.isFinite(sinceParsed) || sinceParsed < 0) { + sinceByte = Math.max(0, totalBytes - TAIL_BYTES); + } else if (sinceParsed > totalBytes) { + resetSuggested = true; + sinceByte = Math.max(0, totalBytes - TAIL_BYTES); + } else { + sinceByte = sinceParsed; + } + + const readable = Math.max(0, totalBytes - sinceByte); + const readLen = Math.min(readable, MAX_CHUNK); + if (readLen === 0 || totalBytes <= 0) { + res.json({ + exists: true, + totalBytes, + chunk: "", + since0: sinceByte, + since1: sinceByte, + resetSuggested, + }); + return; + } + + let chunkBuf = Buffer.alloc(readLen); + try { + const fd = fs.openSync(logPath, "r"); + try { + fs.readSync(fd, chunkBuf, 0, readLen, sinceByte); + } finally { + fs.closeSync(fd); + } + } catch { + return res.status(500).json({ error: "Ошибка чтения журнала установки." }); + } + + res.json({ + exists: true, + totalBytes, + chunk: chunkBuf.toString("utf8"), + since0: sinceByte, + since1: sinceByte + readLen, + resetSuggested, + }); +}); + app.get("/health", (_req, res) => { res.json({ ok: true }); });