mirror of
https://github.com/andrey271192/amnezia_web.git
synced 2026-09-20 14:41:59 +00:00
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:
@@ -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_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 |
|
| `COMMUNITY_INSTALL_FETCH_MS` | `60000` | Таймаут HTTP при скачивании установщика из GitHub |
|
||||||
| `PRIVATE_INSTALL_SCRIPT_MAX_BYTES` | `2097152` | Максимальный размер скачанного скрипта (байты) |
|
| `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_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_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO` | `0` | `1` — не выполнять `docker rm` FREE/лендинга перед install (если знаете, что делаете). |
|
||||||
| `COMMUNITY_PRO_INSTALL_HELPER_IMAGE` | `docker:26-cli` | Образ с бинарём `docker` для фонового контейнера (нужен доступ к демону по сокету). |
|
| `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`**, чтобы не терять настройку.
|
Форма показывается только если в контейнере **`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`
|
### `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`**.
|
На **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`**.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amnezia-admin",
|
"name": "amnezia-admin",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).",
|
"description": "amnezia_web — базовая панель AmneziaWG (FREE: просмотр и удаление клиентов; полная версия — PRO).",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
141
public/app.js
141
public/app.js
@@ -63,6 +63,143 @@ let uiHidden = { users: false, warp: false, cascade: false };
|
|||||||
const editionBanner = document.querySelector("#edition-banner");
|
const editionBanner = document.querySelector("#edition-banner");
|
||||||
const DEFAULT_HEADER_SUB = document.querySelector(".top .sub")?.textContent?.trim() || "";
|
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). */
|
/** Состояние редакции панели (community = просмотр + удаление; остальные мутации — PRO). */
|
||||||
let editionState = {
|
let editionState = {
|
||||||
tier: "pro",
|
tier: "pro",
|
||||||
@@ -104,6 +241,7 @@ function applyEditionPayload(data) {
|
|||||||
}
|
}
|
||||||
if (editionBanner) {
|
if (editionBanner) {
|
||||||
if (editionState.tier === "community") {
|
if (editionState.tier === "community") {
|
||||||
|
const preservedInstallLog = editionBanner.querySelector(".edition-banner-install-log");
|
||||||
editionBanner.classList.remove("hidden");
|
editionBanner.classList.remove("hidden");
|
||||||
editionBanner.innerHTML = "";
|
editionBanner.innerHTML = "";
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
@@ -184,6 +322,7 @@ function applyEditionPayload(data) {
|
|||||||
j.message ||
|
j.message ||
|
||||||
"Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу).";
|
"Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу).";
|
||||||
inp.value = "";
|
inp.value = "";
|
||||||
|
startCommunityInstallLogPollingFresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
msg.className = "edition-banner-act-msg status err";
|
msg.className = "edition-banner-act-msg status err";
|
||||||
msg.textContent = String(e?.message || e);
|
msg.textContent = String(e?.message || e);
|
||||||
@@ -193,9 +332,11 @@ function applyEditionPayload(data) {
|
|||||||
|
|
||||||
row.append(inp, go);
|
row.append(inp, go);
|
||||||
act.append(cap, row, msg);
|
act.append(cap, row, msg);
|
||||||
|
if (preservedInstallLog) act.appendChild(preservedInstallLog);
|
||||||
editionBanner.appendChild(act);
|
editionBanner.appendChild(act);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
stopCommunityInstallLogPolling();
|
||||||
editionBanner.classList.add("hidden");
|
editionBanner.classList.add("hidden");
|
||||||
editionBanner.innerHTML = "";
|
editionBanner.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -995,3 +995,44 @@ tr:last-child td {
|
|||||||
.edition-act-ok {
|
.edition-act-ok {
|
||||||
color: #a7f3d0 !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_UPGRADE_URL COMMUNITY_UPGRADE_PITCH AMNEZIA_EDITION \
|
||||||
COMMUNITY_DISABLE_DOCKER_CLI_HELPER COMMUNITY_SKIP_REMOVE_FREE_BEFORE_PRIVATE_PRO COMMUNITY_PRO_INSTALL_HELPER_IMAGE \
|
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 \
|
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
|
EXPORT_CONFIG_SECRET CLIENT_CONFIG_ENDPOINT CLIENT_EXPORT_DNS1 CLIENT_EXPORT_DNS2; do
|
||||||
if [[ -z "${!__reuse_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then
|
if [[ -z "${!__reuse_var:-}" ]] && [[ -n "${PREV_CONTAINER_ENV}" ]]; then
|
||||||
PREV_VAL=""
|
PREV_VAL=""
|
||||||
@@ -202,7 +203,7 @@ elif [[ -f "${INSTALL_DIR}/.amnezia-panel-edition" ]]; then
|
|||||||
echo "→ AMNEZIA_EDITION из ${INSTALL_DIR}/.amnezia-panel-edition: ${__PE}"
|
echo "→ AMNEZIA_EDITION из ${INSTALL_DIR}/.amnezia-panel-edition: ${__PE}"
|
||||||
fi
|
fi
|
||||||
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
|
if [[ -n "${!__ce_var:-}" ]]; then
|
||||||
RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
|
RUN_ENV+=( -e "${__ce_var}=${!__ce_var}" )
|
||||||
fi
|
fi
|
||||||
|
|||||||
110
server.js
110
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) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user