mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-09-18 01:44:10 +02:00
cb0a5db3b5
# Conflicts: # translations/en.json # translations/fa.json # translations/fr.json # translations/ru.json # translations/zh.json
3915 lines
198 KiB
HTML
3915 lines
198 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title_extra %} — {{ server.name }}{% endblock %}
|
||
{% block page_class %}page page--server-detail{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<link rel="stylesheet" href="/static/vendor/codemirror/codemirror.min.css">
|
||
<link rel="stylesheet" href="/static/vendor/codemirror/theme/material-darker.min.css">
|
||
<style>
|
||
.marketplace-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||
gap: var(--space-md);
|
||
}
|
||
.marketplace-category {
|
||
margin-bottom: var(--space-lg);
|
||
}
|
||
.marketplace-category-title {
|
||
font-weight: 700;
|
||
margin-bottom: var(--space-sm);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-xs);
|
||
}
|
||
.marketplace-app-card {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--space-md);
|
||
background: var(--bg-secondary);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-sm);
|
||
}
|
||
.marketplace-app-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-sm);
|
||
}
|
||
.marketplace-app-title {
|
||
font-weight: 700;
|
||
}
|
||
.marketplace-app-desc {
|
||
color: var(--text-muted);
|
||
font-size: 0.88rem;
|
||
line-height: 1.35;
|
||
min-height: 2.4em;
|
||
}
|
||
.server-status-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-md);
|
||
flex-wrap: wrap;
|
||
}
|
||
.server-status-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
margin-left: auto;
|
||
flex-wrap: wrap;
|
||
}
|
||
.installed-app-hidden,
|
||
.promo-block.installed-app-hidden,
|
||
.protocol-cards > .promo-block {
|
||
display: none !important;
|
||
}
|
||
.protocol-card.installed-app-visible {
|
||
display: block !important;
|
||
}
|
||
/* While the status check runs, hide every protocol tile so the grid
|
||
does not flash the full marketplace list before real data arrives */
|
||
.protocol-cards.apps-pending .protocol-card,
|
||
.protocol-cards.apps-pending .promo-block {
|
||
display: none !important;
|
||
}
|
||
.apps-loading {
|
||
margin-bottom: var(--space-xl);
|
||
min-height: 140px;
|
||
border: 1px dashed var(--border-color);
|
||
border-radius: var(--radius-lg);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--space-sm);
|
||
color: var(--text-muted);
|
||
background: var(--bg-secondary);
|
||
}
|
||
.config-editor-wrap {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
background: #111827;
|
||
}
|
||
.config-editor-wrap .CodeMirror {
|
||
height: 50vh;
|
||
min-height: 360px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
}
|
||
.config-editor-wrap .CodeMirror-gutters {
|
||
border-right: 1px solid rgba(255,255,255,0.08);
|
||
}
|
||
.config-editor-fallback {
|
||
width: 100%;
|
||
height: 50vh;
|
||
min-height: 360px;
|
||
resize: vertical;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||
white-space: pre;
|
||
padding: var(--space-sm);
|
||
}
|
||
@media (max-width: 768px) {
|
||
.config-editor-wrap .CodeMirror,
|
||
.config-editor-fallback {
|
||
height: 220px;
|
||
min-height: 220px;
|
||
}
|
||
.clients-header {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: var(--space-md);
|
||
}
|
||
.config-actions {
|
||
flex-direction: column;
|
||
}
|
||
.config-actions .btn {
|
||
flex: none !important;
|
||
width: 100%;
|
||
}
|
||
.rename-editor .form-input {
|
||
width: 100% !important;
|
||
}
|
||
.rename-editor label {
|
||
flex: 1 1 100%;
|
||
}
|
||
}
|
||
.config-editor-hint {
|
||
margin-top: var(--space-xs);
|
||
color: var(--text-muted);
|
||
font-size: 0.78rem;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<a href="/" class="back-link page-back-link">{{ _('back_to_servers') }}</a>
|
||
|
||
<!-- Server Info Header -->
|
||
<div class="card page-header-card">
|
||
<div class="page-header page-header--card">
|
||
<div class="flex items-center gap-md">
|
||
<div class="server-icon" style="width:56px; height:56px; font-size:1.6rem;">🖥</div>
|
||
<div>
|
||
<h1 style="font-size:1.4rem; font-weight:700;">{{ server.name }}</h1>
|
||
<div class="font-mono text-muted text-sm">{{ server.host }}:{{ server.ssh_port }} • {{ server.username
|
||
}}</div>
|
||
</div>
|
||
</div>
|
||
<div class="page-header__actions">
|
||
<div class="flex gap-sm items-center" id="serverStats" style="display:none;"></div>
|
||
<button class="btn btn-secondary btn-sm" onclick="checkServer()" id="checkBtn">
|
||
<span id="checkBtnText">{{ _('server_check') }}</span>
|
||
<div class="spinner hidden" id="checkSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="serverStatus" class="mt-md hidden">
|
||
<div class="divider"></div>
|
||
<div class="server-status-row">
|
||
<div class="flex gap-md" style="flex-wrap:wrap" id="statusBadges"></div>
|
||
<div class="server-status-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openMarketplaceModal()" id="marketplaceBtn">
|
||
<span>🛒</span> {{ _('marketplace') }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="openManagementModal()" id="managementBtn">
|
||
<span>⚙️</span> {{ _('management') }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="openHostTuning()" id="hostTuningBtn">
|
||
<span>📊</span> {{ _('host_tuning') }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="openWgEasyImport()" id="wgeasyImportBtn">
|
||
<span>📥</span> {{ _('wgeasy_import') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Installed Applications Section -->
|
||
|
||
<div class="apps-loading" id="installedAppsLoading">
|
||
<div class="loading-spinner"></div>
|
||
<span>{{ _('checking_server') }}</span>
|
||
</div>
|
||
<div class="empty-state hidden" id="installedAppsEmpty" style="padding: var(--space-xl) 0; margin-bottom: var(--space-xl);">
|
||
<div class="empty-icon">🛒</div>
|
||
<div class="empty-title">{{ _('no_installed_apps') }}</div>
|
||
<div class="empty-desc">{{ _('no_installed_apps_desc') }}</div>
|
||
<button class="btn btn-primary btn-sm" onclick="openMarketplaceModal()" style="margin-top: var(--space-md);">
|
||
{{ _('open_marketplace') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="protocol-cards apps-pending" id="protocols-grid">
|
||
<!-- AWG3 Card (first - newest version).
|
||
Installed-protocol tiles are static cards toggled by
|
||
applyInstalledAppsVisibility(); a protocol without one is shown in the
|
||
marketplace but never gets a tile here. -->
|
||
<div class="card card-hover protocol-card protocol-awg" id="proto-awg3">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon"><img src="/static/img/amnezia.svg" alt="AmneziaWG"></div>
|
||
<div class="protocol-name">AmneziaWG 3.1 <span style="font-size:0.65rem; background:var(--accent); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">NEW</span></div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="awg3-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('awg3_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="awg3-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="awg3-info" class="hidden">
|
||
<div class="protocol-info" id="awg3-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="awg3-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('awg3')" id="awg3-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AWG2 Card -->
|
||
<div class="card card-hover protocol-card protocol-awg" id="proto-awg2">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon"><img src="/static/img/amnezia.svg" alt="AmneziaWG"></div>
|
||
<div class="protocol-name">AmneziaWG 2.0</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="awg2-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('awg_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="awg2-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="awg2-info" class="hidden">
|
||
<div class="protocol-info" id="awg2-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="awg2-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('awg2')" id="awg2-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AWG Card -->
|
||
<div class="card card-hover protocol-card protocol-awg" id="proto-awg">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon"><img src="/static/img/amnezia.svg" alt="AmneziaWG"></div>
|
||
<div class="protocol-name">AmneziaWG</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="awg-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('awg_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="awg-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="awg-info" class="hidden">
|
||
<div class="protocol-info" id="awg-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="awg-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('awg')" id="awg-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AWG Legacy Card -->
|
||
<div class="card card-hover protocol-card protocol-legacy" id="proto-awg-legacy">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon"><img src="/static/img/amnezia.svg" alt="AmneziaWG"></div>
|
||
<div class="protocol-name">AmneziaWG Legacy</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="awg-legacy-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('awg_legacy_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="awg-legacy-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="awg-legacy-info" class="hidden">
|
||
<div class="protocol-info" id="awg-legacy-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="awg-legacy-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('awg_legacy')" id="awg-legacy-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Xray Card -->
|
||
<div class="card card-hover protocol-card protocol-xray" id="proto-xray">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon"><img src="/static/img/xray.png" alt="Xray"></div>
|
||
<div class="protocol-name">Xray (VLESS-Reality)</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="xray-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('xray_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="xray-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="xray-info" class="hidden">
|
||
<div class="protocol-info" id="xray-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="xray-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('xray')" id="xray-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Telemt Card -->
|
||
<div class="card card-hover protocol-card protocol-telemt" id="proto-telemt">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon">✈</div>
|
||
<div class="protocol-name">Telemt (Telegram Proxy)</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="telemt-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('telemt_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="telemt-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="telemt-info" class="hidden">
|
||
<div class="protocol-info" id="telemt-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="telemt-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('telemt')" id="telemt-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WireGuard Card -->
|
||
<div class="card card-hover protocol-card protocol-wireguard" id="proto-wireguard">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon"><img src="/static/img/wireguard.webp" alt="WireGuard"></div>
|
||
<div class="protocol-name">WireGuard</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="wireguard-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('wireguard_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="wireguard-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="wireguard-info" class="hidden">
|
||
<div class="protocol-info" id="wireguard-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="wireguard-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('wireguard')" id="wireguard-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== AIVPN teaser (locked, full grid width) ===== -->
|
||
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
|
||
<div class="promo-orbs" aria-hidden="true">
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
</div>
|
||
<span class="promo-lock-badge">🔒 {{ _('coming_soon') }}</span>
|
||
<div class="promo-content">
|
||
<div class="promo-icon" aria-hidden="true">🧠</div>
|
||
<div class="promo-text">
|
||
<div class="promo-title">AIVPN</div>
|
||
<div class="promo-subtitle">{{ _('aivpn_subtitle') }}</div>
|
||
</div>
|
||
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
|
||
⭐ <span>{{ _('promo_star_cta') }}</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- DNS Card -->
|
||
<div class="card card-hover protocol-card protocol-dns" id="proto-dns">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon">🔍</div>
|
||
<div class="protocol-name">AmneziaDNS</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="dns-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('dns_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="dns-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="dns-info" class="hidden">
|
||
<div class="protocol-info" id="dns-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="dns-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('dns')" id="dns-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AdGuard Home Card -->
|
||
<div class="card card-hover protocol-card protocol-adguard" id="proto-adguard">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon">🛡️</div>
|
||
<div class="protocol-name">AdGuard Home</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="adguard-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('adguard_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="adguard-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="adguard-info" class="hidden">
|
||
<div class="protocol-info" id="adguard-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="adguard-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('adguard')" id="adguard-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SOCKS5 Card -->
|
||
<div class="card card-hover protocol-card protocol-socks5" id="proto-socks5">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon">🧦</div>
|
||
<div class="protocol-name">SOCKS5 Proxy</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="socks5-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('socks5_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="socks5-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="socks5-info" class="hidden">
|
||
<div class="protocol-info" id="socks5-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="socks5-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('socks5')" id="socks5-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- Exit Node Card -->
|
||
<div class="card card-hover protocol-card protocol-exit" id="proto-exit">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon">🚪</div>
|
||
<div class="protocol-name">Exit Node</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="exit-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('exit_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="exit-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="exit-info" class="hidden">
|
||
<div class="protocol-info" id="exit-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="exit-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('exit')" id="exit-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- NGINX Card -->
|
||
<div class="card card-hover protocol-card protocol-nginx" id="proto-nginx">
|
||
<div class="protocol-card-head">
|
||
<div class="protocol-icon">🌐</div>
|
||
<div class="protocol-name">NGINX</div>
|
||
</div>
|
||
<div class="flex gap-sm protocol-ctrl" id="nginx-ctrl" style="display:none!important;"></div>
|
||
<div class="protocol-desc">
|
||
{{ _('nginx_desc') }}
|
||
</div>
|
||
<div class="protocol-status" id="nginx-status">
|
||
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
|
||
</div>
|
||
<div id="nginx-info" class="hidden">
|
||
<div class="protocol-info" id="nginx-info-grid"></div>
|
||
</div>
|
||
<div class="flex gap-sm" id="nginx-actions">
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('nginx')" id="nginx-install-btn"
|
||
style="flex:1">
|
||
{{ _('install') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Reverse Proxy teaser (locked, full grid width) ===== -->
|
||
<div class="promo-block promo-revproxy" aria-disabled="true" role="article">
|
||
<div class="promo-grid-bg" aria-hidden="true"></div>
|
||
<div class="promo-orbs" aria-hidden="true">
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
</div>
|
||
<span class="promo-lock-badge">🔒 {{ _('coming_soon') }}</span>
|
||
<div class="promo-content">
|
||
<div class="promo-icon" aria-hidden="true">🛡️</div>
|
||
<div class="promo-text">
|
||
<div class="promo-title">{{ _('revproxy_title') }}</div>
|
||
<div class="promo-subtitle">{{ _('revproxy_subtitle') }}</div>
|
||
</div>
|
||
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
|
||
⭐ <span>{{ _('promo_star_cta') }}</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="promo-block promo-aivpn" aria-disabled="true" role="article">
|
||
<div class="promo-orbs" aria-hidden="true">
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
<span class="promo-orb"></span>
|
||
</div>
|
||
<span class="promo-lock-badge">🔒 Coming soon</span>
|
||
<div class="promo-content">
|
||
<div class="promo-icon" aria-hidden="true">🧠</div>
|
||
<div class="promo-text">
|
||
<div class="promo-title">AIVPN</div>
|
||
<div class="promo-subtitle">AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.</div>
|
||
</div>
|
||
<a class="promo-cta" href="https://github.com/PRVTPRO/Amnezia-Web-Panel" target="_blank" rel="noopener">
|
||
⭐ <span>Star us on GitHub</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Connections Section (renamed from Clients) -->
|
||
<div class="clients-section" id="connectionsSection" style="display:none;">
|
||
<div class="clients-header page-header page-header--secondary">
|
||
<h2 class="section-title page-header__title">
|
||
<span class="icon">🔗</span>
|
||
{{ _('connections') }}
|
||
</h2>
|
||
<div class="page-header__actions">
|
||
<select class="form-select" id="connProtoSelect" onchange="loadConnections()"
|
||
style="width:auto;">
|
||
<option value="awg">AmneziaWG</option>
|
||
<option value="awg2">AmneziaWG 2.0</option>
|
||
<option value="awg3">AmneziaWG 3.1</option>
|
||
<option value="awg_legacy">AWG Legacy</option>
|
||
<option value="xray">Xray</option>
|
||
<option value="telemt">Telemt</option>
|
||
<option value="wireguard">WireGuard</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
|
||
<span>+</span> {{ _('add') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="connectionsList">
|
||
<div class="loading-overlay" id="connectionsLoading" style="display:none;">
|
||
<div class="loading-spinner"></div>
|
||
<span>{{ _('loading_connections') }}</span>
|
||
</div>
|
||
<div id="connectionsEmpty" class="empty-state hidden" style="padding: var(--space-xl) 0;">
|
||
<div class="empty-icon">🔗</div>
|
||
<div class="empty-title">{{ _('no_connections') }}</div>
|
||
<div class="empty-desc">{{ _('no_connections_desc') }}</div>
|
||
</div>
|
||
<div class="clients-list" id="connectionsListInner"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Marketplace Modal ===== -->
|
||
<!-- wg-easy Import Modal -->
|
||
<div class="modal-backdrop" id="wgeasyImportModal">
|
||
<div class="modal" style="max-width: 640px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">📥 {{ _('wgeasy_import_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('wgeasyImportModal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('wgeasy_web_port') }}</label>
|
||
<input type="number" class="form-input" id="wgeasyWebPort" value="51821" min="1" max="65535">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('wgeasy_password') }}</label>
|
||
<input type="password" class="form-input" id="wgeasyPassword" autocomplete="off">
|
||
</div>
|
||
<div class="form-group" id="wgeasyUsernameGroup" style="display:none;">
|
||
<label class="form-label">{{ _('wgeasy_username') }}</label>
|
||
<input type="text" class="form-input" id="wgeasyUsername" value="admin" autocomplete="off">
|
||
</div>
|
||
<div id="wgeasyPreviewArea" class="hidden" style="margin-top: var(--space-md);">
|
||
<div class="text-muted text-sm" id="wgeasyPreviewInfo" style="margin-bottom: var(--space-sm);"></div>
|
||
<div style="max-height: 260px; overflow-y: auto; border: 1px solid var(--border-color); border-radius: var(--radius-md);">
|
||
<table style="width:100%; border-collapse: collapse; font-size: 0.85rem;">
|
||
<thead>
|
||
<tr style="position:sticky; top:0; background: var(--bg-secondary, #1f2937);">
|
||
<th style="padding:6px 10px; text-align:left;"><input type="checkbox" id="wgeasyCheckAll" checked></th>
|
||
<th style="padding:6px 10px; text-align:left;">{{ _('name') }}</th>
|
||
<th style="padding:6px 10px; text-align:left;">IP</th>
|
||
<th style="padding:6px 10px; text-align:left;">{{ _('status') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="wgeasyClientsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="text-muted text-sm" style="margin-top: var(--space-sm);">⚠️ {{ _('wgeasy_import_warning') }}</div>
|
||
</div>
|
||
<pre id="wgeasyLog" class="hidden" style="margin-top: var(--space-md); max-height: 220px; overflow-y: auto; background: var(--bg-secondary, #111827); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 10px 12px; font-size: 0.78rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word;"></pre>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('wgeasyImportModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="wgeasyPreview()" id="wgeasyPreviewBtn">{{ _('wgeasy_fetch') }}</button>
|
||
<button class="btn btn-primary hidden" onclick="wgeasyDoImport()" id="wgeasyImportGoBtn">{{ _('wgeasy_import_btn') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rename Instance Modal -->
|
||
<div class="modal-backdrop" id="renameProtoModal">
|
||
<div class="modal" style="max-width: 420px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">✏️ {{ _('rename_instance') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('renameProtoModal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('instance_name') }}</label>
|
||
<input type="text" class="form-input" id="renameProtoInput" maxlength="64">
|
||
<div class="text-muted text-sm" style="margin-top: var(--space-xs);">{{ _('rename_instance_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('renameProtoModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="saveRenameProtocol()" id="renameProtoSaveBtn">{{ _('save') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="marketplaceModal">
|
||
<div class="modal" style="max-width: 980px;">
|
||
<div class="modal-header">
|
||
<div>
|
||
<h2 class="modal-title">🛒 {{ _('marketplace') }}</h2>
|
||
<div class="text-muted text-sm">{{ _('marketplace_desc') }}</div>
|
||
</div>
|
||
<button class="modal-close" onclick="closeModal('marketplaceModal')">×</button>
|
||
</div>
|
||
<div id="marketplaceContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Management Modal ===== -->
|
||
<div class="modal-backdrop" id="managementModal">
|
||
<div class="modal" style="max-width: 520px;">
|
||
<div class="modal-header">
|
||
<div>
|
||
<h2 class="modal-title">⚙️ {{ _('server_management') }}</h2>
|
||
<div class="text-muted text-sm">{{ _('server_management_desc') }}</div>
|
||
</div>
|
||
<button class="modal-close" onclick="closeModal('managementModal')">×</button>
|
||
</div>
|
||
<div id="managementContent"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Install Protocol Modal ===== -->
|
||
<div class="modal-backdrop" id="installModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="installModalTitle">{{ _('install_protocol') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('installModal')">×</button>
|
||
</div>
|
||
|
||
<div id="installForm">
|
||
<div class="form-group">
|
||
<label class="form-label" id="installPortLabel">{{ _('port') }}</label>
|
||
<input class="form-input" type="number" id="installPort" value="55424" min="1" max="65535">
|
||
<div class="form-hint" id="installPortHint">{{ _('port_default_hint') }}</div>
|
||
</div>
|
||
|
||
<div id="awgOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('awg_mtu') }}</label>
|
||
<input class="form-input" type="number" id="installAwgMtu" value="1376" min="576" max="65535">
|
||
<div class="form-hint">{{ _('awg_mtu_hint') }}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">DNS 1</label>
|
||
<input class="form-input" type="text" id="installAwgDns1" value="1.1.1.1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">DNS 2</label>
|
||
<input class="form-input" type="text" id="installAwgDns2" value="1.0.0.1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">DNS IPv6</label>
|
||
<input class="form-input" type="text" id="installAwgDns6" value="2606:4700:4700::1111">
|
||
<div class="form-hint">{{ _('awg_dns6_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
<div id="installAwgJunk">
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex; justify-content:space-between; align-items:center;">
|
||
{{ _('awg_special_junk') }}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="resetSpecialJunkFields('installAwg')">{{ _('awg_special_junk_reset') }}</button>
|
||
</label>
|
||
<div class="form-hint" style="margin-bottom:var(--space-xs);">{{ _('awg_special_junk_hint') }}</div>
|
||
<input class="form-input" type="text" id="installAwgI1" spellcheck="false" placeholder="I1">
|
||
<input class="form-input mt-xs" type="text" id="installAwgI2" spellcheck="false" placeholder="I2">
|
||
<input class="form-input mt-xs" type="text" id="installAwgI3" spellcheck="false" placeholder="I3">
|
||
<input class="form-input mt-xs" type="text" id="installAwgI4" spellcheck="false" placeholder="I4">
|
||
<input class="form-input mt-xs" type="text" id="installAwgI5" spellcheck="false" placeholder="I5">
|
||
<div class="form-hint">{{ _('awg_special_junk_syntax') }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="telemtOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex; justify-content:space-between; align-items:center;">
|
||
{{ _('tls_emulation') }}
|
||
<label class="switch">
|
||
<input type="checkbox" id="installTelemtTls" checked
|
||
onchange="document.getElementById('telemtDomainGroup').style.display = this.checked ? 'block' : 'none'">
|
||
<span class="slider"></span>
|
||
</label>
|
||
</label>
|
||
</div>
|
||
<div class="form-group" id="telemtDomainGroup">
|
||
<label class="form-label">{{ _('tls_domain') }}</label>
|
||
<input class="form-input" type="text" id="installTelemtDomain" value="petrovich.ru">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('max_connections_limit') }}</label>
|
||
<input class="form-input" type="number" id="installTelemtMaxConns" value="0" min="0">
|
||
<div class="form-hint">{{ _('max_connections_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="exitOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('exit_subnet') }}</label>
|
||
<input class="form-input" type="text" id="installExitSubnet" value="10.9.0.0/24" spellcheck="false">
|
||
<div class="form-hint">{{ _('exit_subnet_hint') }}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" style="display:flex; align-items:center; gap:var(--space-sm);">
|
||
<input type="checkbox" id="installExitObfuscation"> {{ _('exit_obfuscation') }}
|
||
</label>
|
||
<div class="form-hint">{{ _('exit_obfuscation_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
<div id="socks5Options"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_username') }}</label>
|
||
<input class="form-input" type="text" id="installSocks5User" value="proxy_user">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_password') }}</label>
|
||
<input class="form-input" type="text" id="installSocks5Pass" placeholder="{{ _('socks5_password_placeholder') }}">
|
||
<div class="form-hint">{{ _('socks5_password_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="adguardOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_mode') }}</label>
|
||
<div style="display:flex; flex-direction:column; gap:var(--space-xs);">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="radio" name="adguardMode" value="sidebyside" checked>
|
||
<span><strong>{{ _('adguard_mode_sidebyside') }}</strong> — {{ _('adguard_mode_sidebyside_hint') }}</span>
|
||
</label>
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="radio" name="adguardMode" value="replace">
|
||
<span><strong>{{ _('adguard_mode_replace') }}</strong> — {{ _('adguard_mode_replace_hint') }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_web_port') }}</label>
|
||
<input class="form-input" type="number" id="installAdguardWebPort" value="3000" min="1" max="65535">
|
||
</div>
|
||
<div class="form-group" style="display:flex; align-items:flex-end; padding-bottom:8px;">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
|
||
<input type="checkbox" id="installAdguardExposeWeb">
|
||
<span>{{ _('adguard_expose_web') }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_dot_port') }}</label>
|
||
<input class="form-input" type="number" id="installAdguardDotPort" value="853" min="1" max="65535">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer; margin-top:4px;">
|
||
<input type="checkbox" id="installAdguardExposeDot">
|
||
<span>{{ _('adguard_expose') }}</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('adguard_doh_port') }}</label>
|
||
<input class="form-input" type="number" id="installAdguardDohPort" value="443" min="1" max="65535">
|
||
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer; margin-top:4px;">
|
||
<input type="checkbox" id="installAdguardExposeDoh">
|
||
<span>{{ _('adguard_expose') }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-hint">{{ _('adguard_install_hint') }}</div>
|
||
</div>
|
||
|
||
|
||
<div id="nginxOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('nginx_domain') }}</label>
|
||
<input class="form-input" type="text" id="installNginxDomain" placeholder="example.com" oninput="updateNginxDnsHint()">
|
||
<div class="form-hint" id="nginxDnsHint"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('nginx_email') }}</label>
|
||
<input class="form-input" type="email" id="installNginxEmail" placeholder="admin@example.com">
|
||
</div>
|
||
<div class="form-hint">{{ _('nginx_install_hint') }}</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('installModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="installProtocol()" id="installBtn">
|
||
<span id="installBtnText">{{ _('install') }}</span>
|
||
<div class="spinner hidden" id="installSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="installProgress" class="hidden">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" id="installProgressFill" style="width: 0%"></div>
|
||
</div>
|
||
<div class="log-output" id="installLog"></div>
|
||
<div class="mt-md" id="installDone" style="display:none;">
|
||
<button class="btn btn-primary" onclick="closeModal('installModal'); window.location.reload();"
|
||
style="width:100%">
|
||
{{ _('done') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== AmneziaWG Settings Modal ===== -->
|
||
<div class="modal-backdrop" id="awgSettingsModal">
|
||
<div class="modal" style="max-width:640px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="awgSettingsTitle">{{ _('awg_settings_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('awgSettingsModal')">×</button>
|
||
</div>
|
||
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('awg_settings_desc') }}</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('awg_mtu') }}</label>
|
||
<input class="form-input" type="number" id="awgSetMtu" min="576" max="65535">
|
||
<div class="form-hint">{{ _('awg_mtu_hint') }}</div>
|
||
<div class="form-hint hidden" id="awgSetMtuExitHint"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">DNS 1</label>
|
||
<input class="form-input" type="text" id="awgSetDns1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">DNS 2</label>
|
||
<input class="form-input" type="text" id="awgSetDns2">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">DNS IPv6</label>
|
||
<input class="form-input" type="text" id="awgSetDns6" placeholder="2606:4700:4700::1111">
|
||
<div class="form-hint">{{ _('awg_dns6_hint') }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="awgSetJunkGroup">
|
||
<label class="form-label" style="display:flex; justify-content:space-between; align-items:center;">
|
||
{{ _('awg_special_junk') }}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="resetSpecialJunkFields('awgSet')">{{ _('awg_special_junk_reset') }}</button>
|
||
</label>
|
||
<div class="form-hint" style="margin-bottom:var(--space-xs);">{{ _('awg_special_junk_hint') }}</div>
|
||
<input class="form-input" type="text" id="awgSetI1" spellcheck="false" placeholder="I1">
|
||
<input class="form-input mt-xs" type="text" id="awgSetI2" spellcheck="false" placeholder="I2">
|
||
<input class="form-input mt-xs" type="text" id="awgSetI3" spellcheck="false" placeholder="I3">
|
||
<input class="form-input mt-xs" type="text" id="awgSetI4" spellcheck="false" placeholder="I4">
|
||
<input class="form-input mt-xs" type="text" id="awgSetI5" spellcheck="false" placeholder="I5">
|
||
<div class="form-hint">{{ _('awg_special_junk_syntax') }}</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('awgSettingsModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="saveAwgSettings()" id="awgSaveBtn">
|
||
<span id="awgSaveBtnText">{{ _('save') }}</span>
|
||
<div class="spinner hidden" id="awgSaveSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Exit Node: peers ===== -->
|
||
<div class="modal-backdrop" id="exitPeersModal">
|
||
<div class="modal" style="max-width:760px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">{{ _('exit_peers_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('exitPeersModal')">×</button>
|
||
</div>
|
||
<div id="exitPeersInfo" class="form-hint" style="margin-bottom:var(--space-md);"></div>
|
||
<div id="exitPeersList"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="showExitPeers(currentExitPeersProto)">{{ _('refresh') }}</button>
|
||
<button class="btn btn-primary" onclick="closeModal('exitPeersModal')">{{ _('cancel') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Exit Node: link an AWG instance ===== -->
|
||
<div class="modal-backdrop" id="exitLinkModal">
|
||
<div class="modal" style="max-width:560px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="exitLinkTitle">{{ _('exit_link_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('exitLinkModal')">×</button>
|
||
</div>
|
||
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('exit_link_desc') }}</div>
|
||
<div id="exitLinkState" style="margin-bottom:var(--space-md);"></div>
|
||
<div id="exitLinkEgress"></div>
|
||
<div id="exitLinkWarnings"></div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('exit_link_select') }}</label>
|
||
<select class="form-input" id="exitLinkSelect"></select>
|
||
<div class="form-hint" id="exitLinkSelectHint"></div>
|
||
</div>
|
||
<div class="form-group hidden" id="exitDnsGroup">
|
||
<label class="form-label" style="display:flex; align-items:center; gap:var(--space-sm);">
|
||
<input type="checkbox" id="exitDnsViaExit" onchange="saveExitDns(this.checked)"> {{ _('exit_dns_via_exit') }}
|
||
</label>
|
||
<div class="form-hint">{{ _('exit_dns_via_exit_hint') }}</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('exitLinkModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-secondary hidden" onclick="checkExitEgress()" id="exitEgressBtn">{{ _('exit_egress_check') }}</button>
|
||
<button class="btn btn-secondary hidden" onclick="repairExitLink()" id="exitRepairBtn">{{ _('exit_link_repair') }}</button>
|
||
<button class="btn btn-danger hidden" onclick="removeExitLink()" id="exitUnlinkBtn">{{ _('exit_unlink_btn') }}</button>
|
||
<button class="btn btn-primary" onclick="saveExitLink(false)" id="exitLinkBtn">
|
||
<span id="exitLinkBtnText">{{ _('exit_link_btn') }}</span>
|
||
<div class="spinner hidden" id="exitLinkSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== SOCKS5 Settings Modal ===== -->
|
||
<div class="modal-backdrop" id="socks5SettingsModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">{{ _('socks5_settings_title') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('socks5SettingsModal')">×</button>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('port') }}</label>
|
||
<input class="form-input" type="number" id="socks5SetPort" min="1" max="65535">
|
||
<div class="form-hint">{{ _('socks5_port_change_hint') }}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_username') }}</label>
|
||
<input class="form-input" type="text" id="socks5SetUser">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('socks5_password') }}</label>
|
||
<input class="form-input" type="text" id="socks5SetPass">
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('socks5SettingsModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="saveSocks5Settings()" id="socks5SaveBtn">
|
||
<span id="socks5SaveBtnText">{{ _('save') }}</span>
|
||
<div class="spinner hidden" id="socks5SaveSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== NGINX Site Modal ===== -->
|
||
<div class="modal-backdrop" id="siteConfigModal">
|
||
<div class="modal" style="max-width:680px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="siteConfigModalTitle">{{ _('site_editor') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('siteConfigModal')">×</button>
|
||
</div>
|
||
<div class="config-display" style="margin-top:var(--space-md);">
|
||
<input type="hidden" id="siteConfigProto" value="nginx" />
|
||
<div class="config-editor-wrap">
|
||
<textarea class="form-input config-editor-fallback" id="siteConfigText"></textarea>
|
||
</div>
|
||
<div class="config-editor-hint">HTML editor • Ctrl+S / Cmd+S to save</div>
|
||
<div class="config-actions" style="margin-top:var(--space-sm);">
|
||
<button class="btn btn-secondary btn-sm"
|
||
onclick="copyToClipboard(getEditorValue('siteConfigText'))" style="flex:1">
|
||
{{ _('copy') }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm"
|
||
onclick="downloadFile(getEditorValue('siteConfigText'), 'index.html')"
|
||
style="flex:1">
|
||
{{ _('download') }}
|
||
</button>
|
||
<button class="btn btn-primary btn-sm" onclick="saveNginxSite()" style="flex:1"
|
||
id="saveSiteConfigBtn">
|
||
{{ _('save') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Protocol Backup Modal ===== -->
|
||
<div class="modal-backdrop" id="backupModal">
|
||
<div class="modal" style="max-width:720px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="backupModalTitle">{{ _('backups') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('backupModal')">×</button>
|
||
</div>
|
||
<input type="hidden" id="backupProto" value="" />
|
||
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('backup_desc') }}</div>
|
||
<div class="backup-toolbar">
|
||
<button class="btn btn-primary btn-sm" onclick="createProtocolBackup()" id="createBackupBtn">
|
||
{{ _('create_backup') }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="document.getElementById('backupUploadFile').click()" id="uploadBackupBtn">
|
||
{{ _('upload_backup') }}
|
||
</button>
|
||
<input type="file" id="backupUploadFile" class="hidden" accept=".tar.gz,application/gzip,application/x-gzip">
|
||
<button class="btn btn-secondary btn-sm" onclick="loadProtocolBackups()">{{ _('refresh') }}</button>
|
||
</div>
|
||
<div id="backupLoading" class="form-hint hidden">{{ _('loading') }}</div>
|
||
<div id="backupEmpty" class="empty-state hidden" style="padding:var(--space-xl);">
|
||
<div class="empty-icon">📦</div>
|
||
<div class="empty-title">{{ _('no_backups') }}</div>
|
||
<div class="empty-desc">{{ _('no_backups_desc') }}</div>
|
||
</div>
|
||
<div id="backupList" class="backup-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- ===== Add Connection Modal ===== -->
|
||
<div class="modal-backdrop" id="addConnectionModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="addConnectionModalTitle">{{ _('add_connection') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('addConnectionModal')">×</button>
|
||
</div>
|
||
|
||
<div class="form-group" id="connectionNameGroup">
|
||
<label class="form-label">{{ _('connection_name') }}</label>
|
||
<input class="form-input" type="text" id="connectionName"
|
||
placeholder="{{ _('connection_name_placeholder') }}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('assign_to_user') }}</label>
|
||
<select class="form-select" id="connectionUserId" data-searchable
|
||
data-search-placeholder="{{ _('select_search_placeholder') }}"
|
||
data-search-empty="{{ _('select_search_empty') }}">
|
||
<option value="">{{ _('no_assign') }}</option>
|
||
{% for u in users %}
|
||
<option value="{{ u.id }}">{{ u.username }} ({{ u.role }})</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
<div id="telemtConnOptions"
|
||
style="display:none; padding: var(--space-md); background: rgba(0,0,0,0.03); border-radius: var(--radius-md); margin-bottom: var(--space-md);">
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('telemt_secret_label') }}</label>
|
||
<input class="form-input" type="text" id="telemtSecret" placeholder="Optional (auto-generated if empty)">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('telemt_ad_tag_label') }}</label>
|
||
<input class="form-input" type="text" id="telemtAdTag" placeholder="Optional">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('telemt_max_conns_label') }}</label>
|
||
<input class="form-input" type="number" id="telemtMaxConns" placeholder="Optional (e.g. 1000)" min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('telemt_quota_label') }}</label>
|
||
<input class="form-input" type="number" id="telemtQuota" placeholder="{{ _('optional') }} (e.g. 1073741824)"
|
||
min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('telemt_max_ips_label') }}</label>
|
||
<input class="form-input" type="number" id="telemtMaxIps" placeholder="Optional (e.g. 16)" min="1">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">{{ _('telemt_expiry_label') }}</label>
|
||
<input class="form-input" type="text" id="telemtExpiry" placeholder="e.g. 2026-12-31T23:59:59Z">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('addConnectionModal')">{{ _('cancel') }}</button>
|
||
<button class="btn btn-primary" onclick="addConnection()" id="addConnBtn">
|
||
<span id="addConnBtnText">{{ _('create') }}</span>
|
||
<div class="spinner hidden" id="addConnSpinner"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ===== Connection Config Modal (with Tabs) ===== -->
|
||
<div class="modal-backdrop" id="configModal">
|
||
<div class="modal" style="max-width: 600px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('configModal')">×</button>
|
||
</div>
|
||
|
||
<!-- Tabs -->
|
||
<div class="config-tabs">
|
||
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
|
||
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
|
||
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
|
||
</div>
|
||
|
||
<!-- Config Panel -->
|
||
<div class="config-panel active" id="panel-conf">
|
||
<div class="config-display">
|
||
<textarea class="config-text" id="configText" spellcheck="false" style="width:100%;min-height:220px;resize:vertical;font-family:monospace;"></textarea>
|
||
<div class="config-actions">
|
||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('configText').value)" style="flex:1">
|
||
{{ _('copy') }}
|
||
</button>
|
||
<button class="btn btn-primary btn-sm" id="saveConnConfigBtn" onclick="saveConnectionConfig()" style="flex:1">
|
||
{{ _('save') }}
|
||
</button>
|
||
<button class="btn btn-primary btn-sm"
|
||
onclick="downloadFile(document.getElementById('configText').value, currentConfigName + '.conf')" style="flex:1">
|
||
{{ _('download_conf') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- VPN Key Panel -->
|
||
<div class="config-panel" id="panel-vpn">
|
||
<div class="vpn-link-box" id="vpnLinkText"></div>
|
||
<div class="config-actions" style="margin-top: var(--space-sm);">
|
||
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentVpnLink)" style="flex:1">
|
||
{{ _('copy_key') }}
|
||
</button>
|
||
</div>
|
||
<div class="form-hint" style="margin-top: var(--space-sm);">
|
||
{{ _('vpn_link_hint') }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- QR Code Panel -->
|
||
<div class="config-panel" id="panel-qr">
|
||
<div class="qr-container">
|
||
<div class="qr-format hidden" id="qrFormat">
|
||
<button type="button" class="qr-format-btn active" data-fmt="amnezia"
|
||
onclick="setQrFormat('amnezia')">{{ _('qr_format_amnezia') }}</button>
|
||
<button type="button" class="qr-format-btn" data-fmt="wireguard"
|
||
onclick="setQrFormat('wireguard')">{{ _('qr_format_wireguard') }}</button>
|
||
</div>
|
||
<div id="qrCode"></div>
|
||
<div class="qr-hint">{{ _('qr_hint') }}</div>
|
||
<div class="qr-hint" id="qrFormatHint"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- ===== Server Config Modal ===== -->
|
||
<div class="modal-backdrop" id="serverConfigModal">
|
||
<div class="modal" style="max-width:680px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="serverConfigModalTitle">{{ _('server_config') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('serverConfigModal')">×</button>
|
||
</div>
|
||
<div class="config-display" style="margin-top:var(--space-md);">
|
||
<!-- Hidden input to store current proto -->
|
||
<input type="hidden" id="serverConfigProto" value="" />
|
||
<div class="config-editor-wrap">
|
||
<textarea class="form-input config-editor-fallback" id="serverConfigText"></textarea>
|
||
</div>
|
||
<div class="config-editor-hint">Config editor • Ctrl+S / Cmd+S to save</div>
|
||
<div class="config-actions" style="margin-top:var(--space-sm);">
|
||
<button class="btn btn-secondary btn-sm"
|
||
onclick="copyToClipboard(getEditorValue('serverConfigText'))" style="flex:1">
|
||
{{ _('copy') }}
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm"
|
||
onclick="downloadFile(getEditorValue('serverConfigText'), serverConfigFilename)"
|
||
style="flex:1">
|
||
{{ _('download') }}
|
||
</button>
|
||
<button class="btn btn-primary btn-sm" onclick="saveServerConfig()" style="flex:1"
|
||
id="saveServerConfigBtn">
|
||
{{ _('save') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- ===== Host Tuning Modal ===== -->
|
||
<div class="modal-backdrop" id="hostTuningModal">
|
||
<div class="modal" style="max-width:520px;">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title">📊 {{ _('host_tuning') }}</h2>
|
||
<button class="modal-close" onclick="closeModal('hostTuningModal')">×</button>
|
||
</div>
|
||
<div id="hostTuningBody" style="margin-top:var(--space-md); font-size:12px; line-height:1.6;"></div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script src="/static/vendor/codemirror/codemirror.min.js"></script>
|
||
<script src="/static/vendor/codemirror/mode/nginx/nginx.min.js"></script>
|
||
<script src="/static/vendor/codemirror/mode/xml/xml.min.js"></script>
|
||
<script src="/static/vendor/codemirror/mode/javascript/javascript.min.js"></script>
|
||
<script src="/static/vendor/codemirror/mode/css/css.min.js"></script>
|
||
<script src="/static/vendor/codemirror/mode/properties/properties.min.js"></script>
|
||
<script src="/static/vendor/codemirror/mode/htmlmixed/htmlmixed.min.js"></script>
|
||
<script>
|
||
const SERVER_ID = {{ server_id }};
|
||
let SSH_COOLDOWN_BASE = {{ (server.get('ssh_cooldown_base') or 30) | tojson }};
|
||
document.body.dataset.serverUid = {{ (server.uid or '') | tojson }};
|
||
const SERVER_HOST = "{{ server.host }}";
|
||
const CUSTOM_PROTO_NAMES = {
|
||
{%- for proto, info in (server.get('protocols') or {}).items() if info.get('custom_name') %}
|
||
{{ proto|tojson }}: {{ info.get('custom_name')|tojson }},
|
||
{%- endfor %}
|
||
};
|
||
const MARKETPLACE_APPS = [
|
||
{ proto: 'awg3', category: 'protocols', icon: '🛡', title: 'AmneziaWG 3.1', descKey: 'awg3_desc', badge: 'NEW' },
|
||
{ proto: 'awg2', category: 'protocols', icon: '✨', title: 'AmneziaWG 2.0', descKey: 'awg_desc' },
|
||
{ proto: 'awg', category: 'protocols', icon: '🔮', title: 'AmneziaWG', descKey: 'awg_desc' },
|
||
{ proto: 'awg_legacy', category: 'protocols', icon: '📡', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
|
||
{ proto: 'xray', category: 'protocols', icon: '⚡', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
|
||
{ proto: 'telemt', category: 'protocols', icon: '✈', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
|
||
{ proto: 'wireguard', category: 'protocols', icon: '🔒', title: 'WireGuard', descKey: 'wireguard_desc' },
|
||
{ proto: 'dns', category: 'services', icon: '🔍', title: 'AmneziaDNS', descKey: 'dns_desc' },
|
||
{ proto: 'adguard', category: 'services', icon: '🛡️', title: 'AdGuard Home', descKey: 'adguard_desc' },
|
||
{ proto: 'socks5', category: 'services', icon: '🧦', title: 'SOCKS5 Proxy', descKey: 'socks5_desc' },
|
||
{ proto: 'exit', category: 'services', icon: '🚪', title: 'Exit Node', descKey: 'exit_desc', badge: 'NEW' },
|
||
{ proto: 'nginx', category: 'web_servers', icon: '🌐', title: 'NGINX', descKey: 'nginx_desc' },
|
||
];
|
||
const MARKETPLACE_CATEGORIES = [
|
||
{ id: 'protocols', icon: '🔐', titleKey: 'protocols' },
|
||
{ id: 'services', icon: '🛠', titleKey: 'services' },
|
||
{ id: 'web_servers', icon: '🌐', titleKey: 'web_servers' },
|
||
];
|
||
let currentInstallProto = 'awg';
|
||
let currentInstallAnother = false;
|
||
let currentConfig = '';
|
||
let currentVpnLink = '';
|
||
let currentConfigName = 'connection';
|
||
let currentConfigClientId = null;
|
||
let installedProtocols = {};
|
||
let currentProtocolStatus = {};
|
||
let installedAppsLoading = true;
|
||
let currentSocks5Proto = 'socks5';
|
||
let currentAwgProto = 'awg2';
|
||
|
||
const AWG_PROTOCOLS = ['awg', 'awg2', 'awg3', 'awg_legacy'];
|
||
const SPECIAL_JUNK_KEYS = ['i1', 'i2', 'i3', 'i4', 'i5'];
|
||
// Same value the desktop client ships as its default I1.
|
||
const AWG_DEFAULT_I1 = '<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>';
|
||
|
||
// The install form and the settings modal name their inputs differently
|
||
// (installAwgI1 vs awgSetI1), so callers pass the whole id prefix.
|
||
function specialJunkField(prefix, key) {
|
||
return document.getElementById(prefix + key.toUpperCase());
|
||
}
|
||
|
||
function resetSpecialJunkFields(prefix) {
|
||
SPECIAL_JUNK_KEYS.forEach((key, idx) => {
|
||
const el = specialJunkField(prefix, key);
|
||
if (el) el.value = idx === 0 ? AWG_DEFAULT_I1 : '';
|
||
});
|
||
}
|
||
|
||
function protoBase(proto) {
|
||
return String(proto || 'awg').split('__')[0];
|
||
}
|
||
|
||
function protoInstance(proto) {
|
||
const parts = String(proto || '').split('__');
|
||
return parts.length > 1 ? (parseInt(parts[1], 10) || 1) : 1;
|
||
}
|
||
|
||
function protoDomKey(proto) {
|
||
return String(proto || '').replace(/_/g, '-');
|
||
}
|
||
|
||
function isMultiInstanceBase(proto) {
|
||
return ['awg', 'awg2', 'awg3', 'awg_legacy', 'xray', 'telemt', 'socks5'].includes(protoBase(proto));
|
||
}
|
||
|
||
function nextSuggestedPort(base, fallback) {
|
||
const used = new Set();
|
||
Object.entries(currentProtocolStatus || {}).forEach(([proto, info]) => {
|
||
if (protoBase(proto) === protoBase(base) && info && info.port) {
|
||
const p = parseInt(info.port, 10);
|
||
if (!Number.isNaN(p)) used.add(p);
|
||
}
|
||
});
|
||
let port = parseInt(fallback, 10) || 55424;
|
||
while (used.has(port)) port += 1;
|
||
return String(port);
|
||
}
|
||
|
||
// ---- Used-port awareness for the install modal ----
|
||
// Fetches real listening ports from the server (not just same-family
|
||
// protocol records) so the suggested port is actually free.
|
||
const UDP_INSTALL_PROTOS = new Set(['awg', 'awg2', 'awg3', 'awg_legacy', 'wireguard', 'exit']);
|
||
const SKIP_PORT_CHECK = new Set(['dns', 'adguard']); // internal bindings
|
||
window.usedPorts = { ts: 0, udp: {}, tcp: {} };
|
||
|
||
async function refreshUsedPorts(force) {
|
||
if (!force && Date.now() - window.usedPorts.ts < 15000) return;
|
||
try {
|
||
const r = await fetch(`/api/servers/${SERVER_ID}/used-ports`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
window.usedPorts.udp = d.udp || {};
|
||
window.usedPorts.tcp = d.tcp || {};
|
||
window.usedPorts.ts = Date.now();
|
||
}
|
||
} catch (e) { /* keep stale data */ }
|
||
}
|
||
|
||
function installPortTransport(proto) {
|
||
return UDP_INSTALL_PROTOS.has(protoBase(proto)) ? 'udp' : 'tcp';
|
||
}
|
||
|
||
function portTakenBy(port, proto) {
|
||
const p = parseInt(port, 10);
|
||
if (Number.isNaN(p)) return null;
|
||
const table = window.usedPorts[installPortTransport(proto)] || {};
|
||
return table[String(p)] || null;
|
||
}
|
||
|
||
function firstFreePort(from, proto) {
|
||
let p = parseInt(from, 10) || 55424;
|
||
let guard = 0;
|
||
while (portTakenBy(p, proto) && guard++ < 1000) p += 1;
|
||
return String(p);
|
||
}
|
||
|
||
function validateInstallPort() {
|
||
const portInput = document.getElementById('installPort');
|
||
const portHint = document.getElementById('installPortHint');
|
||
const btn = document.getElementById('installBtn');
|
||
if (portInput.disabled || SKIP_PORT_CHECK.has(protoBase(currentInstallProto))) return;
|
||
const owner = portTakenBy(portInput.value, currentInstallProto);
|
||
if (owner) {
|
||
portInput.style.borderColor = '#ef4444';
|
||
portHint.style.color = '#ef4444';
|
||
portHint.textContent = _('port_in_use_by').replace('{port}', portInput.value).replace('{owner}', owner);
|
||
btn.disabled = true;
|
||
} else {
|
||
portInput.style.borderColor = '';
|
||
portHint.style.color = '';
|
||
if (portHint.dataset.orig) portHint.textContent = portHint.dataset.orig;
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function orderedProtocolEntries(protocols) {
|
||
const entries = Object.entries(protocols || {});
|
||
const order = new Map(MARKETPLACE_APPS.map((app, idx) => [app.proto, idx]));
|
||
entries.sort(([a], [b]) => {
|
||
const ao = order.has(protoBase(a)) ? order.get(protoBase(a)) : 999;
|
||
const bo = order.has(protoBase(b)) ? order.get(protoBase(b)) : 999;
|
||
if (ao !== bo) return ao - bo;
|
||
const ab = protoBase(a).localeCompare(protoBase(b));
|
||
if (ab !== 0) return ab;
|
||
return protoInstance(a) - protoInstance(b);
|
||
});
|
||
return entries;
|
||
}
|
||
|
||
function baseInstalled(proto) {
|
||
const base = protoBase(proto);
|
||
return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]);
|
||
}
|
||
|
||
function switchCategory(cat) {
|
||
if (cat === 'management') {
|
||
openManagementModal();
|
||
return;
|
||
}
|
||
applyInstalledAppsVisibility();
|
||
}
|
||
|
||
function renderManagementModal() {
|
||
const el = document.getElementById('managementContent');
|
||
if (!el) return;
|
||
el.innerHTML = `
|
||
<div class="flex" style="flex-direction: column; gap: var(--space-sm);">
|
||
<button class="btn btn-secondary" onclick="closeModal('managementModal'); checkServer();" style="justify-content: flex-start; padding: 12px 20px;">
|
||
<span style="font-size: 1.2rem; margin-right: 8px;">🔄</span> ${_('check_server_services')}
|
||
</button>
|
||
<button class="btn btn-warning" onclick="closeModal('managementModal'); rebootServer();" style="justify-content: flex-start; padding: 12px 20px;">
|
||
<span style="font-size: 1.2rem; margin-right: 8px;">🔁</span> ${_('reboot_server')}
|
||
</button>
|
||
<button class="btn btn-danger" onclick="closeModal('managementModal'); clearServer();" style="justify-content: flex-start; padding: 12px 20px;">
|
||
<span style="font-size: 1.2rem; margin-right: 8px;">🧹</span> ${_('clear_server')}
|
||
</button>
|
||
<button class="btn btn-danger" onclick="closeModal('managementModal'); deleteServer();" style="justify-content: flex-start; padding: 12px 20px;">
|
||
<span class="btn-text-with-icon">${ICON_TRASH}</span> ${_('remove_server')}
|
||
</button>
|
||
<div style="margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--border);">
|
||
<label for="sshCooldownInput" style="display:block; font-size:0.85rem; margin-bottom:6px;">${_('ssh_cooldown_title')}</label>
|
||
<div class="flex" style="gap:8px;">
|
||
<input class="form-input" id="sshCooldownInput" type="number" min="5" max="300" step="1" value="${SSH_COOLDOWN_BASE}" style="max-width:120px;">
|
||
<button class="btn btn-secondary btn-sm" onclick="saveSshCooldown()" id="sshCooldownSaveBtn">💾</button>
|
||
</div>
|
||
<div class="text-muted" style="font-size:0.78rem; margin-top:6px;">${_('ssh_cooldown_hint')}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function saveSshCooldown() {
|
||
const input = document.getElementById('sshCooldownInput');
|
||
const btn = document.getElementById('sshCooldownSaveBtn');
|
||
if (!input) return;
|
||
const seconds = parseFloat(input.value);
|
||
if (!Number.isFinite(seconds) || seconds < 5 || seconds > 300) {
|
||
showToast(_('ssh_cooldown_title') + ': 5–300', 'error');
|
||
return;
|
||
}
|
||
if (btn) { btn.disabled = true; btn.innerHTML = '<div class="spinner"></div>'; }
|
||
try {
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/ssh_cooldown`, 'POST', { seconds });
|
||
SSH_COOLDOWN_BASE = res.ssh_cooldown_base;
|
||
input.value = res.ssh_cooldown_base;
|
||
showToast('✓', 'success');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
if (btn) { btn.disabled = false; btn.textContent = '💾'; }
|
||
}
|
||
}
|
||
|
||
function openManagementModal() {
|
||
renderManagementModal();
|
||
openModal('managementModal');
|
||
}
|
||
|
||
function isAppInstalled(info) {
|
||
return !!(info && (info.container_running || info.container_exists));
|
||
}
|
||
|
||
function marketplaceAppStatus(proto) {
|
||
const installed = baseInstalled(proto);
|
||
if (installed) {
|
||
return `<span class="badge badge-success"><span class="badge-dot"></span> ${_('installed')}</span>`;
|
||
}
|
||
return `<span class="badge badge-warn"><span class="badge-dot"></span> ${_('not_installed')}</span>`;
|
||
}
|
||
|
||
function renderMarketplace() {
|
||
const el = document.getElementById('marketplaceContent');
|
||
if (!el) return;
|
||
el.innerHTML = MARKETPLACE_CATEGORIES.map(cat => {
|
||
const apps = MARKETPLACE_APPS.filter(app => app.category === cat.id);
|
||
return `
|
||
<div class="marketplace-category">
|
||
<div class="marketplace-category-title"><span>${cat.icon}</span> ${_(cat.titleKey)}</div>
|
||
<div class="marketplace-grid">
|
||
${apps.map(app => {
|
||
const installed = baseInstalled(app.proto);
|
||
const canInstallAnother = installed && isMultiInstanceBase(app.proto);
|
||
return `
|
||
<div class="marketplace-app-card">
|
||
<div class="marketplace-app-top">
|
||
<div class="flex items-center gap-sm">
|
||
<div class="protocol-icon" style="width:38px;height:38px;font-size:1.2rem;">${app.icon}</div>
|
||
<div>
|
||
<div class="marketplace-app-title">${app.title} ${app.badge ? `<span class="badge badge-info" style="font-size:0.65rem;">${app.badge}</span>` : ''}</div>
|
||
${marketplaceAppStatus(app.proto)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="marketplace-app-desc">${_(app.descKey)}</div>
|
||
<button class="btn ${installed && !canInstallAnother ? 'btn-secondary' : 'btn-primary'} btn-sm" ${installed && !canInstallAnother ? 'disabled' : `onclick="installFromMarketplace('${app.proto}', ${canInstallAnother ? 'true' : 'false'})"`}>
|
||
${canInstallAnother ? _('install_another') : (installed ? _('installed') : _('install'))}
|
||
</button>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function openMarketplaceModal() {
|
||
renderMarketplace();
|
||
openModal('marketplaceModal');
|
||
}
|
||
|
||
function installFromMarketplace(proto, installAnother = false) {
|
||
closeModal('marketplaceModal');
|
||
openInstallModal(proto, installAnother);
|
||
}
|
||
|
||
function setInstalledAppsLoading(isLoading) {
|
||
installedAppsLoading = !!isLoading;
|
||
const loading = document.getElementById('installedAppsLoading');
|
||
const empty = document.getElementById('installedAppsEmpty');
|
||
const grid = document.getElementById('protocols-grid');
|
||
if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
|
||
if (empty && installedAppsLoading) empty.classList.add('hidden');
|
||
if (grid) grid.classList.toggle('apps-pending', installedAppsLoading);
|
||
}
|
||
|
||
function applyInstalledAppsVisibility() {
|
||
let installedCount = 0;
|
||
const grid = document.getElementById('protocols-grid');
|
||
const order = new Map(MARKETPLACE_APPS.map((app, idx) => [app.proto, idx]));
|
||
|
||
Object.entries(currentProtocolStatus || {}).forEach(([proto, info]) => {
|
||
if (isAppInstalled(info)) installedCount++;
|
||
});
|
||
|
||
// Hide every static card first. This prevents stale service cards
|
||
// (SOCKS5/DNS/AdGuard/etc.) from staying visible when they are not
|
||
// reported as installed by the backend.
|
||
document.querySelectorAll('.protocol-card[id^="proto-"]').forEach(card => {
|
||
card.classList.add('installed-app-hidden');
|
||
card.classList.remove('installed-app-visible');
|
||
});
|
||
|
||
MARKETPLACE_APPS.forEach(app => {
|
||
const card = document.getElementById(`proto-${protoDomKey(app.proto)}`);
|
||
const installed = baseInstalled(app.proto);
|
||
if (card) {
|
||
card.classList.toggle('installed-app-hidden', !installed);
|
||
card.classList.toggle('installed-app-visible', installed);
|
||
}
|
||
});
|
||
|
||
orderedProtocolEntries(currentProtocolStatus || {}).forEach(([proto, info]) => {
|
||
if (protoInstance(proto) > 1) {
|
||
ensureProtocolCard(proto);
|
||
const card = document.getElementById(`proto-${protoDomKey(proto)}`);
|
||
const installed = isAppInstalled(info);
|
||
if (card) {
|
||
card.classList.toggle('installed-app-hidden', !installed);
|
||
card.classList.toggle('installed-app-visible', installed);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Keep all installed cards in one compact ordered grid. Without this,
|
||
// services that were originally in another section (NGINX/SOCKS5/DNS/
|
||
// AdGuard) appear with a visual offset below VPN protocols.
|
||
if (grid) {
|
||
Array.from(document.querySelectorAll('.protocol-card.installed-app-visible[id^="proto-"]'))
|
||
.sort((a, b) => {
|
||
const ap = (a.id || '').replace(/^proto-/, '').replace(/-/g, '_');
|
||
const bp = (b.id || '').replace(/^proto-/, '').replace(/-/g, '_');
|
||
const ao = order.has(protoBase(ap)) ? order.get(protoBase(ap)) : 999;
|
||
const bo = order.has(protoBase(bp)) ? order.get(protoBase(bp)) : 999;
|
||
if (ao !== bo) return ao - bo;
|
||
return protoInstance(ap) - protoInstance(bp);
|
||
})
|
||
.forEach(card => grid.appendChild(card));
|
||
}
|
||
|
||
const empty = document.getElementById('installedAppsEmpty');
|
||
if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0);
|
||
const loading = document.getElementById('installedAppsLoading');
|
||
if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
|
||
if (grid) grid.classList.toggle('apps-pending', installedAppsLoading);
|
||
applyCustomTitles();
|
||
}
|
||
|
||
function getProtoTitle(proto) {
|
||
if (CUSTOM_PROTO_NAMES[proto]) return CUSTOM_PROTO_NAMES[proto];
|
||
const base = protoBase(proto);
|
||
const idx = protoInstance(proto);
|
||
let title;
|
||
switch (base) {
|
||
case 'awg': title = 'AmneziaWG'; break;
|
||
case 'awg2': title = 'AmneziaWG 2.0'; break;
|
||
case 'awg3': title = 'AmneziaWG 3.1'; break;
|
||
case 'awg_legacy': title = 'AmneziaWG Legacy'; break;
|
||
case 'xray': title = 'Xray'; break;
|
||
case 'telemt': title = 'Telemt'; break;
|
||
case 'wireguard': title = 'WireGuard'; break;
|
||
case 'dns': title = 'AmneziaDNS'; break;
|
||
case 'socks5': title = 'SOCKS5 Proxy'; break;
|
||
case 'adguard': title = 'AdGuard Home'; break;
|
||
case 'nginx': title = 'NGINX'; break;
|
||
case 'exit': title = 'Exit Node'; break;
|
||
default: title = proto;
|
||
}
|
||
return idx > 1 ? `${title} #${idx}` : title;
|
||
}
|
||
|
||
// Apply custom instance names to the visible card titles (static cards
|
||
// keep hardcoded names in HTML, so they need an explicit override).
|
||
function applyCustomTitles() {
|
||
Object.entries(CUSTOM_PROTO_NAMES).forEach(([proto, name]) => {
|
||
const card = document.getElementById(`proto-${protoDomKey(proto)}`);
|
||
const nameEl = card && card.querySelector('.protocol-name');
|
||
if (nameEl) {
|
||
const pencil = nameEl.querySelector('.proto-rename');
|
||
nameEl.textContent = name;
|
||
if (pencil) nameEl.appendChild(pencil);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== Server Stats ==========
|
||
async function loadServerStats() {
|
||
try {
|
||
const stats = await apiCall(`/api/servers/${SERVER_ID}/stats`, 'POST');
|
||
const el = document.getElementById('serverStats');
|
||
el.style.display = '';
|
||
el.innerHTML = `
|
||
<span class="stat-pill" title="CPU">⚡ ${stats.cpu || 0}%</span>
|
||
<span class="stat-pill" title="RAM: ${formatBytes(stats.ram_used)} / ${formatBytes(stats.ram_total)}">🧠 ${stats.ram_percent || 0}%</span>
|
||
<span class="stat-pill" title="Disk: ${formatBytes(stats.disk_used)} / ${formatBytes(stats.disk_total)}">💾 ${stats.disk_percent || 0}%</span>
|
||
<span class="stat-pill" title="Network">🌐 ↓${formatBytes(stats.net_rx)} ↑${formatBytes(stats.net_tx)}</span>
|
||
`;
|
||
} catch (err) {
|
||
console.warn('Stats load failed:', err);
|
||
}
|
||
}
|
||
|
||
// ========== Server Check ==========
|
||
async function checkServer() {
|
||
setInstalledAppsLoading(true);
|
||
const btn = document.getElementById('checkBtn');
|
||
const text = document.getElementById('checkBtnText');
|
||
const spinner = document.getElementById('checkSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('server_checking');
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/check`, 'POST');
|
||
const statusDiv = document.getElementById('serverStatus');
|
||
const badges = document.getElementById('statusBadges');
|
||
statusDiv.classList.remove('hidden');
|
||
let html = '';
|
||
if (data.connection === 'ok') {
|
||
html += `<span class="badge badge-success"><span class="badge-dot"></span> ${_('ssh_ok')}</span>`;
|
||
}
|
||
if (data.docker_installed) {
|
||
html += `<span class="badge badge-success"><span class="badge-dot"></span> ${_('docker_ok')}</span>`;
|
||
} else {
|
||
html += `<span class="badge badge-warn"><span class="badge-dot"></span> ${_('docker_not_installed')}</span>`;
|
||
}
|
||
badges.innerHTML = html;
|
||
if (data.protocols) {
|
||
const protoSelect = document.getElementById('connProtoSelect');
|
||
const currentVal = protoSelect.value;
|
||
protoSelect.innerHTML = '';
|
||
let firstVal = null;
|
||
|
||
for (const [proto, info] of orderedProtocolEntries(data.protocols)) {
|
||
updateProtocolCard(proto, info);
|
||
const isVPN = ['awg', 'awg2', 'awg3', 'awg_legacy', 'xray', 'telemt', 'wireguard'].includes(protoBase(proto));
|
||
if (info.container_running && isVPN) {
|
||
const opt = document.createElement('option');
|
||
opt.value = proto;
|
||
opt.textContent = getProtoTitle(proto);
|
||
protoSelect.appendChild(opt);
|
||
if (!firstVal) firstVal = proto;
|
||
}
|
||
}
|
||
|
||
if (protoSelect.innerHTML === '') {
|
||
const opt = document.createElement('option');
|
||
opt.textContent = _('no_protocols');
|
||
opt.disabled = true;
|
||
protoSelect.appendChild(opt);
|
||
hideConnectionsSection();
|
||
} else {
|
||
if (data.protocols[currentVal] && data.protocols[currentVal].container_running) {
|
||
protoSelect.value = currentVal;
|
||
} else if (firstVal) {
|
||
protoSelect.value = firstVal;
|
||
}
|
||
loadConnections();
|
||
}
|
||
}
|
||
updateStatusCounts(data.protocols || {});
|
||
showToast(_('server_check_complete'), 'success');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
const statusDiv = document.getElementById('serverStatus');
|
||
const badges = document.getElementById('statusBadges');
|
||
statusDiv.classList.remove('hidden');
|
||
badges.innerHTML = `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('server_connection_error')}</span>`;
|
||
} finally {
|
||
setInstalledAppsLoading(false);
|
||
applyInstalledAppsVisibility();
|
||
btn.disabled = false;
|
||
text.textContent = _('server_check');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function updateStatusCounts(protocols) {
|
||
currentProtocolStatus = protocols || {};
|
||
setInstalledAppsLoading(false);
|
||
installedProtocols = {};
|
||
MARKETPLACE_APPS.forEach(app => {
|
||
installedProtocols[app.proto] = isAppInstalled(currentProtocolStatus[app.proto]);
|
||
});
|
||
Object.entries(currentProtocolStatus || {}).forEach(([proto, info]) => {
|
||
installedProtocols[proto] = isAppInstalled(info);
|
||
const base = protoBase(proto);
|
||
if (isAppInstalled(info)) installedProtocols[base] = true;
|
||
});
|
||
applyInstalledAppsVisibility();
|
||
renderMarketplace();
|
||
}
|
||
|
||
let serverConfigFilename = 'server.conf';
|
||
|
||
const configEditors = {};
|
||
|
||
function editorModeFor(kind, proto) {
|
||
if (kind === 'site') return 'htmlmixed';
|
||
const base = protoBase(proto);
|
||
if (base === 'nginx') return 'nginx';
|
||
if (base === 'xray') return { name: 'javascript', json: true };
|
||
return 'properties';
|
||
}
|
||
|
||
function ensureConfigEditor(textareaId, options = {}) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return null;
|
||
if (!window.CodeMirror) return null;
|
||
if (configEditors[textareaId]) return configEditors[textareaId];
|
||
|
||
const editor = CodeMirror.fromTextArea(textarea, {
|
||
mode: options.mode || 'nginx',
|
||
theme: 'material-darker',
|
||
lineNumbers: true,
|
||
lineWrapping: true,
|
||
indentUnit: 4,
|
||
tabSize: 4,
|
||
indentWithTabs: false,
|
||
smartIndent: true,
|
||
autofocus: false,
|
||
extraKeys: {
|
||
'Ctrl-S': function () {
|
||
if (textareaId === 'siteConfigText') saveNginxSite();
|
||
else saveServerConfig();
|
||
},
|
||
'Cmd-S': function () {
|
||
if (textareaId === 'siteConfigText') saveNginxSite();
|
||
else saveServerConfig();
|
||
},
|
||
'Tab': function (cm) {
|
||
if (cm.somethingSelected()) cm.indentSelection('add');
|
||
else cm.replaceSelection(' ', 'end');
|
||
}
|
||
}
|
||
});
|
||
configEditors[textareaId] = editor;
|
||
return editor;
|
||
}
|
||
|
||
function setEditorValue(textareaId, value, options = {}) {
|
||
const textarea = document.getElementById(textareaId);
|
||
if (!textarea) return;
|
||
textarea.value = value || '';
|
||
const editor = ensureConfigEditor(textareaId, options);
|
||
if (editor) {
|
||
if (options.mode) editor.setOption('mode', options.mode);
|
||
editor.setValue(value || '');
|
||
setTimeout(() => editor.refresh(), 80);
|
||
}
|
||
}
|
||
|
||
function getEditorValue(textareaId) {
|
||
const editor = configEditors[textareaId];
|
||
if (editor) return editor.getValue();
|
||
const textarea = document.getElementById(textareaId);
|
||
return textarea ? textarea.value : '';
|
||
}
|
||
|
||
function refreshConfigEditor(textareaId) {
|
||
const editor = configEditors[textareaId];
|
||
if (editor) setTimeout(() => editor.refresh(), 80);
|
||
}
|
||
|
||
// Services that don't have per-user "connections" — they expose a single
|
||
// shared endpoint (DNS/proxy/admin UI) rather than per-client configs.
|
||
const SERVICE_PROTOS = ['dns', 'socks5', 'adguard', 'nginx', 'exit'];
|
||
|
||
function buildServiceInfoGrid(proto, info) {
|
||
// Custom info-grid markup for non-VPN services. Returns HTML for the grid body.
|
||
const base = protoBase(proto);
|
||
if (base === 'dns') {
|
||
return `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">172.29.172.254:53 (Internal)</span></div>`;
|
||
}
|
||
if (base === 'socks5') {
|
||
const user = info.username ? info.username : '—';
|
||
return `
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/TCP</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('socks5_username')}</span><span class="protocol-info-value">${user}</span></div>
|
||
`;
|
||
}
|
||
if (base === 'adguard') {
|
||
const mode = info.mode === 'replace' ? _('adguard_mode_replace') : _('adguard_mode_sidebyside');
|
||
const ip = info.internal_ip || '—';
|
||
const webPort = info.web_port || 3000;
|
||
const url = info.web_exposed
|
||
? `http://${SERVER_HOST}:${webPort}`
|
||
: `http://${ip}:${webPort}`;
|
||
return `
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('adguard_mode')}</span><span class="protocol-info-value">${mode}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('adguard_internal_ip')}</span><span class="protocol-info-value">${ip}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('adguard_web_ui')}</span><span class="protocol-info-value"><a href="${url}" target="_blank" rel="noopener">${url}</a></span></div>
|
||
`;
|
||
}
|
||
if (base === 'exit') {
|
||
const key = info.public_key ? `<span title="${escapeHtml(info.public_key)}">${escapeHtml(info.public_key.slice(0, 12))}…</span>` : '—';
|
||
return `
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port || '—'}/UDP</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_subnet')}</span><span class="protocol-info-value">${escapeHtml(info.subnet || '—')}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_peers')}</span><span class="protocol-info-value">${info.peers_count ?? 0}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_obfuscation_short')}</span><span class="protocol-info-value">${info.obfuscation ? _('on') : _('off')}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_public_key')}</span><span class="protocol-info-value">${key}</span></div>
|
||
`;
|
||
}
|
||
if (base === 'nginx') {
|
||
const domain = info.domain || '—';
|
||
const port = info.port || 443;
|
||
const url = info.site_url || (domain !== '—' ? (port == 443 ? `https://${domain}` : `https://${domain}:${port}`) : '—');
|
||
return `
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('nginx_domain')}</span><span class="protocol-info-value">${domain}</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${port}/TCP</span></div>
|
||
<div class="protocol-info-item"><span class="protocol-info-label">${_('site')}</span><span class="protocol-info-value">${url !== '—' ? `<a href="${url}" target="_blank" rel="noopener">${url}</a>` : '—'}</span></div>
|
||
`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function replaceIdsRecursive(node, oldKey, newKey) {
|
||
if (node.id) node.id = node.id.replace(oldKey, newKey);
|
||
if (node.getAttribute && node.getAttribute('onclick')) {
|
||
node.setAttribute('onclick', node.getAttribute('onclick').replace(new RegExp(oldKey.replace(/-/g, '[-_]'), 'g'), newKey));
|
||
}
|
||
Array.from(node.children || []).forEach(child => replaceIdsRecursive(child, oldKey, newKey));
|
||
}
|
||
|
||
function ensureProtocolCard(proto) {
|
||
if (protoInstance(proto) <= 1) return;
|
||
const key = protoDomKey(proto);
|
||
if (document.getElementById(`proto-${key}`)) return;
|
||
const base = protoBase(proto);
|
||
const baseKey = protoDomKey(base);
|
||
const baseCard = document.getElementById(`proto-${baseKey}`);
|
||
if (!baseCard) return;
|
||
const clone = baseCard.cloneNode(true);
|
||
replaceIdsRecursive(clone, baseKey, key);
|
||
clone.id = `proto-${key}`;
|
||
const nameEl = clone.querySelector('.protocol-name');
|
||
if (nameEl) nameEl.textContent = getProtoTitle(proto);
|
||
const installBtn = clone.querySelector(`[id$="-install-btn"]`);
|
||
if (installBtn) installBtn.remove();
|
||
let insertAfter = baseCard;
|
||
const sameBaseCards = Array.from(document.querySelectorAll('.protocol-card')).filter(card => {
|
||
const cardProto = (card.id || '').replace(/^proto-/, '').replace(/-/g, '_').replace(/__(\d+)$/, '__$1');
|
||
return card.id && card.id.startsWith(`proto-${baseKey}`);
|
||
});
|
||
sameBaseCards.forEach(card => { insertAfter = card; });
|
||
insertAfter.insertAdjacentElement('afterend', clone);
|
||
}
|
||
|
||
function updateProtocolCard(proto, info) {
|
||
ensureProtocolCard(proto);
|
||
const protoKey = protoDomKey(proto);
|
||
const statusEl = document.getElementById(`${protoKey}-status`);
|
||
const infoEl = document.getElementById(`${protoKey}-info`);
|
||
const infoGrid = document.getElementById(`${protoKey}-info-grid`);
|
||
const actionsEl = document.getElementById(`${protoKey}-actions`);
|
||
const ctrlEl = document.getElementById(`${protoKey}-ctrl`);
|
||
|
||
// Some service cards may not exist in the template (e.g. on older builds);
|
||
// skip quietly rather than throwing so a partial UI still renders.
|
||
if (!statusEl || !actionsEl) return;
|
||
|
||
const isService = SERVICE_PROTOS.includes(protoBase(proto));
|
||
|
||
if (info.container_running) {
|
||
installedProtocols[proto] = true;
|
||
statusEl.innerHTML = `<span class="badge badge-success"><span class="badge-dot"></span> ${_('run')}</span>`;
|
||
if (info.port || info.clients_count !== undefined || isService) {
|
||
infoEl.classList.remove('hidden');
|
||
let grid = '';
|
||
if (isService) {
|
||
grid = buildServiceInfoGrid(proto, info);
|
||
} else if (info.port) {
|
||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
|
||
}
|
||
if (!isService && info.clients_count !== undefined) {
|
||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('connections')}</span><span class="protocol-info-value">${info.clients_count}</span></div>`;
|
||
}
|
||
if (!isService && info.exit_link) {
|
||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_link_via')}</span><span class="protocol-info-value">${escapeHtml(info.exit_link.exit_name || '')} ${exitStatusBadge(info.exit_link_status, info.exit_link)}</span></div>`;
|
||
}
|
||
infoGrid.innerHTML = grid;
|
||
}
|
||
if (protoBase(proto) === 'socks5') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="openSocks5Settings('${proto}')" style="flex:1">${_('socks5_change_settings')}</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else if (protoBase(proto) === 'adguard') {
|
||
const webPort = info.web_port || 3000;
|
||
const ip = info.internal_ip || '172.29.172.253';
|
||
const url = info.web_exposed
|
||
? `http://${SERVER_HOST}:${webPort}`
|
||
: `http://${ip}:${webPort}`;
|
||
actionsEl.innerHTML = `
|
||
<a class="btn btn-secondary btn-sm" href="${url}" target="_blank" rel="noopener" style="flex:1">${_('adguard_open_ui')}</a>
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else if (protoBase(proto) === 'nginx') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showNginxSite('${proto}')" style="flex:1">${_('site')}</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else if (protoBase(proto) === 'dns') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')" style="flex:1">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else if (protoBase(proto) === 'exit') {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showExitPeers('${proto}')" style="flex:1">${_('exit_peers')}</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
showConnectionsSection();
|
||
}
|
||
// Show control buttons (stop + optional config). Services use the
|
||
// container's own config UI (AdGuard web wizard / our SOCKS5 modal /
|
||
// unbound's static config), so we don't expose a raw editor for them.
|
||
if (ctrlEl) {
|
||
ctrlEl.style.cssText = '';
|
||
if (protoBase(proto) === 'nginx') {
|
||
ctrlEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||
`;
|
||
} else if (isService) {
|
||
ctrlEl.innerHTML = `
|
||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||
`;
|
||
} else {
|
||
const awgBtn = AWG_PROTOCOLS.includes(protoBase(proto))
|
||
? `<button class="btn btn-secondary btn-sm" onclick="openAwgSettings('${proto}')" title="${_('awg_settings_title')}">${_('awg_settings_btn')}</button>`
|
||
: '';
|
||
const exitBtn = AWG_PROTOCOLS.includes(protoBase(proto))
|
||
? `<button class="btn btn-secondary btn-sm" onclick="openExitLinkModal('${proto}')" title="${_('exit_link_title')}">🚪${info.exit_link ? ' ' + escapeHtml(info.exit_link.exit_name || '') : ''}</button>`
|
||
: '';
|
||
ctrlEl.innerHTML = `
|
||
${awgBtn}
|
||
${exitBtn}
|
||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
|
||
`;
|
||
}
|
||
}
|
||
} else if (info.container_exists) {
|
||
installedProtocols[proto] = true;
|
||
statusEl.innerHTML = `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
|
||
if (isService) {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
} else {
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('reinstall')}</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="showProtocolBackups('${proto}')">${_('backup')}</button>
|
||
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
|
||
`;
|
||
}
|
||
if (ctrlEl) {
|
||
ctrlEl.style.cssText = '';
|
||
if (protoBase(proto) === 'nginx') {
|
||
ctrlEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||
`;
|
||
} else if (isService) {
|
||
ctrlEl.innerHTML = `
|
||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||
`;
|
||
} else {
|
||
ctrlEl.innerHTML = `
|
||
<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>
|
||
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
|
||
`;
|
||
}
|
||
}
|
||
} else {
|
||
installedProtocols[proto] = false;
|
||
statusEl.innerHTML = `<span class="badge badge-warn"><span class="badge-dot"></span> ${_('not_installed')}</span>`;
|
||
actionsEl.innerHTML = `
|
||
<button class="btn btn-primary btn-sm" onclick="openInstallModal('${proto}')" style="flex:1">${_('install')}</button>
|
||
`;
|
||
if (ctrlEl) {
|
||
ctrlEl.style.cssText = 'display:none!important;';
|
||
ctrlEl.innerHTML = '';
|
||
}
|
||
}
|
||
// Small pencil next to the instance name (visible on name hover).
|
||
// data-proto is (re)assigned here because cloned multi-instance cards
|
||
// inherit the base card's stale value.
|
||
const nameEl = document.querySelector(`#proto-${protoDomKey(proto)} .protocol-name`);
|
||
if (nameEl) {
|
||
let pencil = nameEl.querySelector('.proto-rename');
|
||
if (!pencil) {
|
||
pencil = document.createElement('span');
|
||
pencil.className = 'proto-rename';
|
||
pencil.title = _('rename_instance');
|
||
pencil.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>';
|
||
nameEl.appendChild(pencil);
|
||
}
|
||
pencil.dataset.proto = proto;
|
||
}
|
||
}
|
||
|
||
// Toggle Docker container start/stop
|
||
async function toggleContainer(proto, isRunning) {
|
||
const name = getProtoTitle(proto);
|
||
const action = isRunning ? _('stopping') : _('starting');
|
||
if (!confirm(`${isRunning ? _('stop_container_confirm') : _('start_container_confirm')}`.replace('{}', name))) return;
|
||
try {
|
||
showToast(`${action} ${name}...`, 'info');
|
||
let res;
|
||
try {
|
||
res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
|
||
} catch (err) {
|
||
if (err.message !== 'exit_in_use') throw err;
|
||
// Entry nodes route through this exit: their clients land on the kill-switch
|
||
if (!confirm(_('exit_stop_confirm'))) return;
|
||
res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto, force: true });
|
||
}
|
||
showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success');
|
||
// Recheck after a short delay
|
||
setTimeout(() => checkServer(), 1500);
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// View server-side WireGuard/Xray config
|
||
async function showServerConfig(proto) {
|
||
const name = getProtoTitle(proto);
|
||
try {
|
||
showToast(_('loading'), 'info');
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/server_config`, 'POST', { protocol: proto });
|
||
document.getElementById('serverConfigModalTitle').textContent = `${_('server_config')} — ${name}`;
|
||
document.getElementById('serverConfigProto').value = proto;
|
||
setEditorValue('serverConfigText', res.config || '', { mode: editorModeFor('server', proto) });
|
||
serverConfigFilename = `server_${proto}.conf`;
|
||
openModal('serverConfigModal');
|
||
refreshConfigEditor('serverConfigText');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Host network tuning modal (server-level)
|
||
async function openHostTuning() {
|
||
try {
|
||
showToast(_('loading'), 'info');
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/host_tuning`, 'POST', {});
|
||
renderHostTuning(res);
|
||
openModal('hostTuningModal');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderHostTuning(t) {
|
||
const el = document.getElementById('hostTuningBody');
|
||
if (!el) return;
|
||
if (!t) { el.innerHTML = ''; return; }
|
||
const esc = s => String(s ?? '—').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));
|
||
const row = (k, v) => `<div class="row-between" style="gap:12px;"><span style="opacity:.7">${k}</span><code style="word-break:break-all;text-align:end;">${esc(v)}</code></div>`;
|
||
let html = '';
|
||
const h = t.host || {};
|
||
html += `<div style="opacity:.85;font-weight:600;margin-bottom:2px;">Host</div>`;
|
||
if (t.panel && t.panel.ssh_cooldown_base != null) {
|
||
html += row('ssh cooldown (panel)', `${t.panel.ssh_cooldown_base}s`);
|
||
}
|
||
html += row('congestion control', h.cc);
|
||
html += row('qdisc', h.qdisc);
|
||
html += row('conntrack', `${h.conntrack_count || 0} / ${h.conntrack || '—'}`);
|
||
html += row('netdev_max_backlog', h.netdev_max_backlog);
|
||
html += row('somaxconn', h.somaxconn);
|
||
html += row('amneziawg module', h.awg_module ? `kernel v${h.awg_module}` : 'userspace (amneziawg-go)');
|
||
for (const c of (t.containers || [])) {
|
||
html += `<div style="font-weight:600;margin:12px 0 2px;">🐳 ${esc(c.name)} ${c.running ? '<span style="color:var(--success,#3fb950)">● online</span>' : '<span style="color:var(--danger,#f85149)">● stopped</span>'}</div>`;
|
||
if (c.running && c.ct) {
|
||
html += row('rmem_max / wmem_max', `${c.ct['net.core.rmem_max'] || '—'} / ${c.ct['net.core.wmem_max'] || '—'}`);
|
||
html += row('tcp_fastopen', c.ct['net.ipv4.tcp_fastopen']);
|
||
html += row('tcp_mtu_probing', c.ct['net.ipv4.tcp_mtu_probing']);
|
||
html += row('nofile (ulimit -n)', c.ct['nofile']);
|
||
}
|
||
}
|
||
if (!(t.containers || []).length) html += `<div style="opacity:.6;margin-top:8px;">No AWG containers</div>`;
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// ---------- rename protocol instance ----------
|
||
let renameProtoTarget = null;
|
||
|
||
// Pencils live inside .protocol-name and are (re)built on every status
|
||
// refresh, so one delegated listener covers static and cloned cards.
|
||
document.addEventListener('click', (e) => {
|
||
const el = e.target.closest('.proto-rename');
|
||
if (el && el.dataset.proto) openRenameProtocol(el.dataset.proto);
|
||
});
|
||
|
||
function openRenameProtocol(proto) {
|
||
renameProtoTarget = proto;
|
||
document.getElementById('renameProtoInput').value = CUSTOM_PROTO_NAMES[proto] || getProtoTitle(proto);
|
||
openModal('renameProtoModal');
|
||
}
|
||
|
||
async function saveRenameProtocol() {
|
||
const proto = renameProtoTarget;
|
||
if (!proto) return;
|
||
const name = document.getElementById('renameProtoInput').value.trim();
|
||
const btn = document.getElementById('renameProtoSaveBtn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:-2px;border:2px solid rgba(255,255,255,0.25);border-top-color:#fff;border-radius:50%;animation:spin 0.6s linear infinite;"></span>';
|
||
document.getElementById('renameProtoInput').disabled = true;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/protocol/rename`, 'POST', { protocol: proto, name });
|
||
if (name) {
|
||
CUSTOM_PROTO_NAMES[proto] = name;
|
||
} else {
|
||
delete CUSTOM_PROTO_NAMES[proto];
|
||
}
|
||
closeModal('renameProtoModal');
|
||
showToast(_('success') || 'Saved', 'success');
|
||
setTimeout(() => location.reload(), 800);
|
||
} catch (err) {
|
||
showToast((_('error') || 'Error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
// textContent + the runtime helper: a locale whose "save" carries
|
||
// a quote would break a server-rendered JS string literal.
|
||
btn.textContent = _('save') || 'Save';
|
||
document.getElementById('renameProtoInput').disabled = false;
|
||
}
|
||
}
|
||
|
||
// ---------- wg-easy import ----------
|
||
let wgeasyClients = [];
|
||
|
||
function openWgEasyImport() {
|
||
document.getElementById('wgeasyPreviewArea').classList.add('hidden');
|
||
document.getElementById('wgeasyImportGoBtn').classList.add('hidden');
|
||
document.getElementById('wgeasyPreviewBtn').classList.remove('hidden');
|
||
document.getElementById('wgeasyUsernameGroup').style.display = 'none';
|
||
const logEl = document.getElementById('wgeasyLog');
|
||
logEl.classList.add('hidden');
|
||
logEl.textContent = '';
|
||
wgeasyClients = [];
|
||
openModal('wgeasyImportModal');
|
||
}
|
||
|
||
async function wgeasyPreview() {
|
||
const btn = document.getElementById('wgeasyPreviewBtn');
|
||
const oldText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = '...';
|
||
try {
|
||
const body = {
|
||
web_port: parseInt(document.getElementById('wgeasyWebPort').value, 10) || 51821,
|
||
password: document.getElementById('wgeasyPassword').value,
|
||
username: document.getElementById('wgeasyUsername').value || 'admin',
|
||
};
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/wgeasy/preview`, 'POST', body);
|
||
wgeasyClients = res.clients || [];
|
||
if (res.release >= 15) {
|
||
document.getElementById('wgeasyUsernameGroup').style.display = '';
|
||
}
|
||
renderWgEasyPreview(res);
|
||
document.getElementById('wgeasyPreviewArea').classList.remove('hidden');
|
||
document.getElementById('wgeasyImportGoBtn').classList.remove('hidden');
|
||
} catch (err) {
|
||
showToast((_('error') || 'Error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
}
|
||
}
|
||
|
||
function renderWgEasyPreview(res) {
|
||
const esc = s => String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));
|
||
const info = document.getElementById('wgeasyPreviewInfo');
|
||
const targetLabel = res.recommended_target === 'awg2'
|
||
? ('{{ _("wgeasy_target_awg") }}')
|
||
: ('{{ _("wgeasy_target_wg") }}');
|
||
info.textContent = `wg-easy API v${res.release} • ${esc(res.server_address)} • :${res.listen_port} • ${res.clients.length} ${_('clients') || 'clients'} → ${targetLabel}`;
|
||
const tbody = document.getElementById('wgeasyClientsBody');
|
||
tbody.innerHTML = res.clients.map(c => `
|
||
<tr>
|
||
<td style="padding:5px 10px;"><input type="checkbox" class="wgeasy-client-check" data-id="${esc(c.id)}" checked></td>
|
||
<td style="padding:5px 10px;">${esc(c.name)}</td>
|
||
<td style="padding:5px 10px;" class="font-mono">${esc(c.address)}</td>
|
||
<td style="padding:5px 10px;">${c.enabled ? '🟢' : '⚫'}</td>
|
||
</tr>`).join('');
|
||
const checkAll = document.getElementById('wgeasyCheckAll');
|
||
checkAll.checked = true;
|
||
checkAll.onchange = () => {
|
||
tbody.querySelectorAll('.wgeasy-client-check').forEach(cb => cb.checked = checkAll.checked);
|
||
};
|
||
}
|
||
|
||
async function wgeasyDoImport() {
|
||
const ids = [...document.querySelectorAll('.wgeasy-client-check:checked')].map(cb => cb.dataset.id);
|
||
if (!ids.length) {
|
||
showToast(_('wgeasy_no_clients') || 'No clients selected', 'error');
|
||
return;
|
||
}
|
||
if (!confirm((_('wgeasy_confirm') || 'Import selected clients? The old wg-easy container will be stopped.'))) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('wgeasyImportGoBtn');
|
||
const logEl = document.getElementById('wgeasyLog');
|
||
const showLog = (lines, isError) => {
|
||
logEl.classList.remove('hidden');
|
||
logEl.style.color = isError ? 'var(--danger, #f87171)' : 'inherit';
|
||
logEl.textContent = lines.join('\n');
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
};
|
||
btn.disabled = true;
|
||
btn.textContent = '...';
|
||
logEl.classList.remove('hidden');
|
||
logEl.style.color = 'inherit';
|
||
logEl.textContent = '⏳ ' + (_('wgeasy_importing') || 'Importing, this may take a few minutes...');
|
||
try {
|
||
const body = {
|
||
web_port: parseInt(document.getElementById('wgeasyWebPort').value, 10) || 51821,
|
||
password: document.getElementById('wgeasyPassword').value,
|
||
username: document.getElementById('wgeasyUsername').value || 'admin',
|
||
client_ids: ids,
|
||
};
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/wgeasy/import`, 'POST', body);
|
||
const lines = (res.log || []).concat([
|
||
`✅ ${res.imported} ↑ / ${res.disabled} ⏸ • ${res.target} :${res.port} ${res.subnet}`,
|
||
]);
|
||
showLog(lines, false);
|
||
showToast(_('success') || 'Done', 'success');
|
||
btn.classList.add('hidden');
|
||
// Keep the modal open so the log stays readable; closing it
|
||
// (× or the footer button) reloads the page to show the instance.
|
||
const modal = document.getElementById('wgeasyImportModal');
|
||
modal.querySelector('.modal-close')
|
||
.addEventListener('click', () => location.reload(), { once: true });
|
||
modal.querySelector('.modal-footer .btn-secondary').onclick = () => location.reload();
|
||
} catch (err) {
|
||
const lines = ((err.payload && err.payload.log) || []).concat([`❌ ${err.message}`]);
|
||
showLog(lines, true);
|
||
showToast((_('error') || 'Error') + ': ' + err.message, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = _('wgeasy_import_btn');
|
||
}
|
||
}
|
||
|
||
// Save server config changes
|
||
async function saveServerConfig() {
|
||
const proto = document.getElementById('serverConfigProto').value;
|
||
const newConfig = getEditorValue('serverConfigText');
|
||
const btn = document.getElementById('saveServerConfigBtn');
|
||
const oldText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = _('saving') || 'Saving...';
|
||
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/server_config/save`, 'POST', {
|
||
protocol: proto,
|
||
config: newConfig
|
||
});
|
||
showToast(_('success') || 'Saved successfully', 'success');
|
||
closeModal('serverConfigModal');
|
||
} catch (err) {
|
||
showToast((_('error') || 'Error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
}
|
||
}
|
||
|
||
|
||
async function showNginxSite(proto = 'nginx') {
|
||
try {
|
||
showToast(_('loading'), 'info');
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/nginx/site`, 'POST', { protocol: proto });
|
||
document.getElementById('siteConfigModalTitle').textContent = `${_('site_editor')} — ${getProtoTitle(proto)}`;
|
||
document.getElementById('siteConfigProto').value = proto;
|
||
setEditorValue('siteConfigText', res.html || '', { mode: editorModeFor('site', proto) });
|
||
openModal('siteConfigModal');
|
||
refreshConfigEditor('siteConfigText');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveNginxSite() {
|
||
const proto = document.getElementById('siteConfigProto').value || 'nginx';
|
||
const html = getEditorValue('siteConfigText');
|
||
const btn = document.getElementById('saveSiteConfigBtn');
|
||
const oldText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = _('saving') || 'Saving...';
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/nginx/site/save`, 'POST', { protocol: proto, html });
|
||
showToast(_('site_saved') || _('success'), 'success');
|
||
closeModal('siteConfigModal');
|
||
} catch (err) {
|
||
showToast((_('error') || 'Error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
}
|
||
}
|
||
|
||
|
||
function showConnectionsSection() {
|
||
const section = document.getElementById('connectionsSection');
|
||
if (section) section.style.display = 'block';
|
||
}
|
||
|
||
function hideConnectionsSection() {
|
||
const section = document.getElementById('connectionsSection');
|
||
if (section) section.style.display = 'none';
|
||
const select = document.getElementById('connProtoSelect');
|
||
if (select) {
|
||
select.innerHTML = '';
|
||
const opt = document.createElement('option');
|
||
opt.textContent = _('no_protocols');
|
||
opt.disabled = true;
|
||
select.appendChild(opt);
|
||
}
|
||
}
|
||
|
||
// ========== Protocol Installation ==========
|
||
function updateNginxDnsHint() {
|
||
const input = document.getElementById('installNginxDomain');
|
||
const hint = document.getElementById('nginxDnsHint');
|
||
if (!input || !hint) return;
|
||
const domain = (input.value || '').trim() || 'example.com';
|
||
hint.innerHTML = `${_('nginx_dns_hint')} <code>A ${domain} ${SERVER_HOST}</code>`;
|
||
}
|
||
|
||
function openInstallModal(proto, installAnother = false) {
|
||
const base = protoBase(proto);
|
||
currentInstallProto = installAnother ? base : proto;
|
||
currentInstallAnother = !!installAnother;
|
||
const name = getProtoTitle(proto) + (currentInstallAnother ? ` #${_('new_instance')}` : '');
|
||
document.getElementById('installModalTitle').textContent = `${_('install')} ${name}`;
|
||
|
||
const portInput = document.getElementById('installPort');
|
||
const portLabel = document.getElementById('installPortLabel');
|
||
const portHint = document.getElementById('installPortHint');
|
||
const telemtOpts = document.getElementById('telemtOptions');
|
||
const socks5Opts = document.getElementById('socks5Options');
|
||
const adguardOpts = document.getElementById('adguardOptions');
|
||
const nginxOpts = document.getElementById('nginxOptions');
|
||
const awgOpts = document.getElementById('awgOptions');
|
||
const exitOpts = document.getElementById('exitOptions');
|
||
|
||
telemtOpts.style.display = 'none';
|
||
socks5Opts.style.display = 'none';
|
||
adguardOpts.style.display = 'none';
|
||
nginxOpts.style.display = 'none';
|
||
awgOpts.style.display = 'none';
|
||
exitOpts.style.display = 'none';
|
||
|
||
if (base === 'dns') {
|
||
portLabel.textContent = _('port') + ' (Internal)';
|
||
portInput.value = '53';
|
||
portInput.disabled = true;
|
||
portHint.textContent = _('dns_internal_hint');
|
||
} else if (base === 'xray') {
|
||
portLabel.textContent = _('port') + ' (TCP)';
|
||
portInput.disabled = false;
|
||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
|
||
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('port_xray_hint');
|
||
} else if (base === 'telemt') {
|
||
portLabel.textContent = _('port') + ' (TCP)';
|
||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 443) : '443';
|
||
portInput.disabled = false;
|
||
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('telemt_port_hint');
|
||
telemtOpts.style.display = 'block';
|
||
} else if (base === 'socks5') {
|
||
portLabel.textContent = _('port') + ' (TCP)';
|
||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 38080) : '38080';
|
||
portInput.disabled = false;
|
||
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('socks5_port_hint');
|
||
socks5Opts.style.display = 'block';
|
||
} else if (base === 'adguard') {
|
||
portLabel.textContent = _('adguard_dns_port');
|
||
portInput.value = '53';
|
||
portInput.disabled = false;
|
||
portHint.textContent = _('adguard_dns_port_hint');
|
||
adguardOpts.style.display = 'block';
|
||
} else if (base === 'nginx') {
|
||
portLabel.textContent = _('port') + ' (HTTPS/TCP)';
|
||
portInput.value = '443';
|
||
portInput.disabled = false;
|
||
portHint.textContent = _('nginx_port_hint');
|
||
nginxOpts.style.display = 'block';
|
||
updateNginxDnsHint();
|
||
} else if (base === 'exit') {
|
||
portLabel.textContent = _('port') + ' (UDP)';
|
||
portInput.value = '55520';
|
||
portInput.disabled = false;
|
||
portHint.textContent = _('exit_port_hint');
|
||
exitOpts.style.display = 'block';
|
||
} else {
|
||
portLabel.textContent = _('port') + ' (UDP)';
|
||
portInput.value = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 55424) : '55424';
|
||
portInput.disabled = false;
|
||
portHint.textContent = currentInstallAnother ? _('port_next_instance_hint') : _('port_default_hint');
|
||
awgOpts.style.display = 'block';
|
||
// AWG Legacy predates the special junk packets, only MTU/DNS apply.
|
||
document.getElementById('installAwgJunk').style.display = base === 'awg_legacy' ? 'none' : '';
|
||
document.getElementById('installAwgMtu').value = '1376';
|
||
document.getElementById('installAwgDns1').value = '1.1.1.1';
|
||
document.getElementById('installAwgDns2').value = '1.0.0.1';
|
||
document.getElementById('installAwgDns6').value = '2606:4700:4700::1111';
|
||
resetSpecialJunkFields('installAwg');
|
||
}
|
||
|
||
document.getElementById('installForm').classList.remove('hidden');
|
||
document.getElementById('installProgress').classList.add('hidden');
|
||
document.getElementById('installDone').style.display = 'none';
|
||
|
||
// Live port validation + auto-suggest a truly free port
|
||
portHint.dataset.orig = portHint.textContent;
|
||
portInput.oninput = validateInstallPort;
|
||
validateInstallPort();
|
||
refreshUsedPorts().then(() => {
|
||
if (!portInput.disabled && !SKIP_PORT_CHECK.has(protoBase(currentInstallProto))
|
||
&& portTakenBy(portInput.value, currentInstallProto)) {
|
||
portInput.value = firstFreePort(portInput.value, currentInstallProto);
|
||
}
|
||
validateInstallPort();
|
||
});
|
||
|
||
openModal('installModal');
|
||
}
|
||
|
||
async function installProtocol() {
|
||
const port = document.getElementById('installPort').value;
|
||
const btn = document.getElementById('installBtn');
|
||
const text = document.getElementById('installBtnText');
|
||
const spinner = document.getElementById('installSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('installing');
|
||
spinner.classList.remove('hidden');
|
||
document.getElementById('installForm').classList.add('hidden');
|
||
const progressDiv = document.getElementById('installProgress');
|
||
progressDiv.classList.remove('hidden');
|
||
const logEl = document.getElementById('installLog');
|
||
const fillEl = document.getElementById('installProgressFill');
|
||
logEl.innerHTML = `<div class="log-line info">${_('install_starting')}</div>`;
|
||
fillEl.style.width = '10%';
|
||
fillEl.style.background = '';
|
||
try {
|
||
const progressSteps = [
|
||
{ pct: 20, msg: _('check_docker') },
|
||
{ pct: 40, msg: _('prepare_host') },
|
||
{ pct: 50, msg: _('build_container') },
|
||
];
|
||
let stepIdx = 0;
|
||
const progressInterval = setInterval(() => {
|
||
if (stepIdx < progressSteps.length) {
|
||
fillEl.style.width = progressSteps[stepIdx].pct + '%';
|
||
logEl.innerHTML += `<div class="log-line info">${progressSteps[stepIdx].msg}</div>`;
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
stepIdx++;
|
||
}
|
||
}, 2000);
|
||
const params = {
|
||
protocol: currentInstallProto, port: port, install_another: currentInstallAnother,
|
||
};
|
||
if (protoBase(currentInstallProto) === 'telemt') {
|
||
params.tls_emulation = document.getElementById('installTelemtTls').checked;
|
||
params.tls_domain = document.getElementById('installTelemtDomain').value;
|
||
params.max_connections = parseInt(document.getElementById('installTelemtMaxConns').value) || 0;
|
||
} else if (protoBase(currentInstallProto) === 'socks5') {
|
||
params.socks5_username = document.getElementById('installSocks5User').value || 'proxy_user';
|
||
params.socks5_password = document.getElementById('installSocks5Pass').value || '';
|
||
} else if (protoBase(currentInstallProto) === 'adguard') {
|
||
const modeRadio = document.querySelector('input[name="adguardMode"]:checked');
|
||
params.adguard_mode = modeRadio ? modeRadio.value : 'sidebyside';
|
||
params.adguard_web_port = parseInt(document.getElementById('installAdguardWebPort').value) || 3000;
|
||
params.adguard_expose_web = document.getElementById('installAdguardExposeWeb').checked;
|
||
params.adguard_dot_port = parseInt(document.getElementById('installAdguardDotPort').value) || 853;
|
||
params.adguard_doh_port = parseInt(document.getElementById('installAdguardDohPort').value) || 443;
|
||
params.adguard_expose_dot = document.getElementById('installAdguardExposeDot').checked;
|
||
params.adguard_expose_doh = document.getElementById('installAdguardExposeDoh').checked;
|
||
} else if (protoBase(currentInstallProto) === 'nginx') {
|
||
params.nginx_domain = document.getElementById('installNginxDomain').value.trim();
|
||
params.nginx_email = document.getElementById('installNginxEmail').value.trim();
|
||
} else if (protoBase(currentInstallProto) === 'exit') {
|
||
params.exit_subnet = document.getElementById('installExitSubnet').value.trim();
|
||
params.exit_obfuscation = document.getElementById('installExitObfuscation').checked;
|
||
} else if (AWG_PROTOCOLS.includes(protoBase(currentInstallProto))) {
|
||
params.awg_mtu = document.getElementById('installAwgMtu').value.trim();
|
||
params.awg_dns1 = document.getElementById('installAwgDns1').value.trim();
|
||
params.awg_dns2 = document.getElementById('installAwgDns2').value.trim();
|
||
params.awg_dns6 = document.getElementById('installAwgDns6').value.trim();
|
||
if (protoBase(currentInstallProto) !== 'awg_legacy') {
|
||
['i1', 'i2', 'i3', 'i4', 'i5'].forEach(key => {
|
||
params['awg_' + key] = specialJunkField('installAwg', key).value.trim();
|
||
});
|
||
}
|
||
}
|
||
const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
|
||
clearInterval(progressInterval);
|
||
fillEl.style.width = '100%';
|
||
if (result.log) {
|
||
result.log.forEach(line => {
|
||
// Installers mark a line that succeeded with a caveat by
|
||
// prefixing it with "!" - a green tick would hide it.
|
||
logEl.innerHTML += line.startsWith('!')
|
||
? `<div class="log-line warning">⚠ ${line.slice(1).trim()}</div>`
|
||
: `<div class="log-line success">✓ ${line}</div>`;
|
||
});
|
||
}
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
showToast(_('install_success'), 'success');
|
||
document.getElementById('installDone').style.display = '';
|
||
setTimeout(() => checkServer(), 800);
|
||
} catch (err) {
|
||
fillEl.style.width = '100%';
|
||
fillEl.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)';
|
||
logEl.innerHTML += `<div class="log-line error">✕ ${_('error')}: ${err.message}</div>`;
|
||
logEl.scrollTop = logEl.scrollHeight;
|
||
showToast(_('install_error') + err.message, 'error');
|
||
document.getElementById('installDone').style.display = '';
|
||
}
|
||
}
|
||
|
||
function formatBackupTime(mtime) {
|
||
if (!mtime) return '—';
|
||
try { return new Date(mtime * 1000).toLocaleString(); } catch (_) { return '—'; }
|
||
}
|
||
|
||
function backupDownloadUrl(proto, filename) {
|
||
return `/api/servers/${SERVER_ID}/backups/download`;
|
||
}
|
||
|
||
async function showProtocolBackups(proto) {
|
||
document.getElementById('backupProto').value = proto;
|
||
document.getElementById('backupModalTitle').textContent = `${_('backups')} — ${getProtoTitle(proto)}`;
|
||
openModal('backupModal');
|
||
await loadProtocolBackups();
|
||
}
|
||
|
||
document.getElementById('backupList').addEventListener('click', function (event) {
|
||
const btn = event.target.closest('[data-backup-action]');
|
||
if (!btn || !document.getElementById('backupList').contains(btn)) return;
|
||
const proto = btn.getAttribute('data-proto');
|
||
const filename = btn.getAttribute('data-filename');
|
||
const action = btn.getAttribute('data-backup-action');
|
||
if (action === 'restore') restoreProtocolBackup(proto, filename);
|
||
else if (action === 'download') downloadProtocolBackup(proto, filename);
|
||
else if (action === 'delete') deleteProtocolBackup(proto, filename);
|
||
});
|
||
|
||
async function loadProtocolBackups() {
|
||
const proto = document.getElementById('backupProto').value;
|
||
const list = document.getElementById('backupList');
|
||
const empty = document.getElementById('backupEmpty');
|
||
const loading = document.getElementById('backupLoading');
|
||
list.innerHTML = '';
|
||
empty.classList.add('hidden');
|
||
loading.classList.remove('hidden');
|
||
try {
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/backups`, 'POST', { protocol: proto });
|
||
const backups = res.backups || [];
|
||
if (!backups.length) {
|
||
empty.classList.remove('hidden');
|
||
return;
|
||
}
|
||
list.innerHTML = backups.map(b => `
|
||
<div class="backup-row">
|
||
<div class="backup-row-meta">
|
||
<div class="backup-row-name" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</div>
|
||
<div class="backup-row-info">${formatBytes(b.size)} · ${formatBackupTime(b.mtime)}</div>
|
||
</div>
|
||
<div class="backup-row-actions">
|
||
<button type="button" class="btn btn-secondary btn-sm" data-backup-action="restore" data-proto="${escapeHtml(proto)}" data-filename="${escapeHtml(b.name)}">${_('restore')}</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-backup-action="download" data-proto="${escapeHtml(proto)}" data-filename="${escapeHtml(b.name)}">${_('download')}</button>
|
||
<button type="button" class="btn btn-danger btn-sm" data-backup-action="delete" data-proto="${escapeHtml(proto)}" data-filename="${escapeHtml(b.name)}">${_('delete')}</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
loading.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
async function createProtocolBackup() {
|
||
const proto = document.getElementById('backupProto').value;
|
||
const btn = document.getElementById('createBackupBtn');
|
||
const oldText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = _('creating_backup') || 'Creating backup...';
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/backups/create`, 'POST', { protocol: proto });
|
||
showToast(_('backup_created'), 'success');
|
||
await loadProtocolBackups();
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
}
|
||
}
|
||
|
||
document.getElementById('backupUploadFile').addEventListener('change', uploadProtocolBackup);
|
||
|
||
async function uploadProtocolBackup(event) {
|
||
const file = event.target.files && event.target.files[0];
|
||
event.target.value = '';
|
||
if (!file) return;
|
||
const proto = document.getElementById('backupProto').value;
|
||
const btn = document.getElementById('uploadBackupBtn');
|
||
const oldText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = _('uploading_backup');
|
||
try {
|
||
const body = new FormData();
|
||
body.append('protocol', proto);
|
||
body.append('file', file);
|
||
const res = await fetch(`/api/servers/${SERVER_ID}/backups/upload`, {
|
||
method: 'POST',
|
||
body
|
||
});
|
||
let data = {};
|
||
try {
|
||
data = await res.json();
|
||
} catch (_) {}
|
||
if (!res.ok) {
|
||
throw new Error(data.error || 'Upload failed');
|
||
}
|
||
showToast(_('backup_uploaded'), 'success');
|
||
await loadProtocolBackups();
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = oldText;
|
||
}
|
||
}
|
||
|
||
async function downloadProtocolBackup(proto, filename) {
|
||
try {
|
||
const res = await fetch(`/api/servers/${SERVER_ID}/backups/download`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ protocol: proto, filename })
|
||
});
|
||
if (!res.ok) {
|
||
let message = 'Download failed';
|
||
try {
|
||
const data = await res.json();
|
||
message = data.error || message;
|
||
} catch (_) {}
|
||
throw new Error(message);
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function restoreProtocolBackup(proto, filename) {
|
||
if (!confirm(_('restore_confirm_protocol'))) return;
|
||
try {
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/backups/restore`, 'POST', {
|
||
protocol: proto,
|
||
filename: filename
|
||
});
|
||
showToast(_('restore_success'), 'success');
|
||
// an archive can carry a different exit-link state than the panel has
|
||
const relinked = (res.exit_entries_relinked || []).filter(e => e.status === 'success').length;
|
||
const failed = (res.exit_entries_relinked || []).filter(e => e.status !== 'success').length;
|
||
if (relinked) showToast(_('exit_restore_entries_relinked').replace('{n}', relinked), 'info');
|
||
if (failed) showToast(_('exit_restore_entries_failed').replace('{n}', failed), 'error');
|
||
if (res.exit_link_restored === 'relinked') showToast(_('exit_restore_relinked'), 'info');
|
||
if (res.exit_link_restored === 'stale') showToast(_('exit_restore_stale'), 'error');
|
||
if (res.exit_link_restored === 'removed') showToast(_('exit_restore_removed'), 'info');
|
||
if (res.exit_link_restored === 'orphan') showToast(_('exit_restore_orphan'), 'error');
|
||
await loadProtocolBackups();
|
||
if (typeof loadConnections === 'function') {
|
||
await loadConnections();
|
||
}
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteProtocolBackup(proto, filename) {
|
||
if (!confirm(_('delete_backup_confirm'))) return;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/backups/delete`, 'POST', {
|
||
protocol: proto,
|
||
filename: filename
|
||
});
|
||
showToast(_('backup_deleted'), 'success');
|
||
await loadProtocolBackups();
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function uninstallProtocol(proto) {
|
||
const name = getProtoTitle(proto);
|
||
if (!confirm(_('uninstall_confirm').replace('{}', name))) return;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/uninstall`, 'POST', { protocol: proto });
|
||
showToast(`${name} ${_('stopped')}`, 'success');
|
||
setTimeout(() => window.location.reload(), 800);
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ========== SOCKS5 Settings ==========
|
||
async function openAwgSettings(proto) {
|
||
currentAwgProto = proto;
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/awg/settings`, 'POST', { protocol: proto });
|
||
document.getElementById('awgSettingsTitle').textContent = `${_('awg_settings_title')} — ${getProtoTitle(proto)}`;
|
||
document.getElementById('awgSetMtu').value = data.mtu || '1376';
|
||
document.getElementById('awgSetDns1').value = data.dns1 || '';
|
||
document.getElementById('awgSetDns2').value = data.dns2 || '';
|
||
document.getElementById('awgSetDns6').value = data.dns6 || '';
|
||
const exitHint = document.getElementById('awgSetMtuExitHint');
|
||
exitHint.classList.toggle('hidden', !data.exit_linked);
|
||
if (data.exit_linked) {
|
||
const over = Number(data.mtu) > Number(data.exit_mtu);
|
||
exitHint.textContent = _('awg_mtu_exit_hint').replace('{exit_mtu}', data.exit_mtu);
|
||
exitHint.style.color = over ? 'var(--danger, #f87171)' : '';
|
||
}
|
||
const junkGroup = document.getElementById('awgSetJunkGroup');
|
||
junkGroup.style.display = data.supports_special_junk ? '' : 'none';
|
||
SPECIAL_JUNK_KEYS.forEach(key => {
|
||
const el = specialJunkField('awgSet', key);
|
||
if (el) el.value = data[key] || '';
|
||
});
|
||
openModal('awgSettingsModal');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveAwgSettings() {
|
||
const btn = document.getElementById('awgSaveBtn');
|
||
const text = document.getElementById('awgSaveBtnText');
|
||
const spinner = document.getElementById('awgSaveSpinner');
|
||
const payload = {
|
||
protocol: currentAwgProto,
|
||
mtu: document.getElementById('awgSetMtu').value.trim(),
|
||
dns1: document.getElementById('awgSetDns1').value.trim(),
|
||
dns2: document.getElementById('awgSetDns2').value.trim(),
|
||
dns6: document.getElementById('awgSetDns6').value.trim(),
|
||
};
|
||
if (document.getElementById('awgSetJunkGroup').style.display !== 'none') {
|
||
SPECIAL_JUNK_KEYS.forEach(key => {
|
||
payload[key] = specialJunkField('awgSet', key).value.trim();
|
||
});
|
||
}
|
||
btn.disabled = true;
|
||
text.textContent = _('saving') || 'Saving...';
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/awg/settings/save`, 'POST', payload);
|
||
showToast(_('awg_settings_saved'), 'success');
|
||
closeModal('awgSettingsModal');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('save');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ========== Exit node ==========
|
||
let currentExitPeersProto = 'exit';
|
||
let currentExitLinkProto = null;
|
||
let currentExitLinkState = null;
|
||
const EXIT_LINK_ERROR_KEYS = ['exit_no_handshake', 'exit_mtu_too_big', 'exit_self', 'exit_legacy_obfuscation',
|
||
'exit_not_found', 'exit_apply_failed', 'exit_in_use', 'exit_egress_failed', 'exit_dns_needs_dns',
|
||
'not_linked'];
|
||
|
||
function exitStatusBadge(status, link) {
|
||
const badge = (cls, text, title = '') => `<span class="badge ${cls}" title="${escapeHtml(title)}"><span class="badge-dot"></span> ${escapeHtml(text)}</span>`;
|
||
if (link && link.stale) return badge('badge-danger', _('exit_status_down'), _('exit_link_stale'));
|
||
if (!status) return badge('badge-secondary', _('exit_status_down'));
|
||
if (status.error) return badge('badge-danger', _('exit_status_down'), status.error);
|
||
const age = status.handshake_age;
|
||
const ago = (age === null || age === undefined) ? '' : ' · ' + _('exit_handshake_ago').replace('{s}', age);
|
||
if (status.up) return badge('badge-success', _('exit_status_up') + ago, `${formatBytes(status.rx_bytes || 0)} ↓ ${formatBytes(status.tx_bytes || 0)} ↑`);
|
||
return badge('badge-warn', _('exit_status_stale') + ago);
|
||
}
|
||
|
||
function exitLinkErrorText(err) {
|
||
return EXIT_LINK_ERROR_KEYS.includes(err.message) ? _(err.message) : err.message;
|
||
}
|
||
|
||
async function showExitPeers(proto = 'exit') {
|
||
currentExitPeersProto = proto || 'exit';
|
||
const list = document.getElementById('exitPeersList');
|
||
const infoEl = document.getElementById('exitPeersInfo');
|
||
list.innerHTML = `<div class="empty-state">${_('loading')}</div>`;
|
||
openModal('exitPeersModal');
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/exit/peers`, 'POST', { protocol: currentExitPeersProto });
|
||
const info = data.info || {};
|
||
infoEl.textContent = `${_('port')}: ${info.port || '—'}/UDP · ${_('exit_subnet')}: ${info.subnet || '—'} · ${_('exit_obfuscation_short')}: ${info.obfuscation ? _('on') : _('off')}`;
|
||
const e = escapeHtml;
|
||
if (!data.peers || !data.peers.length) {
|
||
list.innerHTML = `<div class="empty-state">${_('exit_no_peers')}</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = `<div class="table-wrap"><table class="table"><thead><tr>
|
||
<th>${_('exit_peer_name')}</th><th>${_('exit_transit_ip')}</th><th>${_('exit_public_key')}</th><th>${_('exit_handshake')}</th><th>${_('exit_transfer')}</th><th></th>
|
||
</tr></thead><tbody>` + data.peers.map(p => `<tr${p.external ? ' style="opacity:.7"' : ''}>
|
||
<td>${e(p.name || p.peer_id || '—')}${p.external ? ` <span class="badge badge-warn">${_('exit_peer_external')}</span>` : ''}</td>
|
||
<td>${e(p.transit_ip || '—')}</td>
|
||
<td><code title="${e(p.public_key)}">${e((p.public_key || '').slice(0, 12))}…</code></td>
|
||
<td>${e(p.latest_handshake || '—')}</td>
|
||
<td>${e(p.rx || '0 B')} ↓ ${e(p.tx || '0 B')} ↑</td>
|
||
<td><button class="btn btn-danger btn-sm" data-key="${e(p.public_key)}" onclick="removeExitPeer(this.dataset.key)">${_('uninstall')}</button></td>
|
||
</tr>`).join('') + `</tbody></table></div>`;
|
||
} catch (err) {
|
||
list.innerHTML = `<div class="empty-state">${_('error')}: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function removeExitPeer(publicKey) {
|
||
if (!confirm(_('exit_peer_remove_confirm'))) return;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/exit/peers/remove`, 'POST', { protocol: currentExitPeersProto, public_key: publicKey });
|
||
showToast(_('exit_peer_removed'), 'success');
|
||
showExitPeers(currentExitPeersProto);
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderExitLinkState(state) {
|
||
const el = document.getElementById('exitLinkState');
|
||
const warn = document.getElementById('exitLinkWarnings');
|
||
const link = state.exit_link;
|
||
const unlinkBtn = document.getElementById('exitUnlinkBtn');
|
||
const repairBtn = document.getElementById('exitRepairBtn');
|
||
unlinkBtn.classList.toggle('hidden', !link);
|
||
document.getElementById('exitEgressBtn').classList.toggle('hidden', !link);
|
||
document.getElementById('exitDnsGroup').classList.toggle('hidden', !link);
|
||
document.getElementById('exitDnsViaExit').checked = !!(link && link.dns_via_exit);
|
||
repairBtn.classList.toggle('hidden', !(link && (link.stale || !(state.exit_link_status && state.exit_link_status.up))));
|
||
if (!link) {
|
||
el.innerHTML = `<div class="form-hint">${_('exit_link_none')}</div>`;
|
||
} else {
|
||
el.innerHTML = `<div>${escapeHtml(_('exit_link_current').replace('{name}', link.exit_name || '').replace('{ip}', link.transit_ip || ''))} ${exitStatusBadge(state.exit_link_status, link)}</div>`
|
||
+ (link.stale ? `<div class="form-hint" style="color:var(--danger, #f87171)">${_('exit_link_stale')}</div>` : '');
|
||
}
|
||
let warnings = '';
|
||
if (state.ipv6) warnings += `<div class="form-hint">⚠ ${_('exit_link_ipv6_warning')}</div>`;
|
||
if (state.mtu && state.exit_mtu && state.mtu > state.exit_mtu) {
|
||
warnings += `<div class="form-hint" style="color:var(--danger, #f87171)">⚠ ${_('exit_link_mtu_warning').replace('{mtu}', state.mtu).replace('{exit_mtu}', state.exit_mtu)}</div>`;
|
||
}
|
||
warn.innerHTML = warnings;
|
||
}
|
||
|
||
async function openExitLinkModal(proto) {
|
||
currentExitLinkProto = proto;
|
||
document.getElementById('exitLinkTitle').textContent = `${_('exit_link_title')} — ${getProtoTitle(proto)}`;
|
||
const select = document.getElementById('exitLinkSelect');
|
||
const hint = document.getElementById('exitLinkSelectHint');
|
||
select.innerHTML = '';
|
||
hint.textContent = '';
|
||
document.getElementById('exitLinkState').innerHTML = `<div class="form-hint">${_('loading')}</div>`;
|
||
document.getElementById('exitLinkWarnings').innerHTML = '';
|
||
document.getElementById('exitLinkEgress').innerHTML = '';
|
||
// hidden until the state says this instance is linked
|
||
document.getElementById('exitEgressBtn').classList.add('hidden');
|
||
openModal('exitLinkModal');
|
||
try {
|
||
const [nodes, state] = await Promise.all([
|
||
apiCall('/api/exit-nodes'),
|
||
apiCall(`/api/servers/${SERVER_ID}/exit-link/status`, 'POST', { protocol: proto }),
|
||
]);
|
||
currentExitLinkState = state;
|
||
const own = document.body.dataset.serverUid || '';
|
||
const candidates = (nodes.exit_nodes || []).filter(n => n.uid !== own);
|
||
if (!candidates.length) {
|
||
hint.textContent = _('exit_no_exit_nodes');
|
||
}
|
||
const legacy = protoBase(proto) === 'awg_legacy';
|
||
candidates.forEach(n => {
|
||
const opt = document.createElement('option');
|
||
opt.value = n.uid;
|
||
opt.textContent = `${n.name} (${n.host}:${n.port || '?'})${n.obfuscation ? ' [AWG]' : ''}`;
|
||
if (legacy && n.obfuscation) { opt.disabled = true; opt.title = _('exit_legacy_obfuscation'); }
|
||
if (state.exit_link && state.exit_link.exit_uid === n.uid) opt.selected = true;
|
||
select.appendChild(opt);
|
||
});
|
||
document.getElementById('exitLinkBtn').disabled = !candidates.length;
|
||
renderExitLinkState(state);
|
||
} catch (err) {
|
||
document.getElementById('exitLinkState').innerHTML = `<div class="form-hint">${_('error')}: ${escapeHtml(err.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function exitLinkRequest(path, body, successKey) {
|
||
const btn = document.getElementById('exitLinkBtn');
|
||
const spinner = document.getElementById('exitLinkSpinner');
|
||
btn.disabled = true;
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/exit-link${path}`, 'POST', body);
|
||
(res.warnings || []).forEach(w => showToast(_(w), 'info'));
|
||
showToast(_(successKey), 'success');
|
||
closeModal('exitLinkModal');
|
||
setTimeout(() => checkServer(), 500);
|
||
return res;
|
||
} catch (err) {
|
||
const text = exitLinkErrorText(err);
|
||
if (err.message === 'exit_no_handshake' || err.message === 'exit_mtu_too_big') {
|
||
if (confirm(`${text}\n\n${_('exit_link_force')}?`)) {
|
||
return exitLinkRequest(path, { ...body, force: true }, successKey);
|
||
}
|
||
} else {
|
||
showToast(_('error') + ': ' + text, 'error');
|
||
}
|
||
} finally {
|
||
btn.disabled = false;
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
async function saveExitLink(force = false) {
|
||
const uid = document.getElementById('exitLinkSelect').value;
|
||
if (!uid) return;
|
||
if (currentExitLinkState && currentExitLinkState.ipv6 && !confirm(_('exit_link_ipv6_confirm'))) return;
|
||
await exitLinkRequest('', { protocol: currentExitLinkProto, exit_uid: uid, force }, 'exit_link_saved');
|
||
}
|
||
|
||
async function removeExitLink() {
|
||
if (!confirm(_('exit_unlink_confirm'))) return;
|
||
await exitLinkRequest('/remove', { protocol: currentExitLinkProto }, 'exit_unlinked');
|
||
}
|
||
|
||
async function checkExitEgress() {
|
||
const box = document.getElementById('exitLinkEgress');
|
||
const btn = document.getElementById('exitEgressBtn');
|
||
box.innerHTML = `<div class="form-hint">${_('exit_egress_checking')}</div>`;
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await apiCall(`/api/servers/${SERVER_ID}/exit-link/check-egress`, 'POST', { protocol: currentExitLinkProto });
|
||
const via = escapeHtml(r.via_exit || '');
|
||
let text, color;
|
||
if (!r.via_exit) {
|
||
text = _('exit_egress_unknown'); color = 'var(--danger, #f87171)';
|
||
} else if (r.leaks) {
|
||
text = _('exit_egress_direct').replace('{ip}', via); color = 'var(--danger, #f87171)';
|
||
} else if (r.matches) {
|
||
text = _('exit_egress_match').replace('{ip}', via).replace('{name}', escapeHtml(r.exit_name || '')); color = 'var(--success, #4ade80)';
|
||
} else {
|
||
text = _('exit_egress_mismatch').replace('{ip}', via).replace('{expected}', escapeHtml(r.expected_ip || r.expected || '')); color = 'var(--warning, #fbbf24)';
|
||
}
|
||
box.innerHTML = `<div class="form-hint" style="color:${color}">${text}</div>`;
|
||
} catch (err) {
|
||
box.innerHTML = `<div class="form-hint" style="color:var(--danger, #f87171)">${_('error')}: ${escapeHtml(exitLinkErrorText(err))}</div>`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function saveExitDns(enabled) {
|
||
const box = document.getElementById('exitDnsViaExit');
|
||
box.disabled = true;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/exit-link/dns`, 'POST',
|
||
{ protocol: currentExitLinkProto, enabled: !!enabled });
|
||
showToast(_(enabled ? 'exit_dns_via_exit_on' : 'exit_dns_via_exit_off'), 'success');
|
||
if (currentExitLinkState && currentExitLinkState.exit_link) currentExitLinkState.exit_link.dns_via_exit = !!enabled;
|
||
} catch (err) {
|
||
box.checked = !enabled;
|
||
showToast(_('error') + ': ' + exitLinkErrorText(err), 'error');
|
||
} finally {
|
||
box.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function repairExitLink() {
|
||
await exitLinkRequest('/relink', { protocol: currentExitLinkProto }, 'exit_link_saved');
|
||
}
|
||
|
||
async function openSocks5Settings(proto = 'socks5') {
|
||
currentSocks5Proto = proto || 'socks5';
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/socks5/credentials?protocol=${encodeURIComponent(currentSocks5Proto)}`);
|
||
document.getElementById('socks5SetPort').value = data.port || 38080;
|
||
document.getElementById('socks5SetUser').value = data.username || 'proxy_user';
|
||
document.getElementById('socks5SetPass').value = data.password || '';
|
||
openModal('socks5SettingsModal');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveSocks5Settings() {
|
||
const btn = document.getElementById('socks5SaveBtn');
|
||
const text = document.getElementById('socks5SaveBtnText');
|
||
const spinner = document.getElementById('socks5SaveSpinner');
|
||
const port = parseInt(document.getElementById('socks5SetPort').value) || 38080;
|
||
const username = document.getElementById('socks5SetUser').value.trim();
|
||
const password = document.getElementById('socks5SetPass').value.trim();
|
||
btn.disabled = true;
|
||
text.textContent = _('saving') || 'Saving...';
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const res = await apiCall(`/api/servers/${SERVER_ID}/socks5/credentials`, 'POST', {
|
||
protocol: currentSocks5Proto, port, username, password
|
||
});
|
||
if (res.status === 'success') {
|
||
showToast(_('socks5_settings_saved'), 'success');
|
||
closeModal('socks5SettingsModal');
|
||
setTimeout(() => checkServer(), 500);
|
||
} else {
|
||
throw new Error(res.message || res.error || 'Unknown error');
|
||
}
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('save');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// ========== Connection Management ==========
|
||
function selectProtocolForConns(proto) {
|
||
document.getElementById('connectionsSection').style.display = '';
|
||
document.getElementById('connProtoSelect').value = proto;
|
||
loadConnections();
|
||
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
async function loadConnections(isPoll, force) {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const loading = document.getElementById('connectionsLoading');
|
||
const emptyEl = document.getElementById('connectionsEmpty');
|
||
const listEl = document.getElementById('connectionsListInner');
|
||
|
||
if (!proto) {
|
||
loading.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// Skip poll re-renders while the rename editor is open — a full innerHTML
|
||
// rebuild would destroy the input mid-typing. `force` bypasses this for
|
||
// the editor's own save path, which wants an immediate silent refresh.
|
||
if (isPoll && !force && document.querySelector('#connectionsListInner .rename-editor')) {
|
||
clearTimeout(window.connPollTimer);
|
||
if (document.getElementById('connectionsSection').style.display !== 'none') {
|
||
window.connPollTimer = setTimeout(function() { loadConnections(true); }, 5000);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (window.connLoading) return; // don't stack overlapping polls
|
||
window.connLoading = true;
|
||
if (!isPoll) loading.style.display = ''; // silent refresh on polls
|
||
emptyEl.classList.add('hidden');
|
||
try {
|
||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
|
||
// The rename editor may have opened while this poll was in flight —
|
||
// re-rendering now would destroy it mid-typing.
|
||
if (isPoll && !force && listEl.querySelector('.rename-editor')) return;
|
||
loading.style.display = 'none';
|
||
if (!data.clients || data.clients.length === 0) {
|
||
listEl.innerHTML = '';
|
||
emptyEl.classList.remove('hidden');
|
||
return;
|
||
}
|
||
window.connectionsStore = {};
|
||
listEl.innerHTML = '';
|
||
data.clients.forEach((client, idx) => {
|
||
const userData = client.userData || {};
|
||
window.connectionsStore[client.clientId] = userData;
|
||
const name = userData.clientName || `Connection ${idx}`;
|
||
const created = userData.creationDate || '';
|
||
const handshake = userData.latestHandshake || '';
|
||
const received = userData.dataReceived || '';
|
||
const sent = userData.dataSent || '';
|
||
const initial = name.charAt(0).toUpperCase();
|
||
const enabled = (client.enabled !== undefined) ? client.enabled : (userData.enabled !== false);
|
||
const hasPrivKey = !!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt';
|
||
const assignedUser = client.assigned_user || '';
|
||
client._online = false; // reset each render; set by the handshake badge below
|
||
|
||
// Peer internal IP shown next to the name
|
||
const peerIp = userData.clientIp || (userData.allowedIps || '').match(/(\d+\.\d+\.\d+\.\d+)/)?.[1] || '';
|
||
const ipHtml = peerIp ? ` <span class="client-ip">${escapeHtml(peerIp)}</span>` : '';
|
||
|
||
let metaHtml = '';
|
||
if (assignedUser) metaHtml += `<span class="badge badge-info" style="font-size:0.65rem;">👤 ${escapeHtml(assignedUser)}</span>`;
|
||
if (created) metaHtml += `<span>📅 ${formatDate(created)}</span>`;
|
||
|
||
// Connection flood warnings (P2P/torrent detection), newest last
|
||
let warningsHtml = '';
|
||
if (Array.isArray(userData.connWarnings) && userData.connWarnings.length) {
|
||
warningsHtml = '<div class="conn-warnings">' + userData.connWarnings.map(ev => {
|
||
const d = new Date(ev.ts * 1000);
|
||
const dateStr = String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.' + d.getFullYear();
|
||
const timeStr = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||
const text = _('conn_warning_text')
|
||
.replace('{date}', dateStr)
|
||
.replace('{time}', timeStr)
|
||
.replace('{count}', ev.count);
|
||
return `<div class="conn-warning">${escapeHtml(text)}</div>`;
|
||
}).join('') +
|
||
`<button class="btn btn-secondary btn-sm btn-icon conn-warning-clear" onclick="clearConnWarnings('${escapeJs(client.clientId)}')" title="${_('clear_warnings')}" style="margin-top:4px;">${ICON_TRASH}</button></div>`;
|
||
}
|
||
|
||
// Per-peer bandwidth limit badge (AWG): ⚡∞ by default, 🐢 X Mbps when a limit is set
|
||
const maxSpeed = parseFloat(userData.maxSpeed || 0) || 0;
|
||
if (proto.startsWith('awg') || protoBase(proto) === 'wireguard') metaHtml += `<span class="traffic-badge traffic-limit" title="${_('max_speed')}">${maxSpeed > 0 ? '🐢 ' + maxSpeed + ' Mbps' : '⚡<span style="font-size:1.25em; line-height:0;">∞</span>'}</span>`;
|
||
|
||
if (proto === 'telemt') {
|
||
if (userData.total_octets) metaHtml += `<span class="traffic-badge">📊 ${formatBytes(userData.total_octets)}</span>`;
|
||
if (userData.current_connections !== undefined) metaHtml += `<span>🔌 ${userData.current_connections} conns</span>`;
|
||
if (userData.active_ips !== undefined) metaHtml += `<span>📍 ${userData.active_ips} IPs</span>`;
|
||
if (userData.quota) metaHtml += `<span class="badge" style="font-size:0.65rem;">Quota: ${formatBytes(userData.quota)}</span>`;
|
||
if (userData.expiry) metaHtml += `<span class="badge" style="font-size:0.65rem;">⌛ ${formatDate(userData.expiry)}</span>`;
|
||
} else {
|
||
const hsSec = parseHandshakeAgo(handshake);
|
||
const isOnline = hsSec !== null && hsSec < 180;
|
||
if (handshake) metaHtml += `<span class="meta-handshake${isOnline ? ' meta-online' : ''}">🤝 ${formatHandshakeCompact(handshake)}</span>`;
|
||
// awg show reports transfer from the SERVER perspective:
|
||
// "received" = client upload, "sent" = client download.
|
||
if (sent) metaHtml += `<span class="traffic-badge traffic-down">↓ ${sent}</span>`;
|
||
if (received) metaHtml += `<span class="traffic-badge traffic-up">↑ ${received}</span>`;
|
||
const rates = computeRates(client.clientId, userData);
|
||
if (rates) metaHtml += `<span class="traffic-badge speed-badge${rates.active ? ' speed-active' : ''}">↓${formatBytes(Math.round(rates.rx))}/s ↑${formatBytes(Math.round(rates.tx))}/s</span>`;
|
||
if (isOnline) client._online = true;
|
||
}
|
||
|
||
if (!enabled) metaHtml += `<span class="badge badge-danger" style="font-size:0.65rem">${_('stop')}</span>`;
|
||
|
||
const disabledStyle = enabled ? '' : 'opacity:0.5;';
|
||
// 💡 is used for both states; the "off" look lives in CSS (.peer-toggle-off)
|
||
const toggleClass = enabled ? 'peer-toggle' : 'peer-toggle peer-toggle-off';
|
||
const toggleTitle = enabled ? _('stop_peer_title') : _('start_peer_title');
|
||
// Show config button for all WireGuard-based clients.
|
||
// If clientPrivateKey is missing (created via native app), we show a warning instead.
|
||
const canShowConfig = proto === 'xray' || true; // always show 📄 button
|
||
|
||
listEl.innerHTML += `
|
||
<div class="client-item" style="${disabledStyle}" data-maxspeed="${maxSpeed}" data-assigned-user="${client.assigned_user_id || ''}">
|
||
<div class="client-info">
|
||
<div class="client-avatar${client._online ? ' avatar-online' : ''}">${initial}</div>
|
||
<div>
|
||
<div class="client-name">${escapeHtml(name)}${ipHtml}</div>
|
||
<div class="client-meta">${metaHtml || `<span class="text-muted">${_('no_data')}</span>`}</div>
|
||
${warningsHtml}
|
||
</div>
|
||
</div>
|
||
<div class="client-actions">
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="startRenameConnection('${escapeJs(client.clientId)}', this)" title="${_('rename')}">${ICON_PENCIL}</button>
|
||
<button class="btn btn-secondary btn-sm btn-icon" onclick="showConnectionConfig('${escapeJs(client.clientId)}', '${escapeJs(name)}', ${!!userData.clientPrivateKey || proto === 'xray' || proto === 'telemt'})" title="${_('config')}">📄</button>
|
||
${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">⚙️</button>` : ''}
|
||
<button class="btn btn-secondary btn-sm btn-icon ${toggleClass}" onclick="toggleConnection('${escapeJs(client.clientId)}', ${!enabled}, this)" title="${toggleTitle}">💡</button>
|
||
<button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">${ICON_TRASH}</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
} catch (err) {
|
||
loading.style.display = 'none';
|
||
showToast(_('connections_load_error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
window.connLoading = false;
|
||
highlightPeerFromQuery();
|
||
// Auto-refresh every 5s while the connections section is visible (live speed/handshake)
|
||
clearTimeout(window.connPollTimer);
|
||
if (document.getElementById('connectionsSection').style.display !== 'none') {
|
||
window.connPollTimer = setTimeout(function() { loadConnections(true); }, 5000);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Deep link from the users page: /server/N?protocol=awg2&highlight=<client_id>
|
||
// switches to the right instance, frames the peer's card and scrolls it
|
||
// into the middle of the viewport. The id is kept in a var so the 5s poll
|
||
// re-applies the frame after every re-render; URL params are cleaned so a
|
||
// refresh doesn't scroll again.
|
||
let highlightedPeerId = null;
|
||
|
||
function applyPeerHighlight(scroll) {
|
||
if (!highlightedPeerId) return;
|
||
const btn = document.querySelector(`[onclick*="${CSS.escape(highlightedPeerId)}"]`);
|
||
const row = btn && btn.closest('.client-item');
|
||
if (!row) return;
|
||
row.classList.add('peer-highlight');
|
||
if (scroll) row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
}
|
||
|
||
function highlightPeerFromQuery() {
|
||
const params = new URLSearchParams(location.search);
|
||
const proto = params.get('protocol');
|
||
const target = params.get('highlight');
|
||
if (!proto && !target) { applyPeerHighlight(false); return; }
|
||
const select = document.getElementById('connProtoSelect');
|
||
if (proto && select.value !== proto
|
||
&& [...select.options].some(o => o.value === proto)) {
|
||
select.value = proto;
|
||
loadConnections();
|
||
return;
|
||
}
|
||
if (target) {
|
||
highlightedPeerId = target;
|
||
history.replaceState(null, '', location.pathname);
|
||
applyPeerHighlight(true);
|
||
}
|
||
}
|
||
|
||
async function addConnection() {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const connName = document.getElementById('connectionName').value.trim() || 'Connection';
|
||
const userId = document.getElementById('connectionUserId').value || null;
|
||
const btn = document.getElementById('addConnBtn');
|
||
const text = document.getElementById('addConnBtnText');
|
||
const spinner = document.getElementById('addConnSpinner');
|
||
btn.disabled = true;
|
||
text.textContent = _('creating');
|
||
spinner.classList.remove('hidden');
|
||
try {
|
||
const params = {
|
||
protocol: proto, name: connName, user_id: userId,
|
||
};
|
||
if (proto === 'telemt') {
|
||
params.telemt_quota = document.getElementById('telemtQuota').value || null;
|
||
params.telemt_max_ips = parseInt(document.getElementById('telemtMaxIps').value) || null;
|
||
params.telemt_expiry = document.getElementById('telemtExpiry').value || null;
|
||
params.telemt_secret = document.getElementById('telemtSecret').value || null;
|
||
params.telemt_ad_tag = document.getElementById('telemtAdTag').value || null;
|
||
params.telemt_max_conns = parseInt(document.getElementById('telemtMaxConns').value) || null;
|
||
if (isEditingConn) params.client_id = editingClientId;
|
||
}
|
||
const result = await apiCall(isEditingConn ? `/api/servers/${SERVER_ID}/connections/edit` : `/api/servers/${SERVER_ID}/connections/add`, 'POST', params);
|
||
if (isEditingConn) {
|
||
showToast(_('connection_updated'), 'success');
|
||
closeModal('addConnectionModal');
|
||
} else {
|
||
showToast(_('connection_created').replace('{}', connName), 'success');
|
||
closeModal('addConnectionModal');
|
||
document.getElementById('connectionName').value = '';
|
||
if (result.config) {
|
||
currentConfig = result.config;
|
||
currentVpnLink = result.vpn_link || '';
|
||
currentConfigName = connName.replace(/\s+/g, '_');
|
||
currentConfigClientId = result.client_id || null;
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
|
||
const cfgTa = document.getElementById('configText'); cfgTa.value = result.config; cfgTa.readOnly = false;
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
|
||
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : '');
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
generateQR(result.config, result.vpn_qr_chunks);
|
||
}
|
||
}
|
||
loadConnections();
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
text.textContent = _('create');
|
||
spinner.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
let isEditingConn = false;
|
||
let editingClientId = null;
|
||
|
||
function openAddConnectionModal(isEdit = false, clientData = null) {
|
||
isEditingConn = isEdit;
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const telemtOpts = document.getElementById('telemtConnOptions');
|
||
|
||
document.getElementById('addConnectionModalTitle').textContent = isEdit ? _('edit_connection') : _('add_connection');
|
||
document.getElementById('addConnBtnText').textContent = isEdit ? _('save') : _('create');
|
||
document.getElementById('connectionNameGroup').style.display = isEdit ? 'none' : 'block';
|
||
|
||
if (proto === 'telemt') {
|
||
telemtOpts.style.display = 'block';
|
||
if (isEdit && clientData) {
|
||
editingClientId = clientData.clientName;
|
||
document.getElementById('telemtQuota').value = clientData.quota || '';
|
||
document.getElementById('telemtMaxIps').value = '';
|
||
document.getElementById('telemtExpiry').value = clientData.expiry || '';
|
||
document.getElementById('telemtSecret').value = clientData.token || '';
|
||
document.getElementById('telemtAdTag').value = clientData.user_ad_tag || '';
|
||
document.getElementById('telemtMaxConns').value = clientData.max_tcp_conns || '';
|
||
} else {
|
||
document.getElementById('telemtQuota').value = '';
|
||
document.getElementById('telemtMaxIps').value = '';
|
||
document.getElementById('telemtExpiry').value = '';
|
||
document.getElementById('telemtSecret').value = '';
|
||
document.getElementById('telemtAdTag').value = '';
|
||
document.getElementById('telemtMaxConns').value = '';
|
||
}
|
||
} else {
|
||
telemtOpts.style.display = 'none';
|
||
}
|
||
openModal('addConnectionModal');
|
||
}
|
||
|
||
function editConnection(clientId) {
|
||
const userData = window.connectionsStore[clientId];
|
||
if (userData) {
|
||
userData.clientName = clientId;
|
||
openAddConnectionModal(true, userData);
|
||
}
|
||
}
|
||
|
||
async function removeConnection(clientId) {
|
||
if (!confirm(_('delete_connection_confirm'))) return;
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/connections/remove`, 'POST', {
|
||
protocol: proto, client_id: clientId,
|
||
});
|
||
showToast(_('connection_deleted'), 'success');
|
||
loadConnections();
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Clear connection-flood (torrent) warnings history for a peer
|
||
async function clearConnWarnings(clientId) {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/connections/clear_warnings`, 'POST', {
|
||
protocol: proto, client_id: clientId,
|
||
});
|
||
showToast(_('warnings_cleared'), 'success');
|
||
loadConnections();
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function startRenameConnection(clientId, btn) {
|
||
const item = btn.closest('.client-item');
|
||
const nameEl = item.querySelector('.client-name');
|
||
const origHtml = nameEl.innerHTML;
|
||
// .client-name also contains the .client-ip span; edit the name only
|
||
const clone = nameEl.cloneNode(true);
|
||
clone.querySelectorAll('.client-ip').forEach(el => el.remove());
|
||
const oldName = clone.textContent.trim();
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const isAwg = proto.startsWith('awg') || protoBase(proto) === 'wireguard';
|
||
const curSpeed = item.dataset.maxspeed || '0';
|
||
const currentAssigned = item.dataset.assignedUser || '';
|
||
nameEl.innerHTML = `
|
||
<span class="rename-editor" style="display:inline-flex;gap:12px;align-items:flex-end;flex-wrap:wrap;width:100%;">
|
||
<label style="display:flex;flex-direction:column;gap:2px;font-size:0.7rem;opacity:.7;flex:1;min-width:140px;">
|
||
${_('name')}
|
||
<input type="text" class="form-input" style="padding:2px 8px;font-size:0.85rem;width:100%;height:auto;" value="${escapeHtml(oldName)}">
|
||
</label>
|
||
${isAwg ? `<label style="display:flex;flex-direction:column;gap:2px;font-size:0.7rem;opacity:.7;flex:1;min-width:140px;">
|
||
${_('speed_limit_unlim')}
|
||
<input type="number" class="form-input rename-speed" min="0" step="0.1" style="padding:2px 8px;font-size:0.85rem;width:100%;height:auto;" value="${curSpeed}" placeholder="∞">
|
||
</label>` : ''}
|
||
<label style="display:flex;flex-direction:column;gap:2px;font-size:0.7rem;opacity:.7;flex:1;min-width:140px;">
|
||
${_('assign_user')}
|
||
<select class="form-select rename-user" style="padding:2px 8px;font-size:0.85rem;width:100%;height:auto;">
|
||
<option value="">${_('loading')}</option>
|
||
</select>
|
||
</label>
|
||
<button class="btn btn-secondary btn-sm btn-icon" title="${_('save')}">✔</button>
|
||
<button class="btn btn-secondary btn-sm btn-icon rename-cancel" title="${_('cancel')}">✖</button>
|
||
</span>
|
||
`;
|
||
const input = nameEl.querySelector('input');
|
||
const speedInput = nameEl.querySelector('.rename-speed');
|
||
const userSelect = nameEl.querySelector('.rename-user');
|
||
const saveBtn = nameEl.querySelector('button:not(.rename-cancel)');
|
||
const cancelBtn = nameEl.querySelector('.rename-cancel');
|
||
// Fill the user list once; the 5s poll already skips open editors, so
|
||
// the selection survives until Save/Cancel.
|
||
(async () => {
|
||
try {
|
||
const ud = await apiCall('/api/users?size=1000');
|
||
if (!userSelect.isConnected) return;
|
||
const users = (ud.users || []).slice().sort((a, b) => {
|
||
const an = a.role === 'none' ? 0 : 1, bn = b.role === 'none' ? 0 : 1;
|
||
return an - bn || String(a.username).localeCompare(String(b.username));
|
||
});
|
||
// "No link" first and selected by default: opening the editor
|
||
// must never force a user assignment on save.
|
||
userSelect.innerHTML = `<option value="">${_('no_user_option')}</option>` + users.map(u =>
|
||
`<option value="${u.id}" ${u.id === currentAssigned ? 'selected' : ''}>${escapeHtml(u.username)} (${u.role === 'none' ? _('role_none_badge') : u.role})</option>`
|
||
).join('');
|
||
if (currentAssigned) userSelect.value = currentAssigned;
|
||
} catch (e) {
|
||
if (userSelect.isConnected) userSelect.innerHTML = `<option value="">${_('error')}</option>`;
|
||
}
|
||
})();
|
||
const doSave = async () => {
|
||
// Immediate busy feedback so a second click is impossible and the
|
||
// user sees the save is in flight (rename + tc apply takes 2-4s).
|
||
saveBtn.disabled = true;
|
||
cancelBtn.disabled = true;
|
||
input.disabled = true;
|
||
if (speedInput) speedInput.disabled = true;
|
||
if (userSelect) userSelect.disabled = true;
|
||
saveBtn.innerHTML = '<span class="spinner" style="display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,0.25);border-top-color:#1c1c30;border-radius:50%;animation:spin 0.6s linear infinite;"></span>';
|
||
await saveRenameConnection(clientId, input.value, speedInput ? (parseFloat(speedInput.value) || 0) : -1,
|
||
userSelect ? userSelect.value : undefined, currentAssigned);
|
||
// On success loadConnections() rerenders the list and wipes this
|
||
// editor; restore only when it is still in the DOM (error path).
|
||
if (saveBtn.isConnected) {
|
||
saveBtn.disabled = false;
|
||
cancelBtn.disabled = false;
|
||
input.disabled = false;
|
||
if (speedInput) speedInput.disabled = false;
|
||
if (userSelect) userSelect.disabled = false;
|
||
saveBtn.innerHTML = '✔';
|
||
}
|
||
};
|
||
// Cancel closes the editor locally, without reloading the whole panel.
|
||
const cancelEdit = () => { nameEl.innerHTML = origHtml; };
|
||
saveBtn.addEventListener('click', doSave);
|
||
cancelBtn.addEventListener('click', cancelEdit);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') doSave();
|
||
if (e.key === 'Escape') cancelEdit();
|
||
});
|
||
if (speedInput) speedInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') doSave();
|
||
if (e.key === 'Escape') cancelEdit();
|
||
});
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
|
||
async function saveRenameConnection(clientId, rawName, maxSpeed = -1, newUserId, currentAssigned) {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const newName = (rawName || '').trim();
|
||
if (!newName) {
|
||
showToast(_('enter_connection_name'), 'error');
|
||
return;
|
||
}
|
||
try {
|
||
const result = await apiCall(`/api/servers/${SERVER_ID}/connections/rename`, 'POST', {
|
||
protocol: proto, client_id: clientId, new_name: newName, max_speed: maxSpeed,
|
||
});
|
||
const toast = result.speed_warning
|
||
? [ _('error') + ': ' + result.speed_warning, 'error' ]
|
||
: [ _('connection_renamed'), 'success' ];
|
||
// User (re)link: only when the selection actually changed.
|
||
if (newUserId !== undefined && newUserId !== (currentAssigned || '')) {
|
||
try {
|
||
if (currentAssigned) {
|
||
await apiCall(`/api/users/${currentAssigned}/connections/unlink`, 'POST', {
|
||
server_id: SERVER_ID, protocol: proto, client_id: clientId,
|
||
});
|
||
}
|
||
if (newUserId) {
|
||
await apiCall(`/api/users/${newUserId}/connections/add`, 'POST', {
|
||
server_id: SERVER_ID, protocol: proto, client_id: clientId, name: newName,
|
||
});
|
||
showToast(_('conn_linked'), 'success');
|
||
} else {
|
||
showToast(_('conn_unlinked'), 'success');
|
||
}
|
||
} catch (linkErr) {
|
||
showToast(_('error') + ': ' + linkErr.message, 'error');
|
||
}
|
||
}
|
||
// Refresh only after completing any user-link change.
|
||
// If the config modal is open for this connection, retitle it too.
|
||
if (currentConfigClientId === clientId &&
|
||
document.getElementById('configModal').classList.contains('active')) {
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${result.name}`;
|
||
currentConfigName = result.name.replace(/\s+/g, '_');
|
||
}
|
||
await loadConnections(true, true); // silent refresh, no overlay
|
||
showToast(toast[0], toast[1]);
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveConnectionConfig() {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const text = document.getElementById('configText').value;
|
||
if (!text.trim()) {
|
||
showToast(_('error'), 'error');
|
||
return;
|
||
}
|
||
try {
|
||
const result = await apiCall(`/api/servers/${SERVER_ID}/connections/config/save`, 'POST', {
|
||
protocol: proto, client_id: currentConfigClientId, config: text,
|
||
});
|
||
currentConfig = text;
|
||
currentVpnLink = result.vpn_link || '';
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
generateQR(text, result.vpn_qr_chunks);
|
||
showToast(_('config_saved'), 'success');
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function showConnectionConfig(clientId, connName, hasKey) {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
currentConfigClientId = clientId;
|
||
const canEditCfg = ['awg', 'awg2', 'awg_legacy', 'wireguard'].includes(protoBase(proto));
|
||
document.getElementById('saveConnConfigBtn').style.display = canEditCfg ? '' : 'none';
|
||
document.getElementById('configText').readOnly = !canEditCfg;
|
||
|
||
// Restore tabs visibility first
|
||
document.getElementById('panel-vpn').style.display = (proto === 'telemt' ? 'none' : '');
|
||
document.getElementById('panel-qr').style.display = '';
|
||
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' ? 'none' : '');
|
||
document.querySelectorAll('.config-tab')[2].style.display = '';
|
||
|
||
// Client was created via native Amnezia app — private key is not stored server-side
|
||
if (!hasKey) {
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
|
||
currentConfig = '';
|
||
document.getElementById('saveConnConfigBtn').style.display = 'none';
|
||
currentVpnLink = '';
|
||
currentConfigName = connName.replace(/\s+/g, '_');
|
||
const cfgTa = document.getElementById('configText'); cfgTa.value = `${_('config_unavailable')}\n\n${_('client_public_key')}: ${clientId}`; cfgTa.readOnly = true;
|
||
document.getElementById('vpnLinkText').textContent = '';
|
||
document.getElementById('panel-vpn').style.display = 'none';
|
||
document.getElementById('panel-qr').style.display = 'none';
|
||
document.querySelectorAll('.config-tab')[1].style.display = 'none';
|
||
document.querySelectorAll('.config-tab')[2].style.display = 'none';
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
showToast(_('loading'), 'info');
|
||
const result = await apiCall(`/api/servers/${SERVER_ID}/connections/config`, 'POST', {
|
||
protocol: proto, client_id: clientId,
|
||
});
|
||
if (result.config) {
|
||
currentConfig = result.config;
|
||
currentVpnLink = result.vpn_link || '';
|
||
currentConfigName = connName.replace(/\s+/g, '_');
|
||
document.getElementById('configModalTitle').textContent = `${_('config')} — ${connName}`;
|
||
const cfgTa = document.getElementById('configText'); cfgTa.value = result.config; cfgTa.readOnly = false;
|
||
document.getElementById('vpnLinkText').textContent = currentVpnLink;
|
||
switchConfigTab('conf');
|
||
openModal('configModal');
|
||
generateQR(result.config, result.vpn_qr_chunks);
|
||
}
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function toggleConnection(clientId, enable, btn) {
|
||
const proto = document.getElementById('connProtoSelect').value;
|
||
const action = enable ? _('enable_connection_progress') : _('disable_connection_progress');
|
||
// Lock the whole client row and swap the bulb for a spinner until done
|
||
const row = btn ? btn.closest('.client-item') : null;
|
||
let originalContent = null;
|
||
if (row) {
|
||
row.classList.add('client-toggling');
|
||
row.querySelectorAll('button').forEach(b => b.disabled = true);
|
||
}
|
||
if (btn) {
|
||
originalContent = btn.innerHTML;
|
||
btn.innerHTML = '<span class="spinner"></span>';
|
||
}
|
||
const restore = () => {
|
||
if (row) {
|
||
row.classList.remove('client-toggling');
|
||
row.querySelectorAll('button').forEach(b => b.disabled = false);
|
||
}
|
||
if (btn && originalContent !== null) btn.innerHTML = originalContent;
|
||
};
|
||
try {
|
||
showToast(action, 'info');
|
||
await apiCall(`/api/servers/${SERVER_ID}/connections/toggle`, 'POST', {
|
||
protocol: proto, client_id: clientId, enable: enable,
|
||
});
|
||
showToast(enable ? _('connection_enabled') : _('connection_disabled'), 'success');
|
||
// Update just this row in place — no full list reload. The 5s
|
||
// poll reconciles speeds/badges anyway.
|
||
if (row && btn) {
|
||
row.style.opacity = enable ? '' : '0.5';
|
||
btn.classList.toggle('peer-toggle-off', !enable);
|
||
btn.title = enable ? _('stop') : _('starting');
|
||
btn.setAttribute('onclick', `toggleConnection('${escapeJs(clientId)}', ${!enable}, this)`);
|
||
const meta = row.querySelector('.client-meta');
|
||
if (meta) {
|
||
const offBadge = meta.querySelector('.badge-danger');
|
||
if (!enable && !offBadge) {
|
||
meta.insertAdjacentHTML('afterbegin', `<span class="badge badge-danger" style="font-size:0.65rem">${_('stop')}</span>`);
|
||
} else if (enable && offBadge) {
|
||
offBadge.remove();
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
showToast(_('error') + ': ' + err.message, 'error');
|
||
} finally {
|
||
// Also on success: if loadConnections() fails the row it was
|
||
// supposed to rebuild would stay disabled with a spinning bulb.
|
||
restore();
|
||
}
|
||
}
|
||
|
||
// ========== Config Tabs ==========
|
||
function switchConfigTab(tab) {
|
||
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
|
||
const tabs = document.querySelectorAll('.config-tab');
|
||
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
|
||
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
|
||
tabs[tabIdx[tab]].classList.add('active');
|
||
document.getElementById(panels[tab]).classList.add('active');
|
||
// the frame series only animates while its panel is on screen
|
||
if (tab === 'qr') { renderQR(); } else { stopQrAnimation(); }
|
||
}
|
||
|
||
// The QR shows one of two payloads: Amnezia's own config format, split into
|
||
// the frame series ImportController::parseQrCodeChunk reassembles, or the
|
||
// raw config, which is all AmneziaWG and wg-quick understand. The series
|
||
// exists because a whole config in one code lands at QR version 21-26,
|
||
// while the Android scanner reads 640x480 camera frames and cannot decode
|
||
// anything that dense. Every frame below stays under 55x55 modules.
|
||
const QR_FRAME_MS = 800;
|
||
|
||
let currentQrConfig = '';
|
||
let currentQrChunks = [];
|
||
let currentQrFormat = 'amnezia';
|
||
let qrFrame = 0;
|
||
let qrTimer = null;
|
||
|
||
function setQrFormat(format) {
|
||
currentQrFormat = format;
|
||
qrFrame = 0;
|
||
renderQR();
|
||
}
|
||
|
||
function generateQR(text, chunks) {
|
||
currentQrConfig = text || '';
|
||
currentQrChunks = chunks || [];
|
||
qrFrame = 0;
|
||
renderQR();
|
||
}
|
||
|
||
function stopQrAnimation() {
|
||
if (qrTimer) { clearInterval(qrTimer); qrTimer = null; }
|
||
}
|
||
|
||
function drawQr(text, size) {
|
||
const container = document.getElementById('qrCode');
|
||
if (!container) return;
|
||
container.innerHTML = '';
|
||
try {
|
||
new QRCode(container, {
|
||
text: text, width: size, height: size,
|
||
colorDark: '#000000', colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.L
|
||
});
|
||
} catch (e) {
|
||
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${_('qr_error')}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderQR() {
|
||
stopQrAnimation();
|
||
const named = currentQrFormat === 'amnezia' && currentQrChunks.length > 0;
|
||
const switcher = document.getElementById('qrFormat');
|
||
if (switcher) {
|
||
switcher.classList.toggle('hidden', currentQrChunks.length === 0);
|
||
switcher.querySelectorAll('.qr-format-btn').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.fmt === (named ? 'amnezia' : 'wireguard')));
|
||
}
|
||
const hint = document.getElementById('qrFormatHint');
|
||
if (!named) {
|
||
if (hint) hint.textContent = _('qr_format_wireguard_hint');
|
||
drawQr(currentQrConfig, qrSize(document.getElementById('qrCode') || document.body, 280));
|
||
return;
|
||
}
|
||
const paint = () => {
|
||
drawQr(currentQrChunks[qrFrame], qrSize(document.getElementById('qrCode') || document.body, 300));
|
||
if (hint) {
|
||
hint.textContent = currentQrChunks.length > 1
|
||
? `${_('qr_format_amnezia_hint')} ${qrFrame + 1}/${currentQrChunks.length}`
|
||
: _('qr_format_amnezia_hint');
|
||
}
|
||
};
|
||
paint();
|
||
if (currentQrChunks.length > 1) {
|
||
qrTimer = setInterval(() => {
|
||
// the modal was closed or another tab opened -- offsetParent is
|
||
// null once the panel is off screen, so stop burning a timer
|
||
const container = document.getElementById('qrCode');
|
||
if (!container || !container.offsetParent) { stopQrAnimation(); return; }
|
||
qrFrame = (qrFrame + 1) % currentQrChunks.length;
|
||
paint();
|
||
}, QR_FRAME_MS);
|
||
}
|
||
}
|
||
|
||
// ========== Utilities ==========
|
||
function formatDate(dateStr) {
|
||
try {
|
||
const d = new Date(dateStr);
|
||
if (isNaN(d)) return dateStr;
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
} catch { return dateStr; }
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (!bytes || bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
// ========== Peer activity / speed (wg-easy style, client-side deltas) ==========
|
||
// Parse "2 hours, 29 minutes, 55 seconds ago" / "Now" into total seconds
|
||
function parseHandshakeAgo(str) {
|
||
if (!str) return null;
|
||
const s = String(str).toLowerCase().trim();
|
||
if (s.startsWith('now')) return 0;
|
||
let total = 0, matched = false, m;
|
||
const mult = { day: 86400, hour: 3600, minute: 60, second: 1 };
|
||
const re = /(\d+)\s*(day|hour|minute|second)s?/g;
|
||
while ((m = re.exec(s)) !== null) {
|
||
matched = true;
|
||
total += parseInt(m[1], 10) * mult[m[2]];
|
||
}
|
||
return matched ? total : null;
|
||
}
|
||
|
||
// Compact fixed-ish length: "45s ago" / "3m 51s ago" / "2h 29m ago" / "2d 3h ago"
|
||
function formatHandshakeCompact(str) {
|
||
const sec = parseHandshakeAgo(str);
|
||
if (sec === null) return str;
|
||
if (sec < 60) return sec + 's ago';
|
||
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's ago';
|
||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm ago';
|
||
return Math.floor(sec / 86400) + 'd ' + Math.floor((sec % 86400) / 3600) + 'h ago';
|
||
}
|
||
|
||
// Speed = delta of cumulative bytes between two polls (same approach as wg-easy)
|
||
window.speedStore = window.speedStore || {};
|
||
function computeRates(clientId, userData) {
|
||
const rx = userData.dataReceivedBytes, tx = userData.dataSentBytes;
|
||
if (rx === undefined || tx === undefined) return null;
|
||
const now = Date.now();
|
||
const prev = window.speedStore[clientId];
|
||
window.speedStore[clientId] = { ts: now, rx: rx, tx: tx };
|
||
if (!prev) return { rx: 0, tx: 0, active: false };
|
||
const dt = (now - prev.ts) / 1000;
|
||
if (dt <= 0.5) return { rx: 0, tx: 0, active: false };
|
||
// Counter reset (peer re-created) must not produce negative spikes
|
||
const rxRate = Math.max(0, (rx - prev.rx) / dt);
|
||
const txRate = Math.max(0, (tx - prev.tx) / dt);
|
||
return { rx: rxRate, tx: txRate, active: rxRate > 0 || txRate > 0 };
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function escapeJs(str) {
|
||
return str.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||
}
|
||
|
||
// ========== Management ==========
|
||
async function rebootServer() {
|
||
if (!confirm('Are you sure you want to reboot the server? Disconnections will occur.')) return;
|
||
try {
|
||
showToast('Rebooting server...', 'info');
|
||
await apiCall(`/api/servers/${SERVER_ID}/reboot`, 'POST');
|
||
showToast('Server is rebooting', 'success');
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function clearServer() {
|
||
if (!confirm("⚠️ WARNING: This will completely delete all Amnezia configurations, containers, and data from /opt/amnezia on the server! Are you absolutely sure?")) return;
|
||
try {
|
||
showToast('Clearing server from Amnezia software...', 'info');
|
||
await apiCall(`/api/servers/${SERVER_ID}/clear`, 'POST');
|
||
showToast('Server cleared successfully!', 'success');
|
||
setTimeout(() => location.reload(), 1500);
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function removeServer() {
|
||
if (!confirm("Are you sure you want to remove this server from the panel? (The server itself will not be erased).")) return;
|
||
try {
|
||
await apiCall(`/api/servers/${SERVER_ID}/delete`, 'POST');
|
||
window.location.href = '/';
|
||
} catch (err) {
|
||
showToast('Error: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Init ==========
|
||
applyInstalledAppsVisibility();
|
||
checkServer();
|
||
loadServerStats();
|
||
</script>
|
||
{% endblock %}
|