Template
Release v1.6.0: multi-server 3x-ui with API inbounds.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user