mirror of
https://github.com/andrey271192/amnezia_web-PRO.git
synced 2026-11-04 00:43:15 +00:00
v1.4.0: Xray VLESS-Reality, fully open panel (no community edition)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
310
public/app.js
310
public/app.js
@@ -74,77 +74,6 @@ 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() || "";
|
||||
|
||||
/** Состояние редакции панели (community = только просмотр клиентов). */
|
||||
let editionState = {
|
||||
tier: "pro",
|
||||
readOnlyClients: false,
|
||||
upgradeUrl: null,
|
||||
upgradePitch: null,
|
||||
showDebugWg: true,
|
||||
};
|
||||
|
||||
function applyEditionPayload(data) {
|
||||
const ed = data?.edition;
|
||||
if (!ed || typeof ed !== "object") return;
|
||||
editionState = {
|
||||
tier: ed.tier === "community" ? "community" : "pro",
|
||||
readOnlyClients: Boolean(ed.readOnlyClients),
|
||||
upgradeUrl: typeof ed.upgradeUrl === "string" ? ed.upgradeUrl : null,
|
||||
upgradePitch: typeof ed.upgradePitch === "string" ? ed.upgradePitch : null,
|
||||
showDebugWg: ed.showDebugWg !== false,
|
||||
};
|
||||
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: только просмотр клиентов AmneziaWG и статусов. Управление туннелем, экспорт .conf, каскад, Cloudflare WARP, MTProto‑прокси Telegram и синхронизация времени хоста — в версии PRO.";
|
||||
} else {
|
||||
subEl.textContent = DEFAULT_HEADER_SUB;
|
||||
}
|
||||
}
|
||||
if (editionBanner) {
|
||||
if (editionState.tier === "community") {
|
||||
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://github.com/andrey271192/amnezia_web-PRO";
|
||||
cta.textContent = "Открыть PRO на GitHub";
|
||||
wrap.append(textCol, cta);
|
||||
editionBanner.appendChild(wrap);
|
||||
} else {
|
||||
editionBanner.classList.add("hidden");
|
||||
editionBanner.innerHTML = "";
|
||||
}
|
||||
}
|
||||
document.querySelector(".clock-host-sync")?.classList.toggle("hidden", editionState.readOnlyClients);
|
||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||
}
|
||||
|
||||
/** @param {string} [tgLink] */
|
||||
function setMtprotoLinkCard(tgLink) {
|
||||
const t = typeof tgLink === "string" ? tgLink.trim() : "";
|
||||
@@ -350,7 +279,7 @@ function applyUiHiddenFromPayload(data) {
|
||||
};
|
||||
}
|
||||
if (usersPanel) usersPanel.hidden = uiHidden.users;
|
||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users || !editionState.showDebugWg;
|
||||
if (wgRawDetails) wgRawDetails.hidden = uiHidden.users;
|
||||
if (cascadePanel) cascadePanel.hidden = uiHidden.cascade;
|
||||
if (warpPanel) warpPanel.hidden = uiHidden.warp;
|
||||
if (mtprotoPanel) mtprotoPanel.hidden = uiHidden.mtproto;
|
||||
@@ -586,7 +515,6 @@ async function checkSession() {
|
||||
async function loadProtocols() {
|
||||
try {
|
||||
const data = await api("/api/protocols");
|
||||
applyEditionPayload(data);
|
||||
protoLabel.textContent = `Протокол: ${data.currentLabel || "AmneziaWG"}`;
|
||||
if (profileHintEl) {
|
||||
if (data.singleProfile && typeof data.profilesPersistHint === "string" && data.profilesPersistHint) {
|
||||
@@ -630,6 +558,7 @@ loginForm.addEventListener("submit", async (ev) => {
|
||||
await loadClients();
|
||||
await loadInstances();
|
||||
await loadSettings();
|
||||
await loadXray();
|
||||
} catch (e) {
|
||||
loginError.textContent = String(e.message || e);
|
||||
}
|
||||
@@ -649,6 +578,7 @@ refreshBtn.addEventListener("click", () => {
|
||||
loadClients();
|
||||
loadInstances();
|
||||
loadSettings();
|
||||
loadXray();
|
||||
});
|
||||
|
||||
const cascadeForm = document.querySelector("#cascade-form");
|
||||
@@ -838,14 +768,6 @@ async function loadTimeSyncCaps() {
|
||||
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}. Пароль не сохраняется.`
|
||||
@@ -938,38 +860,27 @@ function renderRows(clients) {
|
||||
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 = c.missingClientTable
|
||||
? "Peer найден в конфиге, но нет записи clientsTable"
|
||||
: "Нет готового .conf на сервере (last_config)";
|
||||
hintFold.appendChild(sum);
|
||||
const exHint = document.createElement("p");
|
||||
exHint.className = "muted export-missing-hint";
|
||||
exHint.textContent = c.missingClientTable
|
||||
? "Клиент активен в awg0.conf, но веб не знает имя и last_config. Вставьте его .conf в блок «Импорт клиента», чтобы восстановить запись и экспорт."
|
||||
: "Конфиг с сервера недоступен (нет 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);
|
||||
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 = c.missingClientTable
|
||||
? "Peer найден в конфиге, но нет записи clientsTable"
|
||||
: "Нет готового .conf на сервере (last_config)";
|
||||
hintFold.appendChild(sum);
|
||||
const exHint = document.createElement("p");
|
||||
exHint.className = "muted export-missing-hint";
|
||||
exHint.textContent = c.missingClientTable
|
||||
? "Клиент активен в awg0.conf, но веб не знает имя и last_config. Вставьте его .conf в блок «Импорт клиента», чтобы восстановить запись и экспорт."
|
||||
: "Конфиг с сервера недоступен (нет last_config). Создайте клиента с нужным Endpoint в блоке «Новый клиент под каскад» ниже или возьмите ключ из приложения Amnezia.";
|
||||
hintFold.appendChild(exHint);
|
||||
nameWrap.appendChild(hintFold);
|
||||
}
|
||||
nameTd.appendChild(nameWrap);
|
||||
|
||||
@@ -991,51 +902,42 @@ function renderRows(clients) {
|
||||
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 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) {
|
||||
const lock = document.createElement("span");
|
||||
lock.className = "muted";
|
||||
lock.textContent = "Только PRO";
|
||||
actTd.appendChild(lock);
|
||||
if (c.activeInConf) {
|
||||
actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c)));
|
||||
} else {
|
||||
if (c.activeInConf) {
|
||||
actTd.appendChild(iconBtn("⏻", "Выключить", "btn small ghost icon", () => openDisableDialog(c)));
|
||||
} else {
|
||||
actTd.appendChild(
|
||||
iconBtn("▶", "Включить", "btn small primary icon", () => 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.classList.add("icon");
|
||||
direct.rel = "noopener";
|
||||
direct.title =
|
||||
"Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies).";
|
||||
|
||||
actTd.appendChild(iconBtn("⬇", "Скачать .conf", "btn small ghost icon", () => void downloadClientConfig(c)));
|
||||
actTd.appendChild(direct);
|
||||
actTd.appendChild(
|
||||
iconBtn("⧉", "Копировать URL", "btn small ghost icon", async () => {
|
||||
const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId));
|
||||
setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok);
|
||||
}),
|
||||
);
|
||||
}
|
||||
actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId)));
|
||||
actTd.appendChild(
|
||||
iconBtn("▶", "Включить", "btn small primary icon", () => 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.classList.add("icon");
|
||||
direct.rel = "noopener";
|
||||
direct.title =
|
||||
"Открыть в новой вкладке — скачается .conf, если вы авторизованы в этой панели (cookies).";
|
||||
|
||||
actTd.appendChild(iconBtn("⬇", "Скачать .conf", "btn small ghost icon", () => void downloadClientConfig(c)));
|
||||
actTd.appendChild(direct);
|
||||
actTd.appendChild(
|
||||
iconBtn("⧉", "Копировать URL", "btn small ghost icon", async () => {
|
||||
const ok = await copyTextToClipboard(clientExportGetUrl(c.clientId));
|
||||
setStatus(ok ? "Ссылка скопирована (вставьте в браузер, будучи залогиненным)." : "Не удалось скопировать.", !ok);
|
||||
}),
|
||||
);
|
||||
}
|
||||
actTd.appendChild(iconBtn("🗑", "Удалить", "btn small warn icon", () => confirmDelete(c.name, c.clientId)));
|
||||
|
||||
tr.append(nameTd, ipTd, stTd, offTd, actTd);
|
||||
rowsEl.appendChild(tr);
|
||||
@@ -1651,7 +1553,6 @@ async function loadClients() {
|
||||
try {
|
||||
setStatus("Загрузка…", false);
|
||||
const data = await api(`/api/clients${currentProfileQueryString()}`);
|
||||
applyEditionPayload(data);
|
||||
applyUiHiddenFromPayload(data);
|
||||
const pref = data.profileLabel ? `${data.profileLabel} · ` : "";
|
||||
if (uiHidden.users) {
|
||||
@@ -1661,10 +1562,7 @@ async function loadClients() {
|
||||
} else {
|
||||
if (syncPeersBtn) {
|
||||
syncPeersBtn.hidden = false;
|
||||
syncPeersBtn.disabled = editionState.readOnlyClients;
|
||||
syncPeersBtn.title = editionState.readOnlyClients
|
||||
? "Доступно в PRO"
|
||||
: "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
|
||||
syncPeersBtn.title = "Создать недостающие строки clientsTable из активных [Peer] текущего инстанса";
|
||||
}
|
||||
peerCountEl.textContent = `${pref}${data.clients.length} в таблице · ${data.peerCount} peer`;
|
||||
wgShowEl.textContent = data.wgShow || "";
|
||||
@@ -1733,6 +1631,7 @@ async function boot() {
|
||||
await loadClients();
|
||||
await loadInstances();
|
||||
await loadSettings();
|
||||
await loadXray();
|
||||
} else {
|
||||
showLogin();
|
||||
loginPassword.focus();
|
||||
@@ -1788,3 +1687,100 @@ document.querySelector("#upstream-form")?.addEventListener("submit", async (ev)
|
||||
if (b) b.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadXray() {
|
||||
const line = document.querySelector("#xray-status-line");
|
||||
try {
|
||||
const x = await api("/api/xray/status");
|
||||
document.querySelector("#xray-install-form").style.display = x.installed ? "none" : "";
|
||||
document.querySelector("#xray-manage").style.display = x.installed ? "" : "none";
|
||||
if (line) {
|
||||
line.textContent = !x.installed
|
||||
? "не установлен"
|
||||
: x.running
|
||||
? `порт ${x.port} · ${x.sni} · клиентов: ${x.clients.length}`
|
||||
: "контейнер остановлен";
|
||||
}
|
||||
if (x.error) setStatus(x.error, true);
|
||||
const rows = document.querySelector("#xray-rows");
|
||||
rows.innerHTML = "";
|
||||
for (const c of x.clients) {
|
||||
const tr = document.createElement("tr");
|
||||
const nameTd = document.createElement("td");
|
||||
nameTd.innerHTML = `<strong>${escapeHtml(c.name)}</strong>`;
|
||||
const stTd = document.createElement("td");
|
||||
stTd.innerHTML = `<span class="badge ${c.enabled ? "on" : "off"}">${c.enabled ? "Включён" : "Выключен"}</span>`;
|
||||
const actTd = document.createElement("td");
|
||||
actTd.className = "actions";
|
||||
const act = (action) => async () => {
|
||||
if (action === "delete" && !confirm(`Удалить клиента ${c.name}?`)) return;
|
||||
try {
|
||||
await api("/api/xray/clients/update", { method: "POST", body: JSON.stringify({ id: c.id, action }) });
|
||||
await loadXray();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
};
|
||||
actTd.append(
|
||||
iconBtn("⧉", "Копировать ссылку vless://", "btn small ghost icon", async () => {
|
||||
const ok = await copyTextToClipboard(c.link);
|
||||
setStatus(ok ? `Ссылка ${c.name} скопирована.` : "Не удалось скопировать.", !ok);
|
||||
}),
|
||||
c.enabled
|
||||
? iconBtn("⏻", "Выключить", "btn small ghost icon", act("disable"))
|
||||
: iconBtn("▶", "Включить", "btn small primary icon", act("enable")),
|
||||
iconBtn("🗑", "Удалить", "btn small warn icon", act("delete")),
|
||||
);
|
||||
tr.append(nameTd, stTd, actTd);
|
||||
rows.appendChild(tr);
|
||||
}
|
||||
} catch (e) {
|
||||
if (line) line.textContent = String(e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#xray-install-form")?.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const b = ev.target.querySelector("button[type=submit]");
|
||||
if (b) b.disabled = true;
|
||||
try {
|
||||
setStatus("Ставлю Xray (сборка образа ~1 мин)…", false);
|
||||
await api("/api/xray/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
port: document.querySelector("#xray-port").value,
|
||||
sni: document.querySelector("#xray-sni").value,
|
||||
}),
|
||||
});
|
||||
setStatus("Xray установлен. Создайте клиента.", false);
|
||||
await loadXray();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
} finally {
|
||||
if (b) b.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#xray-client-form")?.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const input = document.querySelector("#xray-client-name");
|
||||
try {
|
||||
const r = await api("/api/xray/clients/create", { method: "POST", body: JSON.stringify({ name: input.value }) });
|
||||
input.value = "";
|
||||
const ok = await copyTextToClipboard(r.link);
|
||||
setStatus(ok ? "Клиент создан, ссылка скопирована." : "Клиент создан.", false);
|
||||
await loadXray();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#xray-remove")?.addEventListener("click", async () => {
|
||||
if (!confirm("Удалить контейнер Xray? Клиенты Xray перестанут работать.")) return;
|
||||
try {
|
||||
await api("/api/xray/remove", { method: "POST", body: "{}" });
|
||||
await loadXray();
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<script>try{var t=localStorage.getItem("theme")||(matchMedia("(prefers-color-scheme: light)").matches?"light":"dark");document.documentElement.dataset.theme=t}catch(e){}</script>
|
||||
<link rel="stylesheet" href="/styles.css?v=1.3.0">
|
||||
<link rel="stylesheet" href="/styles.css?v=1.4.0">
|
||||
</head>
|
||||
<body class="page">
|
||||
<aside class="panel-promo-strip" role="complementary" aria-label="Поддержка и донат">
|
||||
<div class="panel-promo-strip-inner">
|
||||
<p class="panel-promo-subtitle muted">Поддержите проект через Boosty, Ozon СБП и Telegram — ниже те же ссылки в подвале.</p>
|
||||
<div class="panel-promo-links">
|
||||
<a class="panel-promo-a" href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub · PRO</a>
|
||||
<a class="panel-promo-a" href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">⭐ GitHub</a>
|
||||
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
||||
<a class="panel-promo-a" href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">💖 Boosty</a>
|
||||
<span class="panel-promo-sep" aria-hidden="true">·</span>
|
||||
@@ -39,7 +39,7 @@
|
||||
</form>
|
||||
<nav class="gate-donate muted" aria-label="Поддержка и донат">
|
||||
Поддержка веб‑панелей (вне этого сервера):<br/>
|
||||
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub · PRO</a>
|
||||
<a href="https://github.com/andrey271192/amnezia_web-PRO" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||
<a href="https://boosty.to/andrey27/donate" target="_blank" rel="noopener noreferrer">Boosty</a>
|
||||
<span class="gate-donate-dot" aria-hidden="true">·</span>
|
||||
@@ -78,7 +78,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="edition-banner" class="edition-banner hidden" role="region" aria-label="Редакция панели"></div>
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="pill proto-pill"><span class="dot ok"></span><span id="proto-label">Протокол: AmneziaWG</span></div>
|
||||
@@ -190,6 +189,44 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold" id="xray-panel">
|
||||
<summary class="fold-summary">
|
||||
<span class="fold-arrow" aria-hidden="true"></span>
|
||||
<span class="fold-titles">
|
||||
<span class="fold-h">Xray VLESS‑Reality</span>
|
||||
<span class="muted fold-meta" id="xray-status-line"></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="panel-fold-body">
|
||||
<p class="muted warp-intro">
|
||||
Протокол для мест, где блокируют AmneziaWG: трафик выглядит как обычный HTTPS к сайту‑маскировке.
|
||||
Ссылку <code class="inline">vless://</code> вставьте в Amnezia VPN, v2rayNG, Hiddify, Streisand или Happ. Нужен открытый TCP‑порт.
|
||||
</p>
|
||||
<form id="xray-install-form" class="instance-form">
|
||||
<div class="instance-form-row">
|
||||
<label for="xray-port">Порт (TCP)</label>
|
||||
<input id="xray-port" type="number" min="1" max="65535" value="443">
|
||||
</div>
|
||||
<div class="instance-form-row">
|
||||
<label for="xray-sni">Сайт‑маскировка</label>
|
||||
<input id="xray-sni" type="text" value="www.google.com" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn primary">+ Установить Xray</button>
|
||||
</form>
|
||||
<div id="xray-manage" style="display:none">
|
||||
<form id="xray-client-form" class="instance-form">
|
||||
<div class="instance-form-row">
|
||||
<label for="xray-client-name">Новый клиент</label>
|
||||
<input id="xray-client-name" type="text" maxlength="64" placeholder="Имя" autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn primary">+ Создать</button>
|
||||
<button type="button" id="xray-remove" class="btn small warn">Удалить Xray</button>
|
||||
</form>
|
||||
<table class="clients-table"><tbody id="xray-rows"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="panel-fold" id="instances-panel" open>
|
||||
<summary class="fold-summary">
|
||||
<span class="fold-arrow" aria-hidden="true"></span>
|
||||
@@ -423,6 +460,6 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/app.js?v=1.3.0" type="module"></script>
|
||||
<script src="/app.js?v=1.4.0" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1097,36 +1097,6 @@ tr:last-child td {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.edition-banner {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.35);
|
||||
background: linear-gradient(135deg, rgba(125, 211, 252, 0.12), rgba(94, 234, 212, 0.06));
|
||||
}
|
||||
|
||||
.edition-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edition-banner-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.edition-banner-text {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.edition-banner-pitch {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* --- Telegram MTProto --- */
|
||||
.mtproto-link-card {
|
||||
margin: 0.65rem 0 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user