feat(community): live install log panel in FREE UI + install-log API

Poll community-install-last.log after «Установить PRO» with GET
/api/community/install-log; preserve log DOM across banner redraws.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-15 01:17:47 +03:00
parent 902bd99395
commit 6c213ef49e
6 changed files with 299 additions and 2 deletions

View File

@@ -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). Диапазон 1KiB512KiB через env. |
| `COMMUNITY_INSTALL_LOG_API_CHUNK_MAX` | `524288` | Максимум байт **за один** запрос «догонять» журнал (~512KiB; верхний предел через 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`**.

View File

@@ -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",

View File

@@ -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 =
'<span class="muted">Журнал установки</span> <button type="button" class="btn small ghost edition-install-log-pause" title="Пауза обновления">Пауза</button>';
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 ||
"Установка запущена. Через 25 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу).";
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 = "";
}

View File

@@ -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;
}

View File

@@ -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

110
server.js
View File

@@ -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 });
});