Release v1.6.0: multi-server 3x-ui with API inbounds.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -219,16 +219,16 @@ GitHub Actions workflows in `.github/workflows/`:
|
||||
|
||||
## 📋 Fix / changelog (this fork)
|
||||
|
||||
Recent panel fixes and changes:
|
||||
|
||||
* **Removed cascade (double VPN)** for now — the feature showed “active” while client traffic often had no working internet; will return after a solid redesign.
|
||||
* **WG/AWG backups**: ZIP export of client `.conf` files + restore of protocol backups from the server UI (with loading feedback so the panel no longer freezes).
|
||||
* **API performance**: in-memory data cache, faster server check/stats, heavy SSH work moved off the event loop.
|
||||
* **Share / guest pages**: one-tap “Copy key” for configs.
|
||||
* **User expiration**: optional countdown that starts on first config use (panel / share / guest / Telegram), with UTC-safe datetime comparison.
|
||||
* **UI icons**: shared SVG icon system (emoji icons removed).
|
||||
### v1.6.0
|
||||
* **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui).
|
||||
* **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow.
|
||||
* **Removed for now**: Hysteria 2 and 3x-ui integration (kept out of the main panel path).
|
||||
* **Cascade removed** for now (pending redesign — showed active while internet often did not work).
|
||||
* **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
|
||||
* **API performance**: in-memory data cache, faster server check/stats.
|
||||
* **Share / guest**: one-tap “Copy key”.
|
||||
* **User expiration**: countdown can start on first config use; UTC-safe comparisons.
|
||||
* **UI**: shared SVG icon system.
|
||||
* **Removed earlier**: Hysteria 2.
|
||||
|
||||
## 🔧 Project Details
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
"""3x-ui HTTP API client — create VLESS clients and fetch share links.
|
||||
"""3x-ui HTTP API client - create VLESS clients and fetch share links.
|
||||
|
||||
Works with modern panels (/panel/api/clients/*) and falls back to legacy
|
||||
/panel/api/inbounds/addClient when needed.
|
||||
|
||||
@@ -60,7 +60,7 @@ def ensure_xui_servers(settings: dict) -> list:
|
||||
servers = [migrated]
|
||||
settings['xui_servers'] = servers
|
||||
|
||||
# Mirror primary into legacy sync.* for older code paths (no get_xui_server — avoids recursion)
|
||||
# Mirror primary into legacy sync.* for older code paths (no get_xui_server - avoids recursion)
|
||||
sync = settings.setdefault('sync', {})
|
||||
primary = None
|
||||
for s in servers:
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
if (p === 'awg2') return 'AmneziaWG 2.0';
|
||||
if (p === 'awg_legacy') return 'AWG Legacy';
|
||||
if (p === 'xray') return 'Xray';
|
||||
if (p === 'xui') return '3x-ui VLESS';
|
||||
return (p || '').toUpperCase();
|
||||
}
|
||||
|
||||
|
||||
+99
-7
@@ -18,6 +18,18 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if not xui_has_sub_url and xui_configured %}
|
||||
<div class="card" style="margin-bottom:var(--space-lg); border-color: rgba(245, 158, 11, 0.35); background: rgba(245, 158, 11, 0.06);">
|
||||
<div style="display:flex; gap:var(--space-md); align-items:flex-start;">
|
||||
<div style="font-size:1.4rem;">⚠️</div>
|
||||
<div>
|
||||
<div style="font-weight:600; margin-bottom:4px;">{{ _('invite_sub_url_missing_title') }}</div>
|
||||
<div class="form-hint" style="margin:0;">{{ _('invite_sub_url_missing_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="invitesEmpty" class="empty-state {% if invites %}hidden{% endif %}">
|
||||
<div class="empty-icon">{{ icon('link') }}</div>
|
||||
<div class="empty-title">{{ _('invites_empty') }}</div>
|
||||
@@ -31,7 +43,12 @@
|
||||
<div>
|
||||
<div style="font-weight:700; font-size:1.05rem;">{{ inv.name }}</div>
|
||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-top:2px;">
|
||||
{{ inv.protocol }}
|
||||
{% if inv.protocol == 'xui' %}
|
||||
3x-ui · {% for s in xui_servers if s.id == inv.xui_panel_id %}{{ s.name }}{% else %}{{ _('xui_server_select_label') }}{% endfor %}
|
||||
· inbound #{{ inv.xui_inbound_id or '—' }}
|
||||
{% else %}
|
||||
{{ inv.protocol }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if inv.available %}
|
||||
@@ -111,7 +128,8 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||
<select class="form-select" id="inviteProtocol">
|
||||
<select class="form-select" id="inviteProtocol" onchange="updateInviteProtoFields()">
|
||||
<option value="xui">3x-ui VLESS</option>
|
||||
{% for s in servers %}
|
||||
{% set server_idx = loop.index0 %}
|
||||
{% for key, info in (s.protocols or {}).items() %}
|
||||
@@ -123,6 +141,21 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="inviteInboundGroup">
|
||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||||
<select class="form-select" id="inviteXuiPanel" onchange="loadInviteInbounds()">
|
||||
{% for s in xui_servers %}
|
||||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_server_select_hint') }}</div>
|
||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="inviteInboundId">
|
||||
<option value="">{{ _('invite_inbound_loading') }}</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('invite_password_label') }}</label>
|
||||
<input class="form-input" type="password" id="invitePassword" placeholder="{{ _('share_password_hint') }}" autocomplete="new-password">
|
||||
@@ -154,6 +187,9 @@
|
||||
|
||||
<script>
|
||||
const invitesData = {{ invites | tojson }};
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
const xuiDefaultInbound = {{ xui_default_inbound | int }};
|
||||
const xuiDefaultPanelId = {{ (xui_default_panel_id or '') | tojson }};
|
||||
|
||||
function inviteUrl(token) {
|
||||
return `${window.location.origin}/invite/${token}`;
|
||||
@@ -164,8 +200,46 @@
|
||||
showToast(_('copied'), 'success');
|
||||
}
|
||||
|
||||
function updateInviteProtoFields() {
|
||||
const v = document.getElementById('inviteProtocol').value;
|
||||
document.getElementById('inviteInboundGroup').style.display = (v === 'xui') ? '' : 'none';
|
||||
if (v === 'xui') loadInviteInbounds(document.getElementById('inviteInboundId').value || xuiDefaultInbound);
|
||||
}
|
||||
|
||||
async function loadInviteInbounds(selected) {
|
||||
const select = document.getElementById('inviteInboundId');
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
||||
if (!xuiConfigured) {
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_need_xui')}</option>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const panelId = document.getElementById('inviteXuiPanel')?.value || xuiDefaultPanelId || '';
|
||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
||||
const data = await apiCall('/api/settings/xui/inbounds' + q);
|
||||
const list = data.inbounds || [];
|
||||
if (!list.length) {
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
||||
return;
|
||||
}
|
||||
select.innerHTML = '';
|
||||
list.forEach((ib, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
const prefer = selected || xuiDefaultInbound;
|
||||
if (prefer && String(ib.id) === String(prefer)) opt.selected = true;
|
||||
else if (!prefer && idx === 0) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
select.innerHTML = `<option value="">${_('error')}: ${e.message}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseProtocolValue() {
|
||||
const raw = document.getElementById('inviteProtocol').value;
|
||||
if (raw === 'xui') return { protocol: 'xui', server_id: 0 };
|
||||
if (raw.includes('|')) {
|
||||
const [protocol, sid] = raw.split('|');
|
||||
return { protocol, server_id: parseInt(sid || '0') };
|
||||
@@ -175,9 +249,13 @@
|
||||
|
||||
function setProtocolSelect(protocol, serverId) {
|
||||
const sel = document.getElementById('inviteProtocol');
|
||||
const want = `${protocol}|${serverId || 0}`;
|
||||
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
||||
else if (sel.options.length) sel.value = sel.options[0].value;
|
||||
if (protocol === 'xui') sel.value = 'xui';
|
||||
else {
|
||||
const want = `${protocol}|${serverId || 0}`;
|
||||
if ([...sel.options].some(o => o.value === want)) sel.value = want;
|
||||
else sel.value = 'xui';
|
||||
}
|
||||
updateInviteProtoFields();
|
||||
}
|
||||
|
||||
function openCreateInvite() {
|
||||
@@ -191,7 +269,12 @@
|
||||
document.getElementById('inviteNote').value = '';
|
||||
document.getElementById('inviteClearPwdWrap').style.display = 'none';
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'none';
|
||||
setProtocolSelect('awg', 0);
|
||||
setProtocolSelect('xui', 0);
|
||||
if (xuiDefaultPanelId) {
|
||||
const p = document.getElementById('inviteXuiPanel');
|
||||
if (p && [...p.options].some(o => o.value === xuiDefaultPanelId)) p.value = xuiDefaultPanelId;
|
||||
}
|
||||
loadInviteInbounds(xuiDefaultInbound);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
|
||||
@@ -210,7 +293,12 @@
|
||||
document.getElementById('inviteClearPassword').checked = false;
|
||||
document.getElementById('inviteResetUsedWrap').style.display = 'block';
|
||||
document.getElementById('inviteResetUsed').checked = false;
|
||||
setProtocolSelect(inv.protocol || 'awg', inv.server_id || 0);
|
||||
setProtocolSelect(inv.protocol || 'xui', inv.server_id || 0);
|
||||
const panelSel = document.getElementById('inviteXuiPanel');
|
||||
if (panelSel && inv.xui_panel_id && [...panelSel.options].some(o => o.value === inv.xui_panel_id)) {
|
||||
panelSel.value = inv.xui_panel_id;
|
||||
}
|
||||
loadInviteInbounds(inv.xui_inbound_id || xuiDefaultInbound);
|
||||
openModal('inviteModal');
|
||||
}
|
||||
|
||||
@@ -222,6 +310,8 @@
|
||||
try {
|
||||
const editId = document.getElementById('inviteEditId').value;
|
||||
const proto = parseProtocolValue();
|
||||
const inbound = parseInt(document.getElementById('inviteInboundId').value || '0');
|
||||
if (proto.protocol === 'xui' && !inbound) throw new Error(_('invite_inbound_required'));
|
||||
|
||||
const body = {
|
||||
name: document.getElementById('inviteName').value || 'Invite',
|
||||
@@ -230,6 +320,8 @@
|
||||
user_id: document.getElementById('inviteUserId').value || '',
|
||||
protocol: proto.protocol,
|
||||
server_id: proto.server_id,
|
||||
xui_inbound_id: inbound,
|
||||
xui_panel_id: document.getElementById('inviteXuiPanel')?.value || '',
|
||||
note: document.getElementById('inviteNote').value || '',
|
||||
};
|
||||
const pwd = document.getElementById('invitePassword').value;
|
||||
|
||||
+398
-3
@@ -112,6 +112,7 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||
<select class="form-select" id="guest_create_protocol" onchange="updateGuestCreateFields()">
|
||||
<option value="xui" {% if settings.guest.create_protocol == 'xui' %}selected{% endif %}>3x-ui VLESS</option>
|
||||
{% for s in servers %}
|
||||
{% set server_idx = loop.index0 %}
|
||||
{% for key, info in (s.protocols or {}).items() %}
|
||||
@@ -125,6 +126,19 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="guestInboundGroup" style="{% if settings.guest.create_protocol != 'xui' %}display:none;{% endif %}">
|
||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||||
<select class="form-select" id="guest_create_xui_panel" onchange="loadGuestInbounds()">
|
||||
{% for s in xui_servers %}
|
||||
<option value="{{ s.id }}" {% if settings.guest.create_xui_panel_id == s.id %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="guest_create_inbound_id">
|
||||
<option value="0">{{ _('invite_inbound_loading') }}</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -334,6 +348,10 @@
|
||||
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
|
||||
<input type="checkbox" disabled> Marzban
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||||
<input type="checkbox" name="xui_sync" {% if settings.sync.xui_sync %}checked{% endif %}>
|
||||
3x-ui
|
||||
</label>
|
||||
<label
|
||||
style="display: flex; align-items: center; gap: var(--space-sm); cursor: not-allowed; color: var(--text-muted);">
|
||||
<input type="checkbox" disabled> Hiddify
|
||||
@@ -418,9 +436,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="xuiFields"
|
||||
style="{% if not settings.sync.xui_sync %}display:none;{% endif %} border-top: 1px solid var(--border-color); padding-top: var(--space-md); margin-top: var(--space-md);">
|
||||
<div class="form-hint" style="margin-bottom: var(--space-md);">
|
||||
{{ _('xui_servers_manage_hint') }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label
|
||||
style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer; margin-bottom: var(--space-xs);">
|
||||
<input type="checkbox" name="xui_sync_users" {% if settings.sync.xui_sync_users %}checked{% endif %}>
|
||||
{{ _('enable_sync') }}
|
||||
</label>
|
||||
<div class="form-hint"
|
||||
style="margin-left: var(--space-lg); line-height: 1.4; margin-bottom: var(--space-sm);">
|
||||
{{ _('xui_sync_hint') }}
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--space-sm); margin-left: var(--space-lg);">
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="syncXuiNow()" id="xuiSyncNowBtn">
|
||||
<span id="xuiSyncNowBtnText">🔄 {{ _('sync_now_btn') }}</span>
|
||||
<div class="spinner hidden" id="xuiSyncNowSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="deleteSyncXui()" id="xuiSyncDelBtn">
|
||||
<span id="xuiSyncDelBtnText">{{ icon('trash') }} {{ _('delete_sync_btn') }}</span>
|
||||
<div class="spinner hidden" id="xuiSyncDelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||||
<input type="checkbox" name="xui_create_conns" id="xuiSyncCreateConns" {% if
|
||||
settings.sync.xui_create_conns %}checked{% endif %}>
|
||||
{{ _('auto_create_conns') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="xuiAutoConnFields"
|
||||
style="{% if not settings.sync.xui_create_conns %}display:none;{% endif %} background: var(--bg-primary); padding: var(--space-md); border-radius: var(--radius-md); margin-top: var(--space-sm);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('sync_server_label') }}</label>
|
||||
<select class="form-select" name="xui_server_id" onchange="updateProtocolsForXuiSync()">
|
||||
{% for s in servers %}
|
||||
<option value="{{ loop.index0 }}" {% if settings.sync.xui_server_id==loop.index0
|
||||
%}selected{% endif %}>{{ s.name or s.host }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('protocol_label') }}</label>
|
||||
<select class="form-select" name="xui_protocol" id="xuiSyncProtocolSelect">
|
||||
<option value="xray" {% if settings.sync.xui_protocol=='xray' %}selected{% endif %}>Xray</option>
|
||||
<option value="awg" {% if settings.sync.xui_protocol=='awg' %}selected{% endif %}>AmneziaWG</option>
|
||||
<option value="awg2" {% if settings.sync.xui_protocol=='awg2' %}selected{% endif %}>AmneziaWG 2.0</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="xui_url" value="{{ settings.sync.xui_url or '' }}">
|
||||
<input type="hidden" name="xui_sub_url" value="{{ settings.sync.xui_sub_url or '' }}">
|
||||
<input type="hidden" name="xui_api_token" value="{{ settings.sync.xui_api_token or '' }}">
|
||||
<input type="hidden" name="xui_username" value="{{ settings.sync.xui_username or '' }}">
|
||||
<input type="hidden" name="xui_password" value="{{ settings.sync.xui_password or '' }}">
|
||||
<input type="hidden" name="xui_inbound_id" value="{{ settings.sync.xui_inbound_id or 0 }}">
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK: 3x-ui servers (multi) -->
|
||||
<div class="card" style="margin-top: var(--space-lg);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin:0;">{{ _('xui_servers_title') }}</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="openXuiServerModal()">+ {{ _('xui_server_add') }}</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-bottom: var(--space-md);">{{ _('xui_servers_hint') }}</div>
|
||||
<div id="xuiServersList">
|
||||
{% if xui_servers %}
|
||||
{% for s in xui_servers %}
|
||||
<div class="client-item" style="margin-bottom:var(--space-sm);" data-xui-id="{{ s.id }}">
|
||||
<div class="client-info" style="flex:1; min-width:0;">
|
||||
<div class="client-avatar">🌐</div>
|
||||
<div style="min-width:0;">
|
||||
<div class="client-name">{{ s.name }}</div>
|
||||
<div class="client-meta" style="word-break:break-all;">
|
||||
<span>{{ s.url }}</span>
|
||||
{% if s.sub_url %}<span>· sub: {{ s.sub_url }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="client-actions">
|
||||
<button class="btn btn-secondary btn-sm" type="button" onclick='editXuiServer({{ s | tojson }})'>{{ _('edit') }}</button>
|
||||
<button class="btn btn-danger btn-sm" type="button" onclick="deleteXuiServer('{{ s.id }}')">{{ _('delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align:center; padding:var(--space-lg); color:var(--text-muted);">{{ _('xui_servers_empty') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK: Simple Backup -->
|
||||
<div class="card" style="margin-top: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
||||
@@ -500,6 +615,64 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ===== 3x-ui Server Modal ===== -->
|
||||
<div class="modal-backdrop" id="xuiServerModal">
|
||||
<div class="modal" style="max-width:520px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="xuiServerModalTitle">{{ _('xui_server_add') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('xuiServerModal')">×</button>
|
||||
</div>
|
||||
<input type="hidden" id="xuiServerEditId" value="">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_server_name_label') }}</label>
|
||||
<input class="form-input" type="text" id="xuiServerName" placeholder="US / NL / Home">
|
||||
<div class="form-hint">{{ _('xui_server_name_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_url_label') }}</label>
|
||||
<input class="form-input" type="url" id="xuiServerUrl" placeholder="https://panel.example.com:2053/path">
|
||||
<div class="form-hint">{{ _('xui_url_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_sub_url_label') }}</label>
|
||||
<input class="form-input" type="url" id="xuiServerSubUrl" placeholder="https://sub.example.com:2096/sub">
|
||||
<div class="form-hint">{{ _('xui_sub_url_hint') }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_api_token_label') }}</label>
|
||||
<input class="form-input" type="password" id="xuiServerToken" placeholder="{{ _('xui_api_token_placeholder') }}" autocomplete="off">
|
||||
<div class="form-hint">{{ _('xui_api_token_hint') }}</div>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm);">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_username_label') }}</label>
|
||||
<input class="form-input" type="text" id="xuiServerUser" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('xui_password_label') }}</label>
|
||||
<input class="form-input" type="password" id="xuiServerPass" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||
<input type="checkbox" id="xuiServerEnabled" checked> {{ _('enabled') if False else 'Enabled' }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||
<input type="checkbox" id="xuiServerSyncUsers"> {{ _('enable_sync') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal('xuiServerModal')">{{ _('cancel') }}</button>
|
||||
<button class="btn btn-primary" onclick="saveXuiServer()" id="xuiServerSaveBtn">
|
||||
<span id="xuiServerSaveText">{{ _('save') }}</span>
|
||||
<div class="spinner hidden" id="xuiServerSaveSpinner" style="width:14px;height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Create API Token Modal ===== -->
|
||||
<div class="modal-backdrop" id="createTokenModal">
|
||||
<div class="modal" style="max-width: 460px;">
|
||||
@@ -590,11 +763,139 @@
|
||||
document.getElementById('remnawaveFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
document.querySelector('[name="xui_sync"]').addEventListener('change', (e) => {
|
||||
document.getElementById('xuiFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
function updateProtocolsForXuiSync() {
|
||||
const serverIdx = document.querySelector('[name="xui_server_id"]').value;
|
||||
const protoSelect = document.getElementById('xuiSyncProtocolSelect');
|
||||
protoSelect.innerHTML = '';
|
||||
|
||||
if (serverIdx === '' || !serversData[serverIdx]) return;
|
||||
|
||||
const protocols = serversData[serverIdx].protocols || {};
|
||||
let count = 0;
|
||||
|
||||
for (const [key, info] of Object.entries(protocols)) {
|
||||
if (info.installed) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = key;
|
||||
opt.textContent = key === 'awg' ? 'AmneziaWG' : (key === 'awg2' ? 'AmneziaWG 2.0' : (key === 'awg_legacy' ? 'AWG Legacy' : (key === 'xray' ? 'Xray' : key.toUpperCase())));
|
||||
if (key === "{{ settings.sync.xui_protocol }}") opt.selected = true;
|
||||
protoSelect.appendChild(opt);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.textContent = _('no_protocols');
|
||||
opt.disabled = true;
|
||||
protoSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function openXuiServerModal(server) {
|
||||
document.getElementById('xuiServerEditId').value = server?.id || '';
|
||||
document.getElementById('xuiServerModalTitle').textContent = server?.id ? (_('edit') + ' — 3x-ui') : _('xui_server_add');
|
||||
document.getElementById('xuiServerName').value = server?.name || '';
|
||||
document.getElementById('xuiServerUrl').value = server?.url || '';
|
||||
document.getElementById('xuiServerSubUrl').value = server?.sub_url || '';
|
||||
document.getElementById('xuiServerToken').value = '';
|
||||
document.getElementById('xuiServerUser').value = server?.username || '';
|
||||
document.getElementById('xuiServerPass').value = '';
|
||||
document.getElementById('xuiServerEnabled').checked = server ? !!server.enabled : true;
|
||||
document.getElementById('xuiServerSyncUsers').checked = !!(server && server.sync_users);
|
||||
openModal('xuiServerModal');
|
||||
}
|
||||
|
||||
function editXuiServer(server) { openXuiServerModal(server); }
|
||||
|
||||
async function saveXuiServer() {
|
||||
const id = document.getElementById('xuiServerEditId').value;
|
||||
const body = {
|
||||
name: document.getElementById('xuiServerName').value.trim(),
|
||||
url: document.getElementById('xuiServerUrl').value.trim(),
|
||||
sub_url: document.getElementById('xuiServerSubUrl').value.trim(),
|
||||
api_token: document.getElementById('xuiServerToken').value,
|
||||
username: document.getElementById('xuiServerUser').value.trim(),
|
||||
password: document.getElementById('xuiServerPass').value,
|
||||
enabled: document.getElementById('xuiServerEnabled').checked,
|
||||
sync_users: document.getElementById('xuiServerSyncUsers').checked,
|
||||
default_inbound_id: 0,
|
||||
};
|
||||
if (!body.url) { showToast(_('error') + ': URL', 'error'); return; }
|
||||
const btn = document.getElementById('xuiServerSaveBtn');
|
||||
const text = document.getElementById('xuiServerSaveText');
|
||||
const spinner = document.getElementById('xuiServerSaveSpinner');
|
||||
btn.disabled = true; text.textContent = _('saving') || 'Saving...'; spinner.classList.remove('hidden');
|
||||
try {
|
||||
if (id) await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'PUT', body);
|
||||
else await apiCall('/api/settings/xui/servers', 'POST', body);
|
||||
showToast(_('saved') || 'OK', 'success');
|
||||
closeModal('xuiServerModal');
|
||||
setTimeout(() => location.reload(), 400);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; text.textContent = _('save'); spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteXuiServer(id) {
|
||||
if (!confirm(_('xui_server_delete_confirm') || 'Delete this 3x-ui server?')) return;
|
||||
try {
|
||||
await apiCall(`/api/settings/xui/servers/${encodeURIComponent(id)}`, 'DELETE');
|
||||
showToast(_('deleted') || 'OK', 'success');
|
||||
setTimeout(() => location.reload(), 400);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGuestInbounds() {
|
||||
const select = document.getElementById('guest_create_inbound_id');
|
||||
if (!select) return;
|
||||
const preferred = {{ settings.guest.create_inbound_id | default(0) | int }};
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
||||
try {
|
||||
const panelSel = document.getElementById('guest_create_xui_panel');
|
||||
const panelId = panelSel ? panelSel.value : '';
|
||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
||||
const res = await fetch('/api/settings/xui/inbounds' + q);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load inbounds');
|
||||
const list = data.inbounds || [];
|
||||
if (!list.length) {
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
||||
return;
|
||||
}
|
||||
select.innerHTML = '';
|
||||
list.forEach((ib, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
if (preferred && String(ib.id) === String(preferred)) opt.selected = true;
|
||||
else if (!preferred && idx === 0) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('loadGuestInbounds:', err);
|
||||
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('syncCreateConns').addEventListener('change', (e) => {
|
||||
document.getElementById('autoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
if (e.target.checked) updateProtocolsForSync();
|
||||
});
|
||||
|
||||
document.getElementById('xuiSyncCreateConns').addEventListener('change', (e) => {
|
||||
document.getElementById('xuiAutoConnFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
if (e.target.checked) updateProtocolsForXuiSync();
|
||||
});
|
||||
|
||||
document.getElementById('ssl_enabled').addEventListener('change', (e) => {
|
||||
document.getElementById('sslFields').style.display = e.target.checked ? 'block' : 'none';
|
||||
});
|
||||
@@ -844,6 +1145,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function syncXuiNow() {
|
||||
const btn = document.getElementById('xuiSyncNowBtn');
|
||||
const text = document.getElementById('xuiSyncNowBtnText');
|
||||
const spinner = document.getElementById('xuiSyncNowSpinner');
|
||||
const syncForm = document.getElementById('syncForm');
|
||||
|
||||
btn.disabled = true;
|
||||
text.textContent = _('sync_running');
|
||||
spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
xui_sync: true,
|
||||
xui_sync_users: true,
|
||||
xui_url: syncForm.xui_url.value.trim(),
|
||||
xui_username: syncForm.xui_username.value.trim(),
|
||||
xui_password: syncForm.xui_password.value,
|
||||
xui_api_token: syncForm.xui_api_token.value.trim(),
|
||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
||||
xui_protocol: syncForm.xui_protocol.value,
|
||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
|
||||
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
|
||||
};
|
||||
const res = await fetch('/api/settings/xui_sync_now', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
showToast(_('sync_success').replace('{}', data.count), 'success');
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
throw new Error(data.message || data.error || 'Error occurred');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = `🔄 ${_('sync_now_btn')}`;
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSyncXui() {
|
||||
if (!confirm(_('delete_sync_confirm'))) return;
|
||||
|
||||
const btn = document.getElementById('xuiSyncDelBtn');
|
||||
const text = document.getElementById('xuiSyncDelBtnText');
|
||||
const spinner = document.getElementById('xuiSyncDelSpinner');
|
||||
|
||||
btn.disabled = true;
|
||||
text.textContent = _('deleting');
|
||||
spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/xui_sync_delete', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
showToast(_('sync_deleted').replace('{}', data.count), 'success');
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.innerHTML = `${uiIcon('trash')} ${_('delete_sync_btn')}`;
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const btn = document.getElementById('saveBtn');
|
||||
const spinner = document.getElementById('saveSpinner');
|
||||
@@ -867,6 +1240,17 @@
|
||||
remnawave_create_conns: syncForm.remnawave_create_conns.checked,
|
||||
remnawave_server_id: parseInt(syncForm.remnawave_server_id.value || '0'),
|
||||
remnawave_protocol: syncForm.remnawave_protocol.value,
|
||||
xui_sync: syncForm.xui_sync.checked,
|
||||
xui_url: syncForm.xui_url.value,
|
||||
xui_username: syncForm.xui_username.value,
|
||||
xui_password: syncForm.xui_password.value,
|
||||
xui_api_token: syncForm.xui_api_token.value,
|
||||
xui_sync_users: syncForm.xui_sync_users.checked,
|
||||
xui_create_conns: syncForm.xui_create_conns.checked,
|
||||
xui_server_id: parseInt(syncForm.xui_server_id.value || '0'),
|
||||
xui_protocol: syncForm.xui_protocol.value,
|
||||
xui_inbound_id: parseInt(syncForm.xui_inbound_id.value || '0'),
|
||||
xui_sub_url: (syncForm.xui_sub_url && syncForm.xui_sub_url.value || '').trim()
|
||||
};
|
||||
|
||||
|
||||
@@ -889,10 +1273,12 @@
|
||||
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
|
||||
};
|
||||
|
||||
let createProtocol = 'awg';
|
||||
let createProtocol = 'xui';
|
||||
let createServerId = 0;
|
||||
const guestProtoRaw = document.getElementById('guest_create_protocol').value;
|
||||
if (guestProtoRaw.includes('|')) {
|
||||
if (guestProtoRaw === 'xui') {
|
||||
createProtocol = 'xui';
|
||||
} else if (guestProtoRaw.includes('|')) {
|
||||
const parts = guestProtoRaw.split('|');
|
||||
createProtocol = parts[0];
|
||||
createServerId = parseInt(parts[1] || '0');
|
||||
@@ -907,6 +1293,8 @@
|
||||
allow_create: document.getElementById('guest_allow_create').checked,
|
||||
create_protocol: createProtocol,
|
||||
create_server_id: createServerId,
|
||||
create_inbound_id: parseInt(document.getElementById('guest_create_inbound_id').value || '0'),
|
||||
create_xui_panel_id: document.getElementById('guest_create_xui_panel')?.value || '',
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -927,7 +1315,14 @@
|
||||
}
|
||||
|
||||
function updateGuestCreateFields() {
|
||||
// No-op: only server-backed protocols are selectable now.
|
||||
const v = document.getElementById('guest_create_protocol')?.value;
|
||||
const group = document.getElementById('guestInboundGroup');
|
||||
if (group) group.style.display = (v === 'xui') ? 'block' : 'none';
|
||||
if (v === 'xui') loadGuestInbounds();
|
||||
}
|
||||
|
||||
if (document.getElementById('guest_create_protocol')?.value === 'xui') {
|
||||
loadGuestInbounds();
|
||||
}
|
||||
|
||||
function copyGuestLink() {
|
||||
|
||||
+74
-1
@@ -306,6 +306,20 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="ucXuiInboundGroup" style="display:none;">
|
||||
<label class="form-label">{{ _('xui_server_select_label') }}</label>
|
||||
<select class="form-select" id="ucXuiPanel" onchange="loadXuiInbounds()">
|
||||
{% for s in xui_servers %}
|
||||
<option value="{{ s.id }}" {% if s.id == xui_default_panel_id %}selected{% endif %}>{{ s.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="form-label" style="margin-top:var(--space-sm);">{{ _('xui_inbound_label') }}</label>
|
||||
<select class="form-select" id="ucXuiInbound">
|
||||
<option value="">{{ _('invite_inbound_loading') }}</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ _('xui_inbound_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="ucExistingClientGroup" style="display:none;">
|
||||
<label class="form-label">{{ _('select_existing_conn') }}</label>
|
||||
<select class="form-select" id="ucExistingClient">
|
||||
@@ -420,6 +434,9 @@
|
||||
let searchTimeout = null;
|
||||
|
||||
const serversData = {{ servers | tojson }};
|
||||
const xuiConfigured = {{ 'true' if xui_configured else 'false' }};
|
||||
const xuiDefaultInboundId = {{ xui_inbound_id | int }};
|
||||
const xuiServers = {{ xui_servers | default([]) | tojson }};
|
||||
|
||||
function populateTimeSelect(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
@@ -626,6 +643,14 @@
|
||||
count++;
|
||||
}
|
||||
|
||||
if (xuiConfigured && selectId === 'ucProtocol') {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'xui';
|
||||
opt.textContent = '3x-ui VLESS';
|
||||
select.appendChild(opt);
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
if (group) group.style.display = '';
|
||||
} else {
|
||||
@@ -635,6 +660,48 @@
|
||||
select.appendChild(opt);
|
||||
if (group) group.style.display = '';
|
||||
}
|
||||
if (selectId === 'ucProtocol') {
|
||||
updateXuiInboundVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadXuiInbounds() {
|
||||
const select = document.getElementById('ucXuiInbound');
|
||||
if (!select) return;
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_loading')}</option>`;
|
||||
try {
|
||||
const panelId = document.getElementById('ucXuiPanel')?.value || '';
|
||||
const q = panelId ? `?panel_id=${encodeURIComponent(panelId)}` : '';
|
||||
const data = await apiCall('/api/settings/xui/inbounds' + q);
|
||||
const list = data.inbounds || [];
|
||||
if (!list.length) {
|
||||
select.innerHTML = `<option value="">${_('invite_inbound_empty')}</option>`;
|
||||
return;
|
||||
}
|
||||
select.innerHTML = '';
|
||||
list.forEach((ib, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ib.id;
|
||||
opt.textContent = `${ib.remark || 'VLESS'} (:${ib.port})`;
|
||||
if (xuiDefaultInboundId && String(ib.id) === String(xuiDefaultInboundId)) opt.selected = true;
|
||||
else if (!xuiDefaultInboundId && idx === 0) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
select.innerHTML = `<option value="">${_('error')}: ${err.message}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateXuiInboundVisibility() {
|
||||
const proto = document.getElementById('ucProtocol')?.value;
|
||||
const group = document.getElementById('ucXuiInboundGroup');
|
||||
if (!group) return;
|
||||
if (proto === 'xui') {
|
||||
group.style.display = 'block';
|
||||
loadXuiInbounds();
|
||||
} else {
|
||||
group.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide connection fields
|
||||
@@ -667,6 +734,7 @@
|
||||
fetchExistingClients();
|
||||
}
|
||||
updateTelemtOptions('ucProtocol', 'ucTelemtOptions');
|
||||
updateXuiInboundVisibility();
|
||||
});
|
||||
|
||||
async function addUser(e) {
|
||||
@@ -840,6 +908,11 @@
|
||||
const clientId = document.getElementById('ucExistingClient').value;
|
||||
if (!clientId) throw new Error(_('select_connection'));
|
||||
body.client_id = clientId;
|
||||
} else if (body.protocol === 'xui') {
|
||||
const inbound = parseInt(document.getElementById('ucXuiInbound').value || '0');
|
||||
if (!inbound) throw new Error(_('invite_inbound_required'));
|
||||
body.xui_inbound_id = inbound;
|
||||
body.xui_panel_id = document.getElementById('ucXuiPanel')?.value || '';
|
||||
} else if (body.protocol === 'telemt') {
|
||||
body.telemt_quota = document.getElementById('ucTelemtQuota').value || null;
|
||||
body.telemt_max_ips = parseInt(document.getElementById('ucTelemtMaxIps').value) || null;
|
||||
@@ -895,7 +968,7 @@
|
||||
<div class="client-name">${c.name || 'Connection'}</div>
|
||||
<div class="client-meta">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;">${uiIcon('server')} ${c.server_name || ''}</span>
|
||||
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : c.protocol.toUpperCase())))}</span>
|
||||
<span>${c.protocol === 'awg' ? 'AmneziaWG' : (c.protocol === 'awg2' ? 'AmneziaWG 2.0' : (c.protocol === 'awg_legacy' ? 'AWG Legacy' : (c.protocol === 'xray' ? 'Xray' : (c.protocol === 'xui' ? '3x-ui VLESS' : c.protocol.toUpperCase()))))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+32
-2
@@ -193,7 +193,6 @@
|
||||
"guest_password_keep": "Leave blank to keep current password",
|
||||
"guest_clear_password": "Remove password protection",
|
||||
"guest_allow_create": "Allow guests to create a new config",
|
||||
"guest_allow_create_hint": "Creates a config for the guest user using the selected server protocol.",
|
||||
"guest_access_btn": "Continue as guest",
|
||||
"guest_access_login_hint": "No account required",
|
||||
"guest_title": "Guest access",
|
||||
@@ -519,5 +518,36 @@
|
||||
"restore_protocol_backup_confirm": "Restore this backup on the server? Current protocol files will be overwritten and the container will restart.",
|
||||
"restoring_protocol_backup": "Restoring… please wait",
|
||||
"restore_protocol_backup_done": "Backup restored",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"guest_allow_create_hint": "Creates a config for the guest user (3x-ui VLESS or selected server protocol).",
|
||||
"invite_inbound_hint": "Inbounds are loaded from the selected 3x-ui panel. Only 3x-ui returns the config/share link.",
|
||||
"invite_inbound_loading": "Loading inbounds from 3x-ui…",
|
||||
"invite_inbound_empty": "No VLESS inbounds on this 3x-ui panel",
|
||||
"invite_inbound_need_xui": "Configure 3x-ui in Settings first",
|
||||
"invite_inbound_required": "Select a VLESS inbound from 3x-ui",
|
||||
"invite_sub_url_missing_title": "Subscription URL is not set",
|
||||
"invite_sub_url_missing_hint": "In Settings → 3x-ui set Subscription URL base so invite links issue /sub/… configs.",
|
||||
"xui_url_label": "3x-ui URL",
|
||||
"xui_url_hint": "Include scheme, port and webBasePath if configured (example: https://host:2053/secret)",
|
||||
"xui_sub_url_label": "Subscription URL base",
|
||||
"xui_sub_url_hint": "Public /sub base without trailing slash (example: https://host:2096/sub). Invite configs are issued as subscription links.",
|
||||
"xui_api_token_label": "API Token (preferred)",
|
||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||
"xui_api_token_hint": "If set, username/password login is skipped",
|
||||
"xui_username_label": "3x-ui Username",
|
||||
"xui_password_label": "3x-ui Password",
|
||||
"xui_sync_hint": "Clients from 3x-ui inbounds will be created, disabled, and deleted as panel users (matched by email)",
|
||||
"xui_inbound_label": "VLESS inbound",
|
||||
"xui_inbound_auto": "Auto (first VLESS inbound)",
|
||||
"xui_inbound_hint": "Inbounds are loaded from 3x-ui. This site only creates the client and takes the ready share link — no local config generation.",
|
||||
"xui_servers_title": "3x-ui servers",
|
||||
"xui_servers_hint": "Add 3x-ui panels. When creating, pick a server and an inbound from the 3x-ui list — the panel returns the share link.",
|
||||
"xui_servers_empty": "No 3x-ui servers yet. Click Add server.",
|
||||
"xui_servers_manage_hint": "Panel URLs and subscription bases are managed in the “3x-ui servers” section below.",
|
||||
"xui_server_add": "Add server",
|
||||
"xui_server_name_label": "Name / description",
|
||||
"xui_server_name_hint": "Shown in invites and user forms (e.g. US, NL, Home).",
|
||||
"xui_server_select_label": "3x-ui server",
|
||||
"xui_server_select_hint": "Pick a 3x-ui panel, then an inbound from the list that panel returns via API.",
|
||||
"xui_server_delete_confirm": "Delete this 3x-ui server from the panel?"
|
||||
}
|
||||
|
||||
+32
-2
@@ -184,7 +184,6 @@
|
||||
"guest_password_keep": "برای حفظ رمز فعلی خالی بگذارید",
|
||||
"guest_clear_password": "حذف محافظت با رمز",
|
||||
"guest_allow_create": "اجازه ساخت کانفیگ جدید به مهمان",
|
||||
"guest_allow_create_hint": "برای کاربر مهمان با استفاده از پروتکل سرور انتخابشده کانفیگ میسازد.",
|
||||
"guest_access_btn": "ادامه بهعنوان مهمان",
|
||||
"guest_access_login_hint": "بدون حساب کاربری",
|
||||
"guest_title": "دسترسی مهمان",
|
||||
@@ -463,5 +462,36 @@
|
||||
"backup_created": "Backup created",
|
||||
"no_backups": "No backups yet",
|
||||
"no_backups_desc": "Create the first backup for this protocol.",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"guest_allow_create_hint": "برای کاربر مهمان کانفیگ میسازد (3x-ui VLESS یا پروتکل سرور).",
|
||||
"invite_inbound_hint": "یکبار inbound مورد نیاز VLESS را انتخاب کنید",
|
||||
"invite_inbound_loading": "در حال بارگذاری…",
|
||||
"invite_inbound_empty": "inbound VLESS یافت نشد",
|
||||
"invite_inbound_need_xui": "ابتدا 3x-ui را در تنظیمات پیکربندی کنید",
|
||||
"invite_inbound_required": "یک inbound VLESS انتخاب کنید",
|
||||
"invite_sub_url_missing_title": "آدرس اشتراک تنظیم نشده",
|
||||
"invite_sub_url_missing_hint": "در تنظیمات → 3x-ui پایه /sub را وارد کنید.",
|
||||
"xui_url_label": "آدرس 3x-ui",
|
||||
"xui_url_hint": "شامل پروتکل، پورت و webBasePath در صورت وجود",
|
||||
"xui_sub_url_label": "آدرس پایه اشتراک",
|
||||
"xui_sub_url_hint": "پایه عمومی /sub بدون اسلش پایانی (مثال: https://host:2096/sub).",
|
||||
"xui_api_token_label": "توکن API (ترجیحی)",
|
||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||
"xui_api_token_hint": "در صورت تنظیم، ورود با نام کاربری/رمز رد میشود",
|
||||
"xui_username_label": "نام کاربری 3x-ui",
|
||||
"xui_password_label": "رمز عبور 3x-ui",
|
||||
"xui_sync_hint": "کلاینتهای 3x-ui بر اساس email به کاربران پنل همگام میشوند",
|
||||
"xui_inbound_label": "ورودی VLESS",
|
||||
"xui_inbound_auto": "خودکار (اولین ورودی VLESS)",
|
||||
"xui_inbound_hint": "برای ساخت کانفیگ 3x-ui VLESS استفاده میشود. ابتدا یک ورودی VLESS در 3x-ui بسازید.",
|
||||
"xui_servers_title": "سرورهای 3x-ui",
|
||||
"xui_servers_hint": "چند پنل 3x-ui اضافه کنید. هنگام ساخت، سرور را با نام انتخاب کنید تا URL اشتراک همان سرور داده شود.",
|
||||
"xui_servers_empty": "هنوز سرور 3x-ui نیست. افزودن را بزنید.",
|
||||
"xui_servers_manage_hint": "آدرس پنل و پایه اشتراک در بخش «سرورهای 3x-ui» مدیریت میشود.",
|
||||
"xui_server_add": "افزودن سرور",
|
||||
"xui_server_name_label": "نام / توضیح",
|
||||
"xui_server_name_hint": "در دعوتها نمایش داده میشود (مثل US، NL).",
|
||||
"xui_server_select_label": "سرور 3x-ui",
|
||||
"xui_server_select_hint": "پنل 3x-ui که URL اشتراک میدهد را انتخاب کنید.",
|
||||
"xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟"
|
||||
}
|
||||
|
||||
+32
-2
@@ -184,7 +184,6 @@
|
||||
"guest_password_keep": "Laisser vide pour conserver",
|
||||
"guest_clear_password": "Retirer le mot de passe",
|
||||
"guest_allow_create": "Autoriser la création d'une config",
|
||||
"guest_allow_create_hint": "Crée une config pour l'utilisateur invité en utilisant le protocole du serveur sélectionné.",
|
||||
"guest_access_btn": "Continuer en invité",
|
||||
"guest_access_login_hint": "Sans compte",
|
||||
"guest_title": "Accès invité",
|
||||
@@ -463,5 +462,36 @@
|
||||
"backup_created": "Backup created",
|
||||
"no_backups": "No backups yet",
|
||||
"no_backups_desc": "Create the first backup for this protocol.",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"guest_allow_create_hint": "Crée une config pour l'utilisateur invité (3x-ui VLESS ou protocole serveur).",
|
||||
"invite_inbound_hint": "Choisissez l'inbound VLESS une fois",
|
||||
"invite_inbound_loading": "Chargement…",
|
||||
"invite_inbound_empty": "Aucun inbound VLESS",
|
||||
"invite_inbound_need_xui": "Configurez 3x-ui d'abord",
|
||||
"invite_inbound_required": "Sélectionnez un inbound VLESS",
|
||||
"invite_sub_url_missing_title": "URL d'abonnement manquante",
|
||||
"invite_sub_url_missing_hint": "Dans Paramètres → 3x-ui, définissez l'URL de base /sub.",
|
||||
"xui_url_label": "URL 3x-ui",
|
||||
"xui_url_hint": "Inclure le schéma, le port et webBasePath si configuré",
|
||||
"xui_sub_url_label": "URL de base d'abonnement",
|
||||
"xui_sub_url_hint": "Base /sub publique sans slash final (ex: https://host:2096/sub).",
|
||||
"xui_api_token_label": "Jeton API (préféré)",
|
||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||
"xui_api_token_hint": "Si défini, le login/mot de passe est ignoré",
|
||||
"xui_username_label": "Identifiant 3x-ui",
|
||||
"xui_password_label": "Mot de passe 3x-ui",
|
||||
"xui_sync_hint": "Les clients 3x-ui deviennent des utilisateurs du panneau (liés par email)",
|
||||
"xui_inbound_label": "Inbound VLESS",
|
||||
"xui_inbound_auto": "Auto (premier inbound VLESS)",
|
||||
"xui_inbound_hint": "Utilisé pour créer des configs 3x-ui VLESS. Créez d'abord un inbound VLESS dans 3x-ui.",
|
||||
"xui_servers_title": "Serveurs 3x-ui",
|
||||
"xui_servers_hint": "Ajoutez plusieurs panneaux 3x-ui. À la création, choisissez le serveur par nom — son URL d'abonnement est utilisée.",
|
||||
"xui_servers_empty": "Aucun serveur 3x-ui. Cliquez sur Ajouter.",
|
||||
"xui_servers_manage_hint": "Les URL et bases d'abonnement se gèrent dans la section « Serveurs 3x-ui » ci-dessous.",
|
||||
"xui_server_add": "Ajouter un serveur",
|
||||
"xui_server_name_label": "Nom / description",
|
||||
"xui_server_name_hint": "Affiché dans les invitations (ex: US, NL).",
|
||||
"xui_server_select_label": "Serveur 3x-ui",
|
||||
"xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l'URL d'abonnement.",
|
||||
"xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?"
|
||||
}
|
||||
|
||||
+32
-2
@@ -193,7 +193,6 @@
|
||||
"guest_password_keep": "Оставьте пустым, чтобы сохранить текущий",
|
||||
"guest_clear_password": "Убрать защиту паролем",
|
||||
"guest_allow_create": "Разрешить гостям создавать новый конфиг",
|
||||
"guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя с использованием протокола выбранного сервера.",
|
||||
"guest_access_btn": "Войти как гость",
|
||||
"guest_access_login_hint": "Регистрация не нужна",
|
||||
"guest_title": "Гостевой доступ",
|
||||
@@ -519,5 +518,36 @@
|
||||
"restore_protocol_backup_confirm": "Восстановить этот бекап на сервер? Текущие файлы протокола будут перезаписаны, контейнер перезапустится.",
|
||||
"restoring_protocol_backup": "Восстановление… подождите",
|
||||
"restore_protocol_backup_done": "Бекап восстановлен",
|
||||
"refresh": "Обновить"
|
||||
"refresh": "Обновить",
|
||||
"guest_allow_create_hint": "Создаёт конфиг у гостевого пользователя (3x-ui VLESS или протокол выбранного сервера).",
|
||||
"invite_inbound_hint": "Список inbound загружается из выбранной панели 3x-ui. Конфиг и ссылку отдаёт только 3x-ui.",
|
||||
"invite_inbound_loading": "Загрузка inbound из 3x-ui…",
|
||||
"invite_inbound_empty": "VLESS inbound не найдены на этой панели 3x-ui",
|
||||
"invite_inbound_need_xui": "Сначала настройте 3x-ui в Настройках",
|
||||
"invite_inbound_required": "Выберите VLESS inbound из 3x-ui",
|
||||
"invite_sub_url_missing_title": "Не задан URL подписки",
|
||||
"invite_sub_url_missing_hint": "В Настройки → 3x-ui укажите «Базовый URL подписки», чтобы ссылки выдавали /sub/… конфиги.",
|
||||
"xui_url_label": "URL 3x-ui",
|
||||
"xui_url_hint": "Укажите схему, порт и webBasePath при наличии (пример: https://host:2053/secret)",
|
||||
"xui_sub_url_label": "Базовый URL подписки",
|
||||
"xui_sub_url_hint": "Публичный /sub без слэша в конце (пример: https://host:2096/sub). Конфиги по ссылкам выдаются как subscription URL.",
|
||||
"xui_api_token_label": "API Token (предпочтительно)",
|
||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||
"xui_api_token_hint": "Если задан, вход по логину/паролю не используется",
|
||||
"xui_username_label": "Логин 3x-ui",
|
||||
"xui_password_label": "Пароль 3x-ui",
|
||||
"xui_sync_hint": "Клиенты из inbounds 3x-ui будут создаваться, отключаться и удаляться как пользователи панели (связь по email)",
|
||||
"xui_inbound_label": "VLESS inbound",
|
||||
"xui_inbound_auto": "Авто (первый VLESS inbound)",
|
||||
"xui_inbound_hint": "Inbound подгружается из 3x-ui. Сайт только создаёт клиента и забирает готовую ссылку — без своей генерации конфига.",
|
||||
"xui_servers_title": "Серверы 3x-ui",
|
||||
"xui_servers_hint": "Добавьте панели 3x-ui. При создании выберите сервер и inbound из списка 3x-ui — ссылку вернёт панель.",
|
||||
"xui_servers_empty": "Серверов 3x-ui пока нет. Нажмите «Добавить сервер».",
|
||||
"xui_servers_manage_hint": "URL панелей и базовые URL подписок настраиваются в разделе «Серверы 3x-ui» ниже.",
|
||||
"xui_server_add": "Добавить сервер",
|
||||
"xui_server_name_label": "Название / описание",
|
||||
"xui_server_name_hint": "Так сервер будет отображаться в инвайтах и у пользователей (например: US, NL, Home).",
|
||||
"xui_server_select_label": "Сервер 3x-ui",
|
||||
"xui_server_select_hint": "Выберите панель 3x-ui, затем inbound из списка, который она отдаёт по API.",
|
||||
"xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?"
|
||||
}
|
||||
|
||||
+32
-2
@@ -184,7 +184,6 @@
|
||||
"guest_password_keep": "留空以保留当前密码",
|
||||
"guest_clear_password": "取消密码保护",
|
||||
"guest_allow_create": "允许访客创建新配置",
|
||||
"guest_allow_create_hint": "使用所选服务器协议为访客用户创建配置。",
|
||||
"guest_access_btn": "以访客继续",
|
||||
"guest_access_login_hint": "无需账号",
|
||||
"guest_title": "访客访问",
|
||||
@@ -463,5 +462,36 @@
|
||||
"backup_created": "Backup created",
|
||||
"no_backups": "No backups yet",
|
||||
"no_backups_desc": "Create the first backup for this protocol.",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"guest_allow_create_hint": "为访客用户创建配置(3x-ui VLESS 或所选服务器协议)。",
|
||||
"invite_inbound_hint": "一次性选择所需的 VLESS 入站",
|
||||
"invite_inbound_loading": "加载入站中…",
|
||||
"invite_inbound_empty": "未找到 VLESS 入站",
|
||||
"invite_inbound_need_xui": "请先在设置中配置 3x-ui",
|
||||
"invite_inbound_required": "请选择 VLESS 入站",
|
||||
"invite_sub_url_missing_title": "未设置订阅 URL",
|
||||
"invite_sub_url_missing_hint": "在 设置 → 3x-ui 中填写订阅基础 URL,以便发放 /sub/… 配置。",
|
||||
"xui_url_label": "3x-ui 地址",
|
||||
"xui_url_hint": "包含协议、端口和 webBasePath(如有)",
|
||||
"xui_sub_url_label": "订阅基础 URL",
|
||||
"xui_sub_url_hint": "公共 /sub 基础地址,末尾不要斜杠(如 https://host:2096/sub)。",
|
||||
"xui_api_token_label": "API Token(推荐)",
|
||||
"xui_api_token_placeholder": "Settings → Security → API Token",
|
||||
"xui_api_token_hint": "若已设置,将跳过用户名/密码登录",
|
||||
"xui_username_label": "3x-ui 用户名",
|
||||
"xui_password_label": "3x-ui 密码",
|
||||
"xui_sync_hint": "3x-ui 客户端将按 email 同步为面板用户",
|
||||
"xui_inbound_label": "VLESS 入站",
|
||||
"xui_inbound_auto": "自动(第一个 VLESS 入站)",
|
||||
"xui_inbound_hint": "创建 3x-ui VLESS 配置时使用。请先在 3x-ui 中创建 VLESS 入站。",
|
||||
"xui_servers_title": "3x-ui 服务器",
|
||||
"xui_servers_hint": "可添加多个 3x-ui 面板。创建配置时按名称选择服务器,并使用其订阅基础 URL。",
|
||||
"xui_servers_empty": "暂无 3x-ui 服务器。请点击添加。",
|
||||
"xui_servers_manage_hint": "面板与订阅地址在下方「3x-ui 服务器」中管理。",
|
||||
"xui_server_add": "添加服务器",
|
||||
"xui_server_name_label": "名称 / 描述",
|
||||
"xui_server_name_hint": "显示在邀请与用户表单中(如 US、NL)。",
|
||||
"xui_server_select_label": "3x-ui 服务器",
|
||||
"xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。",
|
||||
"xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user