const loginGate = document.querySelector("#login-gate"); const appRoot = document.querySelector("#app-root"); const loginForm = document.querySelector("#login-form"); const loginPassword = document.querySelector("#login-password"); const loginError = document.querySelector("#login-error"); const logoutBtn = document.querySelector("#logout"); const refreshBtn = document.querySelector("#refresh"); const clockServerEl = document.querySelector("#clock-server"); const clockLocalEl = document.querySelector("#clock-local"); const clockZoneDiffEl = document.querySelector("#clock-zone-diff"); const clockSyncBtn = document.querySelector("#clock-sync"); const rowsEl = document.querySelector("#rows"); const statusEl = document.querySelector("#status"); const peerCountEl = document.querySelector("#peer-count"); const wgShowEl = document.querySelector("#wg-show"); const warpPanel = document.querySelector("#warp-panel"); const warpStatusLine = document.querySelector("#warp-status-line"); const warpActionsEl = document.querySelector("#warp-actions"); const warpClientListEl = document.querySelector("#warp-client-list"); const warpWgShowEl = document.querySelector("#warp-wg-show"); const mtprotoPanel = document.querySelector("#mtproto-panel"); const mtprotoStatusLine = document.querySelector("#mtproto-status-line"); const mtprotoActionsEl = document.querySelector("#mtproto-actions"); const mtprotoLogsTailEl = document.querySelector("#mtproto-logs-tail"); const mtprotoBanner = document.querySelector("#mtproto-banner"); const mtprotoHostPortInput = document.querySelector("#mtproto-host-port"); const mtprotoSecretInput = document.querySelector("#mtproto-secret-opt"); const mtprotoCalloutEl = document.querySelector("#mtproto-callout"); const mtprotoLinkCardEl = document.querySelector("#mtproto-link-card"); const mtprotoLinkAnchorEl = document.querySelector("#mtproto-link-anchor"); const mtprotoLinkCopyBtn = document.querySelector("#mtproto-link-copy"); const mtprotoLinkCopyState = document.querySelector("#mtproto-link-copy-state"); const themeSelect = document.querySelector("#theme-select"); const cascadePanel = document.querySelector("#cascade-panel"); const usersPanel = document.querySelector("#users-panel"); const wgRawDetails = document.querySelector("#wg-raw-details"); const protoSwitch = document.querySelector("#proto-switch"); const protoSelect = document.querySelector("#proto-select"); const protoLabel = document.querySelector("#proto-label"); const pwForm = document.querySelector("#pw-form"); const pwCurrent = document.querySelector("#pw-current"); const pwNew = document.querySelector("#pw-new"); const pwNew2 = document.querySelector("#pw-new2"); const pwMsg = document.querySelector("#pw-msg"); const profileHintEl = document.querySelector("#profile-hint"); const dtDialog = document.querySelector("#disconnect-dt-dialog"); const dtTitle = document.querySelector("#dt-dialog-title"); const dtClientEl = document.querySelector("#dt-dialog-client"); const dtInput = document.querySelector("#dt-dialog-input"); const dtCancel = document.querySelector("#dt-dialog-cancel"); const dtOk = document.querySelector("#dt-dialog-ok"); const dtExtra = document.querySelector("#dt-dialog-extra"); const dtHint = document.querySelector("#dt-dialog-hint"); const dtScheduleTunnel = document.querySelector("#dt-dialog-schedule-tunnel"); const warpSshDialog = document.querySelector("#warp-ssh-dialog"); const warpSshTitle = document.querySelector("#warp-ssh-title"); const warpSshLead = document.querySelector("#warp-ssh-lead"); const warpSshPw = document.querySelector("#warp-ssh-pw"); const warpSshCancel = document.querySelector("#warp-ssh-cancel"); const warpSshOk = document.querySelector("#warp-ssh-ok"); const warpSshErr = document.querySelector("#warp-ssh-err"); /** @type {"install" | "uninstall" | null} */ let warpSshPendingCmd = null; /** Какие панели скрыты настройкой сервера (`UI_HIDE_SECTIONS`). */ let uiHidden = { users: false, warp: false, cascade: false, mtproto: 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`; } /** Ссылки доната и Telegram‑спонсора: шапка + подвал панели (`GET /api/panel/footer`). */ function wirePanelPromoAnchor(el, url, label) { const u = typeof url === "string" ? url.trim() : ""; const lb = typeof label === "string" ? label.trim() : ""; if (!el || !u || !lb) { if (el) { el.classList.add("hidden"); el.removeAttribute("href"); el.textContent = ""; } return false; } el.href = u; el.textContent = lb; el.classList.remove("hidden"); return true; } async function hydratePanelPromoFooter() { const strip = document.getElementById("panel-promo-strip"); const sub = document.getElementById("panel-promo-subtitle"); const aDonTop = document.getElementById("panel-strip-donate"); const sepTop = document.getElementById("panel-strip-sep"); const aTgTop = document.getElementById("panel-strip-telegram"); const gap1 = document.getElementById("footer-promo-gap1"); const aDonF = document.getElementById("footer-promo-donate"); const sepMid = document.getElementById("footer-promo-sep-mid"); const aTgF = document.getElementById("footer-promo-telegram"); try { const r = await fetch("/api/panel/footer", { credentials: "same-origin" }); if (!r.ok) return; const j = await r.json().catch(() => null); if (!j || typeof j !== "object") return; const subtitle = typeof j.promoSubtitle === "string" ? j.promoSubtitle.trim() : ""; if (sub) sub.textContent = subtitle; const okD = wirePanelPromoAnchor(aDonTop, j.donateUrl, j.donateLabel); const okT = wirePanelPromoAnchor(aTgTop, j.telegramUrl, j.telegramLabel); if (sepTop) sepTop.classList.toggle("hidden", !(okD && okT)); wirePanelPromoAnchor(aDonF, j.donateUrl, j.donateLabel); wirePanelPromoAnchor(aTgF, j.telegramUrl, j.telegramLabel); const footerD = aDonF && !aDonF.classList.contains("hidden"); const footerT = aTgF && !aTgF.classList.contains("hidden"); if (gap1) gap1.classList.toggle("hidden", !(footerD || footerT)); if (sepMid) sepMid.classList.toggle("hidden", !(footerD && footerT)); if (strip && (okD || okT)) strip.classList.remove("hidden"); } catch { /* офлайн или сбой — блок остаётся скрытым */ } } 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", readOnlyClients: false, allowDeleteClients: false, upgradeUrl: null, upgradePitch: null, showDebugWg: true, githubActivationAllowed: false, }; function applyEditionPayload(data) { const ed = data?.edition; if (!ed || typeof ed !== "object") return; editionState = { tier: ed.tier === "community" ? "community" : "pro", readOnlyClients: Boolean(ed.readOnlyClients), allowDeleteClients: Boolean(ed.allowDeleteClients), upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null, upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null, showDebugWg: ed.showDebugWg !== false, githubActivationAllowed: Boolean(ed.githubActivationAllowed), }; const titleEl = document.querySelector(".top h1"); if (titleEl) { titleEl.textContent = editionState.tier === "community" ? "Пользователи AmneziaWG FREE" : "Пользователи AmneziaWG PRO"; } const subEl = document.querySelector(".top .sub"); if (subEl) { if (editionState.tier === "community") { subEl.textContent = "Базовая панель amnezia_web: просмотр клиентов и статусов, удаление клиента с сервера, установка Telegram MTProto‑прокси (Docker) в этом интерфейсе. Включение/выключение туннеля, даты, переименование, экспорт .conf, каскад, Cloudflare WARP и синхронизация времени хоста — в версии PRO."; } else { subEl.textContent = DEFAULT_HEADER_SUB; } } 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"); wrap.className = "edition-banner-inner"; const textCol = document.createElement("div"); textCol.className = "edition-banner-text"; const strong = document.createElement("strong"); strong.textContent = "Базовая версия · просмотр и удаление клиентов"; const pitch = document.createElement("p"); pitch.className = "edition-banner-pitch muted"; pitch.textContent = editionState.upgradePitch || ""; textCol.append(strong, pitch); const cta = document.createElement("a"); cta.className = "btn small primary edition-banner-cta"; cta.rel = "noopener noreferrer"; cta.target = "_blank"; cta.href = editionState.upgradeUrl || "https://boosty.to/andrey27/purchase/3906453?ssource=DIRECT&share=subscription_link"; cta.textContent = "Разблокировать PRO (Boosty)"; wrap.append(textCol, cta); editionBanner.appendChild(wrap); if (editionState.githubActivationAllowed) { const act = document.createElement("div"); act.className = "edition-banner-activation muted"; const cap = document.createElement("div"); cap.className = "edition-banner-act-title"; cap.textContent = "Подписка получена и есть GitHub‑токен к приватному репозиторию? Запуск установки PRO с сервера (порт обычно сохранится; процесс см. журнал):"; const row = document.createElement("div"); row.className = "edition-banner-act-row"; const inp = document.createElement("input"); inp.type = "password"; inp.autocomplete = "new-password"; inp.spellcheck = false; inp.placeholder = "Токен (classic: repo или fine‑grained: Contents Read)"; inp.className = "edition-banner-act-input monospace"; const go = document.createElement("button"); go.type = "button"; go.className = "btn small primary"; go.textContent = "Установить PRO"; const msg = document.createElement("p"); msg.className = "edition-banner-act-msg muted"; msg.setAttribute("role", "status"); msg.textContent = ""; go.addEventListener("click", async () => { msg.textContent = ""; msg.className = "edition-banner-act-msg muted"; const tok = inp.value.trim(); if (!tok) { msg.className = "edition-banner-act-msg status err"; msg.textContent = "Вставьте токен."; return; } go.disabled = true; try { const r = await fetch("/api/community/run-private-install", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ githubToken: tok }), }); const j = await r.json().catch(() => ({})); if (!r.ok) { msg.className = "edition-banner-act-msg status err"; msg.textContent = j.error || `Ошибка ${r.status}`; go.disabled = false; return; } msg.className = "edition-banner-act-msg edition-act-ok muted"; msg.textContent = j.message || "Установка запущена. Через 2–5 минут откройте панель снова по тому же адресу (или несколько раз обновите страницу)."; inp.value = ""; startCommunityInstallLogPollingFresh(); } catch (e) { msg.className = "edition-banner-act-msg status err"; msg.textContent = String(e?.message || e); go.disabled = false; } }); 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 = ""; } } document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients); if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; } async function refreshMtprotoPanel() { if (!mtprotoPanel || !mtprotoStatusLine || !mtprotoActionsEl) return; if (uiHidden.mtproto) { mtprotoPanel.hidden = true; if (mtprotoCalloutEl) { mtprotoCalloutEl.textContent = ""; mtprotoCalloutEl.classList.add("hidden"); } return; } mtprotoPanel.hidden = false; mtprotoActionsEl.innerHTML = ""; if (mtprotoBanner) { mtprotoBanner.classList.add("hidden"); mtprotoBanner.textContent = ""; } try { const snap = await api("/api/mtproto/status"); if (mtprotoLogsTailEl) { mtprotoLogsTailEl.textContent = typeof snap.logsTail === "string" ? snap.logsTail : ""; } if (mtprotoCalloutEl) { if (snap.exists) { const hp = snap.hostPort != null && snap.hostPort !== "" ? String(snap.hostPort) : null; mtprotoCalloutEl.classList.remove("hidden"); mtprotoCalloutEl.textContent = hp ? `Официальный образ слушает 443/tcp внутри контейнера — это норма. На хост проброшен порт ${hp}: в Telegram указывайте публичный IP/DNS этого VPS и порт ${hp}. В логах ниже официальный прокси сам пишет tg:// и port=443 (внутренний процесс); при таком пробросе в клиенте используйте порт ${hp}, а не 443. Ссылку из синего блока панели (если показана) можно открыть как есть.` : `Официальный образ слушает 443/tcp внутри контейнера; снаружи — порт хоста из строки статуса («порт хоста»). Если в логах видно tg:// с port=443, для подключения с интернета подставляйте порт хоста из статуса, не 443.`; } else { mtprotoCalloutEl.textContent = ""; mtprotoCalloutEl.classList.add("hidden"); } } const parts = []; if (snap.exists) parts.push("контейнер есть"); if (snap.running) parts.push("запущен"); mtprotoStatusLine.textContent = parts.length > 0 ? parts.join(" · ") + (snap.hostPort ? ` · порт хоста :${String(snap.hostPort)}` : "") + (snap.secretMasked ? ` · секрет ${snap.secretMasked}` : "") : "не установлен"; if (typeof snap.hint === "string" && snap.hint.trim()) { const hintEl = document.createElement("p"); hintEl.className = "muted warp-muted"; hintEl.textContent = snap.hint.trim(); mtprotoActionsEl.appendChild(hintEl); } else if (snap.exists && !snap.running && typeof snap.image === "string" && snap.image.trim()) { const imgHint = document.createElement("p"); imgHint.className = "muted warp-muted"; imgHint.textContent = `После установки образ будет доступен здесь (${snap.image.trim()} при актуальной конфигурации).`; mtprotoActionsEl.appendChild(imgHint); } if (mtprotoLinkCardEl) { if (snap.tgLink) { mtprotoLinkCardEl.classList.remove("hidden"); mtprotoLinkCardEl.dataset.tgLink = snap.tgLink; if (mtprotoLinkAnchorEl) { mtprotoLinkAnchorEl.href = snap.tgLink; mtprotoLinkAnchorEl.textContent = snap.tgLink; } if (mtprotoLinkCopyState) mtprotoLinkCopyState.textContent = ""; } else { mtprotoLinkCardEl.classList.add("hidden"); delete mtprotoLinkCardEl.dataset.tgLink; } } const row = document.createElement("div"); row.className = "warp-actions"; row.appendChild( btn("Установить / пересоздать", "btn small primary", async () => { try { setStatus("MTProto: docker pull/run…", false); const hp = mtprotoHostPortInput?.value?.trim(); const sec = mtprotoSecretInput?.value?.trim()?.toLowerCase(); const body = {}; if (hp !== "" && hp !== undefined) { const n = Number.parseInt(String(hp), 10); if (Number.isFinite(n)) body.hostPort = n; } if (/^[a-f0-9]{32}$/.test(sec)) body.secret = sec; const j = await api("/api/mtproto/install", { method: "POST", body: JSON.stringify(body) }); const lines = []; if (typeof j.secretHex === "string") lines.push(`Секрет (сохраните): ${j.secretHex}`); if (typeof j.tgLink === "string" && j.tgLink) lines.push(`Ссылка: ${j.tgLink}`); if (mtprotoBanner) { mtprotoBanner.textContent = lines.join("\n"); mtprotoBanner.classList.remove("hidden"); } if (typeof j.secretHex === "string" && mtprotoSecretInput) mtprotoSecretInput.value = ""; await refreshMtprotoPanel(); setStatus("MTProto: готово", false); } catch (e) { setStatus(String(e?.message || e), true); } }), ); row.appendChild( btn("Перезапустить", "btn small ghost", async () => { try { setStatus("MTProto: перезапуск…", false); await api("/api/mtproto/restart", { method: "POST", body: JSON.stringify({}) }); await refreshMtprotoPanel(); setStatus("", false); } catch (e) { setStatus(String(e?.message || e), true); } }), ); row.appendChild( btn("Удалить прокси", "btn small warn", async () => { try { if (!confirm("Удалить контейнер MTProto? Секрет в Telegram сохранять не нужно — он пересоздастся заново при установке.")) return; setStatus("MTProto: удаление…", false); await api("/api/mtproto/remove", { method: "POST", body: JSON.stringify({}) }); if (mtprotoBanner) { mtprotoBanner.classList.add("hidden"); mtprotoBanner.textContent = ""; } await refreshMtprotoPanel(); setStatus("", false); } catch (e) { setStatus(String(e?.message || e), true); } }), ); row.appendChild( btn("Обновить статус", "btn small ghost", async () => { try { setStatus("", false); await refreshMtprotoPanel(); } catch (e) { setStatus(String(e?.message || e), true); } }), ); mtprotoActionsEl.appendChild(row); } catch (e) { if (mtprotoCalloutEl) { mtprotoCalloutEl.textContent = ""; mtprotoCalloutEl.classList.add("hidden"); } if (mtprotoLinkCardEl) { mtprotoLinkCardEl.classList.add("hidden"); delete mtprotoLinkCardEl.dataset.tgLink; } if (mtprotoLogsTailEl) mtprotoLogsTailEl.textContent = ""; const err = document.createElement("p"); err.className = "muted warp-muted err"; err.textContent = String(e.message || e); mtprotoActionsEl.appendChild(err); } } function applyUiHiddenFromPayload(data) { const u = data?.uiHidden; if (u && typeof u === "object") { uiHidden = { users: Boolean(u.users), warp: Boolean(u.warp), cascade: Boolean(u.cascade), mtproto: Boolean(u.mtproto), }; } if (usersPanel) usersPanel.hidden = uiHidden.users; if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg; if (cascadePanel) cascadePanel.hidden = uiHidden.cascade; if (warpPanel) warpPanel.hidden = uiHidden.warp; if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto; void refreshMtprotoPanel(); } let dtMode = "disable"; /** @type {Record | null} */ let dtClient = null; function isoToDatetimeLocal(iso) { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function datetimeLocalToIso(localVal) { if (!localVal || !String(localVal).trim()) { throw new Error("Укажите дату и время"); } const d = new Date(localVal); if (Number.isNaN(d.getTime())) { throw new Error("Некорректная дата"); } return d.toISOString(); } function openDisableDialog(c) { dtMode = "disable"; dtClient = c; dtTitle.textContent = "Выключить клиента"; dtClientEl.textContent = c.name; dtOk.textContent = "Выключить"; dtInput.value = isoToDatetimeLocal(new Date().toISOString()); dtExtra.classList.add("hidden"); dtScheduleTunnel.checked = false; dtDialog.showModal(); } function openEditDisconnectDialog(c) { dtMode = "edit"; dtClient = c; dtTitle.textContent = "Дата последнего отключения"; dtClientEl.textContent = c.name; dtOk.textContent = "Сохранить"; const iso = (c.activeInConf && c.scheduledTunnelDisconnectAt) || c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || new Date().toISOString(); dtInput.value = isoToDatetimeLocal(iso); if (c.activeInConf) { dtExtra.classList.remove("hidden"); dtHint.textContent = "Без галочки — только запись даты в таблице, клиент остаётся в туннеле. С галочкой ключ будет убран из туннеля автоматически в выбранный момент (проверка на сервере каждые ~60 с)."; dtScheduleTunnel.checked = Boolean(c.scheduledTunnelDisconnectAt); } else { dtExtra.classList.add("hidden"); dtScheduleTunnel.checked = false; } dtDialog.showModal(); } dtCancel.addEventListener("click", () => { dtDialog.close(); dtClient = null; }); dtOk.addEventListener("click", async () => { if (!dtClient) return; let iso; try { iso = datetimeLocalToIso(dtInput.value); } catch (e) { setStatus(String(e.message || e), true); return; } try { if (dtMode === "disable") { setStatus("Выполняю…", false); await api("/api/clients/disable", { method: "POST", body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso }), }); } else { const scheduleTunnel = Boolean(dtScheduleTunnel.checked && dtClient.activeInConf); setStatus(scheduleTunnel ? "Сохраняю расписание отключения…" : "Сохраняю дату…", false); await api("/api/clients/disconnect-date", { method: "POST", body: JSON.stringify({ clientId: dtClient.clientId, disconnectedAt: iso, scheduleTunnelDisconnect: scheduleTunnel, }), }); } dtDialog.close(); dtClient = null; setStatus("Готово.", false); await loadClients(); } catch (e) { setStatus(String(e.message || e), true); } }); function openWarpHostSetup(cmd) { if (!warpSshDialog || !warpSshTitle || !warpSshLead || !warpSshPw || !warpSshOk || !warpSshErr) return; warpSshPendingCmd = cmd; warpSshErr.textContent = ""; warpSshPw.value = ""; if (cmd === "install") { warpSshTitle.textContent = "Установить Cloudflare WARP"; warpSshLead.textContent = "На хосте VPS выполнится scripts/warp-amnezia.sh install для контейнера текущего инстанса. SSH так же, как у блока синхронизации времени (TIME_SYNC_SSH_HOST, часто 172.17.0.1). Пароль root не сохраняется."; warpSshOk.textContent = "Установить"; } else { warpSshTitle.textContent = "Удалить Cloudflare WARP"; warpSshLead.textContent = "На хосте выполнится scripts/warp-amnezia.sh uninstall (интерфейс warp, правила, автозапуск в start.sh; контейнер AWG перезапустится)."; warpSshOk.textContent = "Удалить"; } warpSshDialog.showModal(); } if (warpSshCancel && warpSshDialog) { warpSshCancel.addEventListener("click", () => { warpSshDialog.close(); warpSshPendingCmd = null; }); } if (warpSshOk && warpSshDialog && warpSshPw) { warpSshOk.addEventListener("click", async () => { const cmd = warpSshPendingCmd; if (!cmd) return; const pw = warpSshPw.value; if (!String(pw).trim()) { warpSshErr.textContent = "Введите пароль root."; return; } warpSshErr.textContent = ""; try { setStatus(cmd === "install" ? "Устанавливаю WARP на хосте VPS…" : "Удаляю WARP на хосте VPS…", false); await api("/api/warp/host-setup", { method: "POST", body: JSON.stringify({ rootPassword: pw, cmd }), }); warpSshDialog.close(); warpSshPendingCmd = null; warpSshPw.value = ""; setStatus("Готово.", false); await loadClients(); } catch (e) { const msg = String(e.message || e); warpSshErr.textContent = msg; setStatus(msg, true); } }); } function showLogin() { stopClocks(); loginGate.classList.remove("hidden"); loginGate.setAttribute("aria-hidden", "false"); appRoot.classList.add("hidden"); } function showApp() { loginGate.classList.add("hidden"); loginGate.setAttribute("aria-hidden", "true"); appRoot.classList.remove("hidden"); startClocks(); } function setStatus(text, isErr) { statusEl.textContent = text || ""; statusEl.classList.toggle("err", Boolean(isErr)); } function setPwMsg(text, isErr) { pwMsg.textContent = text || ""; pwMsg.classList.toggle("err", Boolean(isErr)); } async function api(path, opts = {}) { const res = await fetch(path, { ...opts, credentials: "same-origin", headers: { "Content-Type": "application/json", ...opts.headers, }, }); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } if (!res.ok) { const msg = data.error || data.raw || res.statusText; throw new Error(msg); } return data; } async function checkSession() { try { await api("/api/session"); return true; } catch { return false; } } async function loadProtocols() { try { const data = await api("/api/protocols"); applyEditionPayload(data); applyUiHiddenFromPayload(data); protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`; if (profileHintEl) { if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) { profileHintEl.textContent = data.profilesPersistHint; profileHintEl.classList.remove("hidden"); } else { profileHintEl.textContent = ""; profileHintEl.classList.add("hidden"); } } if (!data.profiles || data.profiles.length < 2) { protoSwitch.classList.add("hidden"); return; } protoSwitch.classList.remove("hidden"); protoSelect.innerHTML = ""; for (const p of data.profiles) { const opt = document.createElement("option"); opt.value = p.id; opt.textContent = `${p.label} (${p.container})`; if (p.id === data.currentId) opt.selected = true; protoSelect.appendChild(opt); } } catch { protoSwitch.classList.add("hidden"); } } loginForm.addEventListener("submit", async (ev) => { ev.preventDefault(); loginError.textContent = ""; try { await api("/api/login", { method: "POST", body: JSON.stringify({ password: loginPassword.value }), }); loginPassword.value = ""; showApp(); await loadProtocols(); await loadTimeSyncCaps(); await loadClients(); } catch (e) { loginError.textContent = String(e.message || e); } }); logoutBtn.addEventListener("click", async () => { try { await api("/api/logout", { method: "POST", body: JSON.stringify({}) }); } catch { /* ignore */ } showLogin(); loginPassword.focus(); }); refreshBtn.addEventListener("click", () => { loadClients(); }); const cascadeForm = document.querySelector("#cascade-form"); if (cascadeForm) { cascadeForm.addEventListener("submit", (ev) => void downloadCascadeConf(ev)); } protoSelect.addEventListener("change", async () => { try { setStatus("Смена инстанса…", false); await api("/api/protocol", { method: "POST", body: JSON.stringify({ profileId: protoSelect.value }), }); await loadProtocols(); await loadClients(); setStatus("", false); } catch (e) { setStatus(String(e.message || e), true); await loadProtocols(); } }); clockSyncBtn.addEventListener("click", () => { void refreshServerClock(); }); const clockFmt = new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "medium", }); /** Часовой пояс строки «Сервер» (IANA), как в /api/server-time */ let serverDisplayTz = "UTC"; /** @type {Intl.DateTimeFormat | null} */ let serverTzFmtCached = null; function buildServerTzFmt(tz) { try { return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "medium", timeZone: tz, }); } catch { return null; } } /** @type {ReturnType | null} */ let clockTickId = null; /** @type {ReturnType | null} */ let clockServerPollId = null; /** Метка UTC сервера (мс) по последнему ответу API */ let serverAnchorUtcMs = /** @type {number | null} */ (null); /** Date.now() в момент установки якоря */ let serverAnchorWallMs = 0; function browserTimeZoneLabel() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch { return ""; } } function tickServerClockDisplay() { if (serverAnchorUtcMs === null) { clockServerEl.dateTime = ""; clockServerEl.textContent = "—"; return; } const estimatedUtcMs = serverAnchorUtcMs + (Date.now() - serverAnchorWallMs); const d = new Date(estimatedUtcMs); clockServerEl.dateTime = d.toISOString(); const fmt = serverTzFmtCached || clockFmt; clockServerEl.textContent = `${fmt.format(d)} · ${serverDisplayTz}`; } async function refreshServerClock() { try { const tz = browserTimeZoneLabel(); const q = tz ? `?browserTz=${encodeURIComponent(tz)}` : ""; const t = await api(`/api/server-time${q}`); const iso = typeof t.iso === "string" ? t.iso : ""; const parsed = new Date(iso).getTime(); if (!iso || Number.isNaN(parsed)) { throw new Error("нет времени"); } serverAnchorUtcMs = parsed; serverAnchorWallMs = Date.now(); serverDisplayTz = typeof t.timeZone === "string" && t.timeZone.trim() ? t.timeZone.trim() : "UTC"; serverTzFmtCached = buildServerTzFmt(serverDisplayTz); tickServerClockDisplay(); if (clockZoneDiffEl) { const hint = typeof t.zoneCompareHint === "string" ? t.zoneCompareHint.trim() : ""; if (hint) { clockZoneDiffEl.textContent = hint; clockZoneDiffEl.classList.remove("hidden"); clockZoneDiffEl.classList.toggle("clock-zone-diff--accent", t.zoneSame === false); } else { clockZoneDiffEl.textContent = ""; clockZoneDiffEl.classList.add("hidden"); clockZoneDiffEl.classList.remove("clock-zone-diff--accent"); } } } catch { serverAnchorUtcMs = null; serverTzFmtCached = null; clockServerEl.dateTime = ""; clockServerEl.textContent = "—"; if (clockZoneDiffEl) { clockZoneDiffEl.textContent = ""; clockZoneDiffEl.classList.add("hidden"); clockZoneDiffEl.classList.remove("clock-zone-diff--accent"); } } } function tickLocalClock() { const n = new Date(); clockLocalEl.dateTime = n.toISOString(); const tz = browserTimeZoneLabel(); clockLocalEl.textContent = tz ? `${clockFmt.format(n)} · ${tz}` : clockFmt.format(n); } function tickClocks() { tickLocalClock(); tickServerClockDisplay(); } function stopClocks() { if (clockTickId !== null) { clearInterval(clockTickId); clockTickId = null; } if (clockServerPollId !== null) { clearInterval(clockServerPollId); clockServerPollId = null; } serverAnchorUtcMs = null; serverAnchorWallMs = 0; serverTzFmtCached = null; serverDisplayTz = "UTC"; clockServerEl.dateTime = ""; clockLocalEl.dateTime = ""; clockServerEl.textContent = "—"; clockLocalEl.textContent = "—"; if (clockZoneDiffEl) { clockZoneDiffEl.textContent = ""; clockZoneDiffEl.classList.add("hidden"); clockZoneDiffEl.classList.remove("clock-zone-diff--accent"); } } function startClocks() { stopClocks(); tickClocks(); void refreshServerClock(); clockTickId = setInterval(tickClocks, 1000); clockServerPollId = setInterval(() => void refreshServerClock(), 30_000); } async function loadTimeSyncCaps() { const hint = document.querySelector("#sync-host-hint"); const btn = document.querySelector("#sync-host-time"); try { const c = await api("/api/time-sync-capabilities"); if (editionState.readOnlyClients || c.communityBlocked) { if (hint) { hint.textContent = "В базовой версии синхронизация времени хоста по SSH недоступна — это функция PRO."; } if (btn) btn.disabled = true; return; } if (hint) { hint.textContent = c.hostTimeSync ? `Записывается UTC-момент с этого устройства на хост по SSH (root@${c.sshHost}). Пояс строки «Сервер»: ${c.serverClockTimeZone}. Пароль не сохраняется.` : `Авто-синхронизация по SSH недоступна (или TIME_SYNC_DISABLED). Пояс «Сервер»: ${c.serverClockTimeZone}. Задайте TZ контейнера панели при необходимости — см. README.`; } if (btn) btn.disabled = !c.hostTimeSync; } catch { if (hint) hint.textContent = ""; if (btn) btn.disabled = true; } } document.querySelector("#sync-host-time")?.addEventListener("click", async () => { const inp = document.querySelector("#sync-root-pw"); const pw = inp && typeof inp.value === "string" ? inp.value : ""; if (!pw.trim()) { setStatus("Введите пароль root на хосте.", true); return; } try { setStatus("Беру время с этого устройства и отправляю на хост…", false); await api("/api/sync-host-time", { method: "POST", body: JSON.stringify({ rootPassword: pw, unixMs: Date.now() }), }); inp.value = ""; setStatus("Готово: часы хоста выставлены по вашему устройству (UTC). Проверьте строки времени.", false); void refreshServerClock(); } catch (e) { setStatus(String(e.message || e), true); } }); const dtRu = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short", }); function formatLastDisconnect(c) { if (c.scheduledTunnelDisconnectAt && c.activeInConf) { const d = new Date(String(c.scheduledTunnelDisconnectAt)); if (!Number.isNaN(d.getTime())) { return `${dtRu.format(d)} · авто`; } } const iso = c.lastDisconnectedAt || (!c.activeInConf && c.disabledAt) || null; if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; return dtRu.format(d); } pwForm.addEventListener("submit", async (ev) => { ev.preventDefault(); setPwMsg("", false); if (pwNew.value !== pwNew2.value) { setPwMsg("Новый пароль и повтор не совпадают.", true); return; } try { const data = await api("/api/change-password", { method: "POST", body: JSON.stringify({ currentPassword: pwCurrent.value, newPassword: pwNew.value, }), }); pwCurrent.value = ""; pwNew.value = ""; pwNew2.value = ""; setPwMsg(data.message || "Готово.", false); showLogin(); loginPassword.focus(); } catch (e) { setPwMsg(String(e.message || e), true); } }); function renderRows(clients) { rowsEl.innerHTML = ""; if (uiHidden.users) return; clients.forEach((c) => { const tr = document.createElement("tr"); const nameTd = document.createElement("td"); const nameWrap = document.createElement("div"); nameWrap.className = "name-cell"; const strong = document.createElement("strong"); strong.textContent = c.name; if (!editionState.readOnlyClients) { const renameWrap = document.createElement("div"); renameWrap.className = "rename-inline"; renameWrap.appendChild( btn("Переименовать", "btn small ghost", () => void renameClient(c)) ); nameWrap.append(strong, renameWrap); if (!c.exportAvailable) { const hintFold = document.createElement("details"); hintFold.className = "hint-mini"; const sum = document.createElement("summary"); sum.textContent = "Нет готового .conf на сервере (last_config)"; hintFold.appendChild(sum); const exHint = document.createElement("p"); exHint.className = "muted export-missing-hint"; exHint.textContent = "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia."; hintFold.appendChild(exHint); nameWrap.appendChild(hintFold); } } else { nameWrap.appendChild(strong); const roHint = document.createElement("p"); roHint.className = "muted hint-mini"; roHint.style.margin = "0.35rem 0 0"; roHint.textContent = c.exportAvailable ? "На сервере есть данные для .conf — скачивание доступно в PRO." : "Нет last_config на сервере — полный конфиг в приложении Amnezia."; nameWrap.appendChild(roHint); } nameTd.appendChild(nameWrap); const ipTd = document.createElement("td"); ipTd.innerHTML = `${escapeHtml(c.allowedIps || "—")}`; const stTd = document.createElement("td"); const badge = document.createElement("span"); badge.className = `badge ${c.activeInConf ? "on" : "off"}`; if (c.activeInConf && c.warpEnabled) { badge.textContent = "В туннеле · WARP"; } else { badge.textContent = c.activeInConf ? "В туннеле" : "Выключен"; } stTd.appendChild(badge); const offTd = document.createElement("td"); offTd.className = "date-cell"; const dateLine = document.createElement("div"); dateLine.textContent = formatLastDisconnect(c); offTd.appendChild(dateLine); if (!editionState.readOnlyClients) { const dtWrap = document.createElement("div"); dtWrap.className = "rename-inline"; dtWrap.appendChild( btn("Задать дату", "btn small ghost", () => openEditDisconnectDialog(c)) ); offTd.appendChild(dtWrap); } const actTd = document.createElement("td"); actTd.className = "actions"; if (editionState.readOnlyClients) { if (editionState.allowDeleteClients) { const note = document.createElement("div"); note.className = "muted"; note.style.fontSize = "0.85rem"; note.style.marginBottom = "0.35rem"; note.textContent = "Вкл/выкл, даты, переименование, экспорт — PRO"; actTd.appendChild(note); actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId))); } else { const lock = document.createElement("span"); lock.className = "muted"; lock.textContent = "Только PRO"; actTd.appendChild(lock); } } else { if (c.activeInConf) { actTd.appendChild(btn("Выключить", "btn small ghost", () => openDisableDialog(c))); } else { actTd.appendChild( btn("Включить", "btn small primary", () => mutate("/api/clients/enable", c.clientId)) ); } if (c.exportAvailable) { const direct = document.createElement("a"); direct.className = "btn small ghost"; direct.href = clientExportGetUrl(c.clientId); direct.textContent = "Прямая ссылка"; direct.rel = "noopener"; direct.title = "Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies)."; actTd.appendChild(btn("Скачать .conf", "btn small ghost", () => void downloadClientConfig(c))); actTd.appendChild(direct); actTd.appendChild( btn("Копировать URL", "btn small ghost", async () => { const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId)); setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok); }), ); } actTd.appendChild(btn("Удалить", "btn small warn", () => confirmDelete(c.name, c.clientId))); } tr.append(nameTd, ipTd, stTd, offTd, actTd); rowsEl.appendChild(tr); }); } function btn(label, cls, onClick) { const b = document.createElement("button"); b.type = "button"; b.className = cls; b.textContent = label; b.addEventListener("click", onClick); return b; } /** Для политики WARP на сервере нужны IPv4 вида 10.8.x.x/32 */ function parseIpv4Cidrs(allowedIps) { if (!allowedIps) return []; return String(allowedIps) .split(",") .map((x) => x.trim()) .filter((x) => /^(\d{1,3}\.){3}\d{1,3}\/\d{1,3}$/.test(x)); } /** @param {{ warp?: Record; clients: Record[] }} data */ function renderWarpPanel(data) { if (!warpPanel || !warpStatusLine || !warpActionsEl || !warpClientListEl || !warpWgShowEl) return; if (uiHidden.warp) { warpPanel.hidden = true; return; } const w = data.warp; if (!w || w.supported === false) { warpPanel.hidden = true; return; } warpPanel.hidden = false; warpActionsEl.innerHTML = ""; warpClientListEl.innerHTML = ""; warpWgShowEl.textContent = typeof w.wgShowWarp === "string" ? w.wgShowWarp : ""; if (!w.installed) { warpStatusLine.textContent = "Не установлен"; const explain = document.createElement("p"); explain.className = "muted warp-muted"; explain.textContent = "Так и должно быть, пока на VPS не создан файл warp.conf внутри контейнера AWG. После установки статус сменится; без WARP обычный AmneziaWG уже работает."; warpActionsEl.appendChild(explain); if (w.hostSshInstall) { warpActionsEl.appendChild( btn("Установить WARP на VPS", "btn small primary", () => openWarpHostSetup("install")), ); const sshHint = document.createElement("p"); sshHint.className = "muted warp-muted"; sshHint.textContent = "Кнопка запускает на хосте тот же скрипт, что в README; понадобится пароль root по SSH (не сохраняется). Альтернатива — команда вручную по SSH."; warpActionsEl.appendChild(sshHint); } const hint = document.createElement("p"); hint.className = "muted warp-muted"; hint.innerHTML = "Вручную на хосте (root), из каталога репозитория: bash scripts/warp-amnezia.sh install или с именем контейнера: bash scripts/warp-amnezia.sh install amnezia-awg2."; warpActionsEl.appendChild(hint); if (!w.hostSshInstall) { const noBtn = document.createElement("p"); noBtn.className = "muted warp-muted"; noBtn.textContent = "Кнопка установки с панели недоступна: нет sshpass в образе панели или включено TIME_SYNC_DISABLED=1 — используйте SSH вручную."; warpActionsEl.appendChild(noBtn); } const skip = document.createElement("p"); skip.className = "muted warp-muted"; skip.textContent = "Если выход через Cloudflare не нужен, ничего не нажимайте — VPN уже работает без этого блока."; warpActionsEl.appendChild(skip); return; } const parts = []; parts.push(w.running ? "Интерфейс warp поднят" : "Интерфейс warp опущен"); if (w.exitIp) parts.push(`выход ${w.exitIp}`); warpStatusLine.textContent = parts.join(" · "); const selection = new Set((w.selectedAllowedIps || []).map(String)); function redrawChecks() { warpClientListEl.innerHTML = ""; const frag = document.createDocumentFragment(); let any = false; for (const c of data.clients) { if (!c.activeInConf) continue; const ips = parseIpv4Cidrs(c.allowedIps); if (!ips.length) continue; any = true; const ip = ips[0]; const label = document.createElement("label"); label.className = "warp-check-row"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = selection.has(ip); cb.addEventListener("change", () => { if (cb.checked) selection.add(ip); else selection.delete(ip); }); const span = document.createElement("span"); span.textContent = `${c.name} · ${ip}`; label.append(cb, span); frag.appendChild(label); } warpClientListEl.appendChild(frag); if (!any) { const p = document.createElement("p"); p.className = "muted warp-muted"; p.textContent = "Нет активных клиентов с IPv4 AllowedIPs (/32) — WARP-политика в вебе работает только для таких адресов."; warpClientListEl.appendChild(p); } } redrawChecks(); warpActionsEl.appendChild( btn("Поднять WARP", "btn small primary", async () => { try { setStatus("Поднимаю WARP…", false); await api("/api/warp/start", { method: "POST", body: JSON.stringify({}) }); setStatus("Готово.", false); await loadClients(); } catch (e) { setStatus(String(e.message || e), true); } }), ); warpActionsEl.appendChild( btn("Остановить WARP", "btn small ghost", async () => { try { setStatus("Останавливаю WARP…", false); await api("/api/warp/stop", { method: "POST", body: JSON.stringify({}) }); setStatus("Готово.", false); await loadClients(); } catch (e) { setStatus(String(e.message || e), true); } }), ); warpActionsEl.appendChild( btn("Все в WARP", "btn small ghost", () => { for (const c of data.clients) { if (!c.activeInConf) continue; parseIpv4Cidrs(c.allowedIps).forEach((ip) => selection.add(ip)); } redrawChecks(); }), ); warpActionsEl.appendChild( btn("Никого", "btn small ghost", () => { selection.clear(); redrawChecks(); }), ); warpActionsEl.appendChild( btn("Применить маршрутизацию", "btn small primary", async () => { try { setStatus("Сохраняю WARP и перезапускаю контейнер AWG…", false); await api("/api/warp/routing", { method: "POST", body: JSON.stringify({ selectedAllowedIps: [...selection] }), }); setStatus("Готово.", false); await loadClients(); } catch (e) { setStatus(String(e.message || e), true); } }), ); warpActionsEl.appendChild( btn("Удалить WARP с VPS", "btn small warn", () => openWarpHostSetup("uninstall")), ); } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function currentProfileIdValue() { if (!protoSelect || !protoSwitch || protoSwitch.classList.contains("hidden")) return ""; return String(protoSelect.value || "").trim(); } /** Query для нужного инстанса при нескольких профилях AWG_PROFILES */ function currentProfileQuerySuffix() { const pid = currentProfileIdValue(); return pid ? `&profileId=${encodeURIComponent(pid)}` : ""; } /** Прямая GET-ссылка на скачивание (работает в браузере с активной сессией панели). */ function clientExportGetUrl(clientId) { const q = `clientId=${encodeURIComponent(clientId)}${currentProfileQuerySuffix()}`; return `${window.location.origin}/api/clients/export-config?${q}`; } async function copyTextToClipboard(text) { try { await navigator.clipboard.writeText(text); return true; } catch { try { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); return true; } catch { return false; } } } async function downloadClientConfig(c) { try { setStatus("Готовлю конфиг…", false); const res = await fetch(clientExportGetUrl(c.clientId), { method: "GET", credentials: "same-origin", }); const text = await res.text(); if (!res.ok) { let msg = text; try { const j = JSON.parse(text); msg = typeof j.error === "string" ? j.error : msg; } catch { /* сырой текст */ } throw new Error(msg); } const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const safe = String(c.name || "client") .replace(/[^\w\u0400-\u04FF\-]+/g, "_") .slice(0, 60); a.href = url; a.download = `amnezia-${safe}.conf`; a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setStatus("Конфиг скачан.", false); } catch (e) { setStatus(String(e.message || e), true); } } async function downloadCascadeConf(ev) { ev.preventDefault(); const endpointEl = document.querySelector("#cascade-endpoint"); const portEl = document.querySelector("#cascade-port"); const tunnelEl = document.querySelector("#cascade-tunnel-ip"); const nameEl = document.querySelector("#cascade-name"); const endpointHost = endpointEl?.value.trim() || ""; if (!endpointHost) { setStatus("Укажите Endpoint (IP или DNS для клиента в каскаде).", true); return; } const body = { endpointHost }; const praw = portEl?.value.trim() ?? ""; if (praw) { const n = Number(praw); if (!Number.isFinite(n) || n < 1 || n > 65535) { setStatus("Некорректный порт Endpoint (1–65535).", true); return; } body.endpointPort = n; } const tip = tunnelEl?.value.trim(); if (tip) body.tunnelIp = tip; const nm = nameEl?.value.trim(); if (nm) body.clientName = nm; const pid = currentProfileIdValue(); if (pid) body.profileId = pid; try { setStatus("Создаю клиента на сервере и собираю .conf…", false); const res = await fetch("/api/clients/create-cascade", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const text = await res.text(); if (!res.ok) { let msg = text; try { const j = JSON.parse(text); msg = typeof j.error === "string" ? j.error : msg; } catch { /* raw */ } throw new Error(msg); } const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const safe = (nm || "cascade") .replace(/[^\w\u0400-\u04FF\-]+/g, "_") .slice(0, 60); a.href = url; a.download = `amnezia-cascade-${safe}.conf`; a.rel = "noopener"; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setStatus("Клиент добавлен на сервер, .conf скачан. Обновите таблицу.", false); await loadClients(); } catch (e) { setStatus(String(e.message || e), true); } } async function renameClient(c) { const next = prompt(`Новое имя для «${c.name}»:`, c.name); if (next === null) return; const trimmed = next.trim().replace(/\s+/g, " "); if (!trimmed) { setStatus("Имя не может быть пустым", true); return; } try { setStatus("Сохраняю имя…", false); await api("/api/clients/rename", { method: "POST", body: JSON.stringify({ clientId: c.clientId, name: trimmed }), }); setStatus("Готово.", false); await loadClients(); } catch (e) { setStatus(String(e.message || e), true); } } async function mutate(path, clientId) { try { setStatus("Выполняю…", false); await api(path, { method: "POST", body: JSON.stringify({ clientId }) }); setStatus("Готово.", false); await loadClients(); } catch (e) { setStatus(String(e.message || e), true); } } async function confirmDelete(name, clientId) { const ok = confirm( `Удалить клиента «${name}»? Конфиг из приложения Amnezia перестанет совпадать с сервером.` ); if (!ok) return; await mutate("/api/clients/delete", clientId); } async function loadClients() { try { setStatus("Загрузка…", false); const data = await api("/api/clients"); applyEditionPayload(data); applyUiHiddenFromPayload(data); const pref = data.profileLabel ? `${data.profileLabel} · ` : ""; if (uiHidden.users) { peerCountEl.textContent = ""; wgShowEl.textContent = ""; } else { peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`; wgShowEl.textContent = data.wgShow || ""; } renderWarpPanel(data); renderRows(data.clients); setStatus("", false); void refreshServerClock(); } catch (e) { const msg = String(e.message || e); if (msg.includes("Unauthorized")) { showLogin(); setStatus("", false); loginError.textContent = "Сессия истекла — войдите снова."; return; } setStatus(msg, true); rowsEl.innerHTML = ""; wgShowEl.textContent = ""; peerCountEl.textContent = ""; if (warpPanel) warpPanel.hidden = true; if (mtprotoPanel) mtprotoPanel.hidden = true; } } async function boot() { const ok = await checkSession(); if (ok) { showApp(); await loadProtocols(); await loadTimeSyncCaps(); await loadClients(); } else { showLogin(); loginPassword.focus(); } } function applyThemePref(pref) { const root = document.documentElement; const p = pref === "light" || pref === "dark" ? pref : "auto"; const resolved = p === "auto" ? window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light" : p; root.setAttribute("data-theme-pref", p); root.setAttribute("data-theme", resolved); try { localStorage.setItem("amnezia.theme", p); } catch { /* приватный режим — не критично */ } } function initThemeSwitch() { let saved = "auto"; try { const v = localStorage.getItem("amnezia.theme"); if (v === "light" || v === "dark" || v === "auto") saved = v; } catch { /* приватный режим — не критично */ } applyThemePref(saved); if (themeSelect) { themeSelect.value = saved; themeSelect.addEventListener("change", () => applyThemePref(themeSelect.value)); } const mql = window.matchMedia?.("(prefers-color-scheme: dark)"); if (mql) { const handler = () => { const pref = document.documentElement.getAttribute("data-theme-pref") || "auto"; if (pref === "auto") applyThemePref("auto"); }; if (typeof mql.addEventListener === "function") mql.addEventListener("change", handler); else if (typeof mql.addListener === "function") mql.addListener(handler); } } function initMtprotoLinkCopy() { if (!mtprotoLinkCopyBtn) return; mtprotoLinkCopyBtn.addEventListener("click", async () => { const link = mtprotoLinkCardEl?.dataset?.tgLink || mtprotoLinkAnchorEl?.href || ""; if (!link) return; try { if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(link); else { const ta = document.createElement("textarea"); ta.value = link; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); } if (mtprotoLinkCopyState) { mtprotoLinkCopyState.textContent = "Скопировано"; setTimeout(() => { if (mtprotoLinkCopyState) mtprotoLinkCopyState.textContent = ""; }, 2000); } } catch (e) { if (mtprotoLinkCopyState) { mtprotoLinkCopyState.textContent = "Не удалось скопировать — выделите вручную."; } } }); } initThemeSwitch(); initMtprotoLinkCopy(); void hydratePanelPromoFooter(); boot();