Files
test2andCursor 7ef408afe7 Add reverse proxy masking and Cloudflare panel SSL via DNS-01.
Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 00:41:50 +03:00

1818 lines
93 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title_extra %} — {{ server.name }}{% endblock %}
{% block head_extra %}
<style>
.client-actions {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: var(--space-xs) !important;
align-items: center;
}
</style>
{% endblock %}
{% block content %}
<a href="/" class="back-link">{{ _('back_to_servers') }}</a>
<!-- Server Info Header -->
<div class="card" style="margin-bottom: var(--space-xl);">
<div class="flex items-center justify-between" style="flex-wrap: wrap; gap: var(--space-md);">
<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="flex gap-sm items-center">
<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="flex gap-md" style="flex-wrap:wrap" id="statusBadges"></div>
</div>
</div>
<!-- Protocol Installation Section -->
<div class="flex items-center gap-md"
style="margin-bottom: var(--space-xl); border-bottom: 1px solid var(--border-color); padding-bottom: 2px;">
<button class="nav-tab active" id="tab-protocols" onclick="switchCategory('protocols')"
style="background: none; border: none; padding: 10px 16px; font-size: 1rem; color: var(--text-primary); cursor: pointer; border-bottom: 2px solid var(--accent); font-weight: 600;">
<span class="icon">🔐</span> {{ _('protocols') }} <span id="protocols-count"
style="font-size: 0.8rem; opacity: 0.6; margin-left: 4px;">0/5</span>
</button>
<button class="nav-tab" id="tab-services" onclick="switchCategory('services')"
style="background: none; border: none; padding: 10px 16px; font-size: 1rem; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent;">
<span class="icon">🛠</span> {{ _('services') }} <span id="services-count"
style="font-size: 0.8rem; opacity: 0.6; margin-left: 4px;">0/3</span>
</button>
<button class="nav-tab" id="tab-management" onclick="switchCategory('management')"
style="background: none; border: none; padding: 10px 16px; font-size: 1rem; color: var(--text-muted); cursor: pointer; border-bottom: 2px solid transparent;">
<span class="icon">⚙️</span> {{ _('management') }}
</button>
</div>
<div class="protocol-cards" id="protocols-grid">
<!-- AWG2 Card (first - new version) -->
<div class="card card-hover protocol-card protocol-awg" id="proto-awg2">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon"></div>
<div class="flex gap-sm" id="awg2-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">AmneziaWG 2.0 <span
style="font-size:0.65rem; background:var(--accent); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">NEW</span>
</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">🔮</div>
<div class="flex gap-sm" id="awg-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">AmneziaWG</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">📡</div>
<div class="flex gap-sm" id="awg-legacy-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">AmneziaWG Legacy</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon"></div>
<div class="flex gap-sm" id="xray-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Xray (VLESS-Reality)</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon"></div>
<div class="flex gap-sm" id="telemt-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Telemt (Telegram Proxy)</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">🔒</div>
<div class="flex gap-sm" id="wireguard-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">WireGuard</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>
</div>
<div class="protocol-cards hidden" id="services-grid">
<!-- DNS Card -->
<div class="card card-hover protocol-card protocol-dns" id="proto-dns">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">🔍</div>
<div class="flex gap-sm" id="dns-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">AmneziaDNS</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">🛡️</div>
<div class="flex gap-sm" id="adguard-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">AdGuard Home</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">🧦</div>
<div class="flex gap-sm" id="socks5-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">SOCKS5 Proxy</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>
<!-- Reverse Proxy Card -->
<div class="card card-hover protocol-card protocol-revproxy" id="proto-revproxy">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">🛡️</div>
<div class="flex gap-sm" id="revproxy-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">{{ _('revproxy_title') }}</div>
<div class="protocol-desc">
{{ _('revproxy_desc') }}
</div>
<div class="protocol-status" id="revproxy-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="revproxy-info" class="hidden">
<div class="protocol-info" id="revproxy-info-grid"></div>
</div>
<div class="flex gap-sm" id="revproxy-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('revproxy')" id="revproxy-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
</div>
<div class="protocol-cards hidden" id="management-grid" style="margin-bottom: var(--space-xl);">
<div class="card" style="margin-bottom: var(--space-md); width:100%; max-width:600px;">
<div class="card-title">{{ _('server_management') }}</div>
<div class="card-subtitle" style="margin-bottom: var(--space-md);">
{{ _('server_management_desc') }}
</div>
<div class="flex" style="flex-direction: column; gap: var(--space-sm);">
<button class="btn btn-secondary" onclick="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-secondary" onclick="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="clearServer()" style="justify-content: flex-start; padding: 12px 20px; background: rgba(239, 68, 68, 0.05);">
<span style="font-size: 1.2rem; margin-right: 8px;">🗑️</span> {{ _('clear_server') }}
</button>
<button class="btn btn-danger" onclick="removeServer()" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px;"></span> {{ _('remove_server') }}
</button>
</div>
</div>
</div>
<!-- Connections Section (renamed from Clients) -->
<div class="clients-section" id="connectionsSection" style="display:none;">
<div class="clients-header">
<h2 class="section-title" style="margin-bottom:0;">
<span class="icon">🔗</span>
{{ _('connections') }}
</h2>
<div class="flex gap-sm items-center">
<select class="form-select" id="connProtoSelect" onchange="loadConnections()"
style="width:auto; min-width:160px;">
<option value="awg">AmneziaWG</option>
<option value="awg2">AmneziaWG 2.0</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>
<!-- ===== 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="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="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="revproxyOptions"
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">{{ _('revproxy_domain') }}</label>
<input class="form-input" type="text" id="installRevproxyDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('revproxy_domain_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('revproxy_site_title') }}</label>
<input class="form-input" type="text" id="installRevproxyTitle" value="CloudEdge">
</div>
<div class="form-group">
<label class="form-label">{{ _('revproxy_tls_email') }}</label>
<input class="form-input" type="email" id="installRevproxyEmail" placeholder="admin@example.com">
<div class="form-hint">{{ _('revproxy_tls_email_hint') }}</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('revproxy_backend') }}</label>
<select class="form-select" id="installRevproxyBackend" onchange="toggleRevproxyBackendOpts()">
<option value="none">{{ _('revproxy_backend_none') }}</option>
<option value="xray">Xray</option>
<option value="telemt">Telemt</option>
</select>
</div>
<div class="form-group" id="revproxyBackendPortGroup">
<label class="form-label">{{ _('revproxy_backend_port') }}</label>
<input class="form-input" type="number" id="installRevproxyBackendPort" value="8443" min="1" max="65535">
</div>
</div>
<div class="form-group" id="revproxyVpnPathGroup">
<label class="form-label">{{ _('revproxy_vpn_path') }}</label>
<input class="form-input" type="text" id="installRevproxyVpnPath" value="/cdn-cgi/challenge">
<div class="form-hint">{{ _('revproxy_vpn_path_hint') }}</div>
</div>
<div class="form-group" id="revproxyTelemtMaskGroup" style="display:none;">
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
<input type="checkbox" id="installRevproxyTelemtMask" checked>
<span>{{ _('revproxy_telemt_mask') }}</span>
</label>
</div>
<div class="form-hint">{{ _('revproxy_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>
<!-- ===== 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>
<!-- ===== Reverse Proxy Settings Modal ===== -->
<div class="modal-backdrop" id="revproxySettingsModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">{{ _('revproxy_settings_title') }}</h2>
<button class="modal-close" onclick="closeModal('revproxySettingsModal')">×</button>
</div>
<div class="form-group">
<label class="form-label">{{ _('revproxy_domain') }}</label>
<input class="form-input" type="text" id="revproxySetDomain" placeholder="vpn.example.com">
</div>
<div class="form-group">
<label class="form-label">{{ _('revproxy_site_title') }}</label>
<input class="form-input" type="text" id="revproxySetTitle">
</div>
<div class="form-group">
<label class="form-label">{{ _('revproxy_tls_email') }}</label>
<input class="form-input" type="email" id="revproxySetEmail">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('revproxy_backend') }}</label>
<select class="form-select" id="revproxySetBackend" onchange="toggleRevproxySetBackendOpts()">
<option value="none">{{ _('revproxy_backend_none') }}</option>
<option value="xray">Xray</option>
<option value="telemt">Telemt</option>
</select>
</div>
<div class="form-group" id="revproxySetBackendPortGroup">
<label class="form-label">{{ _('revproxy_backend_port') }}</label>
<input class="form-input" type="number" id="revproxySetBackendPort" min="1" max="65535">
</div>
</div>
<div class="form-group" id="revproxySetVpnPathGroup">
<label class="form-label">{{ _('revproxy_vpn_path') }}</label>
<input class="form-input" type="text" id="revproxySetVpnPath">
</div>
<div class="form-group" id="revproxySetTelemtMaskGroup" style="display:none;">
<label style="display:flex; align-items:center; gap:var(--space-xs); cursor:pointer;">
<input type="checkbox" id="revproxySetTelemtMask">
<span>{{ _('revproxy_telemt_mask') }}</span>
</label>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('revproxySettingsModal')">{{ _('cancel') }}</button>
<button class="btn btn-primary" onclick="saveRevproxySettings()" id="revproxySaveBtn">
<span id="revproxySaveBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="revproxySaveSpinner"></div>
</button>
</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">
<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">Secret (Hex 32 chars)</label>
<input class="form-input" type="text" id="telemtSecret" placeholder="Optional (auto-generated if empty)">
</div>
<div class="form-group">
<label class="form-label">AD Tag (Hex 32 chars)</label>
<input class="form-input" type="text" id="telemtAdTag" placeholder="Optional">
</div>
<div class="form-group">
<label class="form-label">Max TCP Conns</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">Quota (Bytes)</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">Max Unique IPs</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">Expiration Date (ISO 8601)</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">
<div class="config-text" id="configText"></div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
{{ _('copy') }}
</button>
<button class="btn btn-primary btn-sm"
onclick="downloadFile(currentConfig, 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 id="qrCode"></div>
<div class="qr-hint">{{ _('qr_hint') }}</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="" />
<textarea class="form-input" id="serverConfigText"
style="width:100%; height:40vh; resize:vertical; font-family:monospace; white-space:pre; padding:var(--space-sm);"></textarea>
<div class="config-actions" style="margin-top:var(--space-sm);">
<button class="btn btn-secondary btn-sm"
onclick="copyToClipboard(document.getElementById('serverConfigText').value)" style="flex:1">
{{ _('copy') }}
</button>
<button class="btn btn-secondary btn-sm"
onclick="downloadFile(document.getElementById('serverConfigText').value, 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>
{% endblock %}
{% block scripts %}
<script>
const SERVER_ID = {{ server_id }};
const SERVER_HOST = "{{ server.host }}";
let currentInstallProto = 'awg';
let currentConfig = '';
let currentVpnLink = '';
let currentConfigName = 'connection';
let installedProtocols = {};
function switchCategory(cat) {
const grids = ['protocols-grid', 'services-grid', 'management-grid'];
const tabs = ['tab-protocols', 'tab-services', 'tab-management'];
grids.forEach(g => {
if (document.getElementById(g)) document.getElementById(g).classList.add('hidden');
});
tabs.forEach(t => {
const el = document.getElementById(t);
if (el) {
el.classList.remove('active');
el.style.borderBottomColor = 'transparent';
el.style.color = 'var(--text-muted)';
el.style.fontWeight = '400';
}
});
const activeGrid = document.getElementById(cat + '-grid');
const activeTab = document.getElementById('tab-' + cat);
if (activeGrid) activeGrid.classList.remove('hidden');
if (activeTab) {
activeTab.classList.add('active');
activeTab.style.borderBottomColor = 'var(--accent)';
activeTab.style.color = 'var(--text-primary)';
activeTab.style.fontWeight = '600';
}
}
function getProtoTitle(proto) {
switch (proto) {
case 'awg': return 'AmneziaWG';
case 'awg2': return 'AmneziaWG 2.0';
case 'awg_legacy': return 'AWG Legacy';
case 'xray': return 'Xray';
case 'telemt': return 'Telemt';
case 'wireguard': return 'WireGuard';
case 'dns': return 'AmneziaDNS';
case 'socks5': return 'SOCKS5 Proxy';
case 'adguard': return 'AdGuard Home';
case 'revproxy': return _('revproxy_title');
default: return proto;
}
}
// ========== 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() {
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('remove');
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 Object.entries(data.protocols)) {
updateProtocolCard(proto, info);
const isVPN = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'].includes(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 = 'Нет протоколов';
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('Проверка завершена', 'success');
} catch (err) {
showToast('Ошибка: ' + 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> Ошибка подключения</span>`;
} finally {
btn.disabled = false;
text.textContent = _('server_check');
spinner.classList.add('hidden');
}
}
function updateStatusCounts(protocols) {
const protos = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard'];
const servs = ['dns', 'adguard', 'socks5', 'revproxy'];
let pRunning = 0, pTotal = protos.length;
let sRunning = 0, sTotal = servs.length;
protos.forEach(p => {
if (protocols[p] && protocols[p].container_running) pRunning++;
});
servs.forEach(s => {
if (protocols[s] && protocols[s].container_running) sRunning++;
});
document.getElementById('protocols-count').textContent = `${pRunning}/${pTotal}`;
document.getElementById('services-count').textContent = `${sRunning}/${sTotal}`;
}
let serverConfigFilename = 'server.conf';
// 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', 'revproxy'];
function buildServiceInfoGrid(proto, info) {
// Custom info-grid markup for non-VPN services. Returns HTML for the grid body.
if (proto === '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 (proto === '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 (proto === '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 (proto === 'revproxy') {
const siteUrl = info.site_url || (info.domain ? `https://${info.domain}` : `https://${SERVER_HOST}`);
const backend = info.backend && info.backend !== 'none' ? info.backend.toUpperCase() : _('revproxy_backend_none');
let grid = `
<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">443/TCP</span></div>
<div class="protocol-info-item"><span class="protocol-info-label">${_('revproxy_site')}</span><span class="protocol-info-value"><a href="${siteUrl}" target="_blank" rel="noopener">${siteUrl}</a></span></div>
<div class="protocol-info-item"><span class="protocol-info-label">${_('revproxy_backend')}</span><span class="protocol-info-value">${backend}</span></div>
`;
if (info.backend && info.backend !== 'none') {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('revproxy_vpn_path')}</span><span class="protocol-info-value">${info.vpn_path || '—'}</span></div>`;
}
return grid;
}
return '';
}
function updateProtocolCard(proto, info) {
const protoKey = proto.replace('_', '-');
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(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}/${(proto === 'xray' || proto === 'telemt') ? '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>`;
}
infoGrid.innerHTML = grid;
}
if (proto === 'socks5') {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="openSocks5Settings()" style="flex:1">${_('socks5_change_settings')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else if (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-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else if (proto === 'revproxy') {
const siteUrl = info.site_url || (info.domain ? `https://${info.domain}` : `https://${SERVER_HOST}`);
actionsEl.innerHTML = `
<a class="btn btn-secondary btn-sm" href="${siteUrl}" target="_blank" rel="noopener" style="flex:1">${_('revproxy_open_site')}</a>
<button class="btn btn-secondary btn-sm" onclick="openRevproxySettings()">${_('revproxy_change_settings')}</button>
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
} else if (proto === 'dns') {
actionsEl.innerHTML = `
<button class="btn btn-danger btn-sm" onclick="uninstallProtocol('${proto}')" style="flex:1">${_('uninstall')}</button>
`;
} else {
actionsEl.innerHTML = `
<button class="btn btn-secondary btn-sm" onclick="selectProtocolForConns('${proto}')" style="flex:1">${_('connections')}</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 (isService) {
const cfgBtn = proto === 'revproxy'
? `<button class="btn btn-secondary btn-sm" onclick="showServerConfig('${proto}')" title="${_('server_config')}">${_('config_btn')}</button>`
: '';
ctrlEl.innerHTML = `
${cfgBtn}
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_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-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
`;
}
}
} else if (info.container_exists) {
installedProtocols[proto] = false;
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-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-danger btn-sm" onclick="uninstallProtocol('${proto}')">${_('uninstall')}</button>
`;
}
if (ctrlEl) {
ctrlEl.style.cssText = '';
if (isService) {
ctrlEl.innerHTML = `
${proto === 'revproxy' ? `<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 {
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 = '';
}
}
}
// 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');
const res = await apiCall(`/api/servers/${SERVER_ID}/container/toggle`, 'POST', { protocol: proto });
showToast(`${name} ${res.action === 'stopped' ? _('stopped') : _('started')}`, 'success');
// Recheck after a short delay
setTimeout(() => checkServer(), 1500);
} catch (err) {
showToast('Ошибка: ' + 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;
document.getElementById('serverConfigText').value = res.config || '';
serverConfigFilename = `server_${proto}.conf`;
openModal('serverConfigModal');
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
}
// Save server config changes
async function saveServerConfig() {
const proto = document.getElementById('serverConfigProto').value;
const newConfig = document.getElementById('serverConfigText').value;
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;
}
}
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 openInstallModal(proto) {
currentInstallProto = proto;
const name = getProtoTitle(proto);
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 revproxyOpts = document.getElementById('revproxyOptions');
telemtOpts.style.display = 'none';
socks5Opts.style.display = 'none';
adguardOpts.style.display = 'none';
revproxyOpts.style.display = 'none';
if (proto === 'dns') {
portLabel.textContent = _('port') + ' (Internal)';
portInput.value = '53';
portInput.disabled = true;
portHint.textContent = _('dns_internal_hint');
} else if (proto === 'xray') {
portLabel.textContent = _('port') + ' (TCP)';
portInput.disabled = false;
portInput.value = '443';
portHint.textContent = _('port_xray_hint');
} else if (proto === 'telemt') {
portLabel.textContent = _('port') + ' (TCP)';
portInput.value = '443';
portInput.disabled = false;
portHint.textContent = _('telemt_port_hint');
telemtOpts.style.display = 'block';
} else if (proto === 'socks5') {
portLabel.textContent = _('port') + ' (TCP)';
portInput.value = '38080';
portInput.disabled = false;
portHint.textContent = _('socks5_port_hint');
socks5Opts.style.display = 'block';
} else if (proto === 'adguard') {
portLabel.textContent = _('adguard_dns_port');
portInput.value = '53';
portInput.disabled = false;
portHint.textContent = _('adguard_dns_port_hint');
adguardOpts.style.display = 'block';
} else if (proto === 'revproxy') {
portLabel.textContent = _('port') + ' (HTTPS)';
portInput.value = '443';
portInput.disabled = true;
portHint.textContent = _('revproxy_port_hint');
revproxyOpts.style.display = 'block';
toggleRevproxyBackendOpts();
} else {
portLabel.textContent = _('port') + ' (UDP)';
portInput.value = '55424';
portInput.disabled = false;
portHint.textContent = _('port_default_hint');
}
document.getElementById('installForm').classList.remove('hidden');
document.getElementById('installProgress').classList.add('hidden');
document.getElementById('installDone').style.display = 'none';
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">Начинаем установку...</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,
};
if (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 (currentInstallProto === 'socks5') {
params.socks5_username = document.getElementById('installSocks5User').value || 'proxy_user';
params.socks5_password = document.getElementById('installSocks5Pass').value || '';
} else if (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 (currentInstallProto === 'revproxy') {
params.revproxy_domain = document.getElementById('installRevproxyDomain').value.trim();
params.revproxy_site_title = document.getElementById('installRevproxyTitle').value.trim() || 'CloudEdge';
params.revproxy_tls_email = document.getElementById('installRevproxyEmail').value.trim();
params.revproxy_backend = document.getElementById('installRevproxyBackend').value;
params.revproxy_backend_port = parseInt(document.getElementById('installRevproxyBackendPort').value) || 8443;
params.revproxy_vpn_path = document.getElementById('installRevproxyVpnPath').value.trim();
params.revproxy_telemt_mask = document.getElementById('installRevproxyTelemtMask').checked;
}
const result = await apiCall(`/api/servers/${SERVER_ID}/install`, 'POST', params);
clearInterval(progressInterval);
fillEl.style.width = '100%';
if (result.log) {
result.log.forEach(line => {
logEl.innerHTML += `<div class="log-line success">✓ ${line}</div>`;
});
}
logEl.scrollTop = logEl.scrollHeight;
showToast(_('install_success'), 'success');
document.getElementById('installDone').style.display = '';
} 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 = '';
}
}
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('Ошибка: ' + err.message, 'error');
}
}
// ========== Reverse Proxy Settings ==========
function toggleRevproxyBackendOpts() {
const backend = document.getElementById('installRevproxyBackend').value;
const show = backend !== 'none';
document.getElementById('revproxyBackendPortGroup').style.display = show ? '' : 'none';
document.getElementById('revproxyVpnPathGroup').style.display = show ? '' : 'none';
document.getElementById('revproxyTelemtMaskGroup').style.display = backend === 'telemt' ? '' : 'none';
}
function toggleRevproxySetBackendOpts() {
const backend = document.getElementById('revproxySetBackend').value;
const show = backend !== 'none';
document.getElementById('revproxySetBackendPortGroup').style.display = show ? '' : 'none';
document.getElementById('revproxySetVpnPathGroup').style.display = show ? '' : 'none';
document.getElementById('revproxySetTelemtMaskGroup').style.display = backend === 'telemt' ? '' : 'none';
}
async function openRevproxySettings() {
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/revproxy/settings`);
document.getElementById('revproxySetDomain').value = data.domain || '';
document.getElementById('revproxySetTitle').value = data.site_title || 'CloudEdge';
document.getElementById('revproxySetEmail').value = data.tls_email || '';
document.getElementById('revproxySetBackend').value = data.backend || 'none';
document.getElementById('revproxySetBackendPort').value = data.backend_port || 8443;
document.getElementById('revproxySetVpnPath').value = data.vpn_path || '/cdn-cgi/challenge';
document.getElementById('revproxySetTelemtMask').checked = false;
toggleRevproxySetBackendOpts();
openModal('revproxySettingsModal');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
async function saveRevproxySettings() {
const btn = document.getElementById('revproxySaveBtn');
const text = document.getElementById('revproxySaveBtnText');
const spinner = document.getElementById('revproxySaveSpinner');
btn.disabled = true;
text.textContent = _('saving');
spinner.classList.remove('hidden');
try {
await apiCall(`/api/servers/${SERVER_ID}/revproxy/settings`, 'POST', {
domain: document.getElementById('revproxySetDomain').value.trim(),
site_title: document.getElementById('revproxySetTitle').value.trim(),
tls_email: document.getElementById('revproxySetEmail').value.trim(),
backend: document.getElementById('revproxySetBackend').value,
backend_port: parseInt(document.getElementById('revproxySetBackendPort').value) || 8443,
vpn_path: document.getElementById('revproxySetVpnPath').value.trim(),
telemt_mask: document.getElementById('revproxySetTelemtMask').checked,
});
showToast(_('success'), 'success');
closeModal('revproxySettingsModal');
setTimeout(() => checkServer(), 500);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = _('save');
spinner.classList.add('hidden');
}
}
// ========== SOCKS5 Settings ==========
async function openSocks5Settings() {
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/socks5/credentials`);
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', {
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() {
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;
}
loading.style.display = '';
emptyEl.classList.add('hidden');
listEl.innerHTML = '';
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden');
return;
}
window.connectionsStore = {};
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 || '';
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>`;
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 {
if (handshake) metaHtml += `<span>🤝 ${handshake}</span>`;
if (received) metaHtml += `<span class="traffic-badge traffic-down">↓ ${received}</span>`;
if (sent) metaHtml += `<span class="traffic-badge traffic-up">↑ ${sent}</span>`;
}
if (!enabled) metaHtml += `<span class="badge badge-danger" style="font-size:0.65rem">${_('stop')}</span>`;
const disabledStyle = enabled ? '' : 'opacity:0.5;';
const toggleIcon = enabled ? '🔵' : '⚫';
const toggleTitle = enabled ? _('stop') : _('starting');
// 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}">
<div class="client-info">
<div class="client-avatar">${initial}</div>
<div>
<div class="client-name">${escapeHtml(name)}</div>
<div class="client-meta">${metaHtml || `<span class="text-muted">${_('no_data')}</span>`}</div>
</div>
</div>
<div class="client-actions">
<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" onclick="toggleConnection('${escapeJs(client.clientId)}', ${!enabled})" title="${toggleTitle}">${toggleIcon}</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="removeConnection('${escapeJs(client.clientId)}')" title="${_('delete')}">🗑</button>
</div>
</div>
`;
});
} catch (err) {
loading.style.display = 'none';
showToast('Ошибка загрузки подключений: ' + err.message, 'error');
}
}
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('Подключение обновлено', '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, '_');
document.getElementById('configModalTitle').textContent = `Конфигурация — ${connName}`;
document.getElementById('configText').textContent = result.config;
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);
}
}
loadConnections();
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = 'Создать';
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 ? 'Редактировать подключение' : 'Добавить подключение';
document.getElementById('addConnBtnText').textContent = isEdit ? 'Сохранить' : 'Создать';
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('Ошибка: ' + err.message, 'error');
}
}
async function showConnectionConfig(clientId, connName, hasKey) {
const proto = document.getElementById('connProtoSelect').value;
// 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 = '';
currentVpnLink = '';
currentConfigName = connName.replace(/\s+/g, '_');
document.getElementById('configText').innerHTML = `<div style="padding:var(--space-md);background:var(--bg-primary);border-radius:var(--radius-md);border:1px solid var(--border-color);"><div style="font-size:1.4rem;margin-bottom:var(--space-sm);">⚠️</div><div style="font-weight:600;margin-bottom:var(--space-sm);">${_('config_unavailable')}</div><div style="font-size:0.85rem;color:var(--text-muted);line-height:1.6;">${_('config_unavailable_desc').replace(/\\n/g, '<br>')}<br><br><strong>${_('client_public_key')}</strong><br><code style="word-break:break-all;font-size:0.78rem;">${escapeHtml(clientId)}</code></div></div>`;
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}`;
document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
switchConfigTab('conf');
openModal('configModal');
generateQR(result.config);
}
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
}
async function toggleConnection(clientId, enable) {
const proto = document.getElementById('connProtoSelect').value;
const action = enable ? 'Включить' : 'Отключить';
try {
showToast(`${action} подключение...`, 'info');
await apiCall(`/api/servers/${SERVER_ID}/connections/toggle`, 'POST', {
protocol: proto, client_id: clientId, enable: enable,
});
showToast(`Подключение ${enable ? 'включено' : 'отключено'}`, 'success');
loadConnections();
} catch (err) {
showToast('Ошибка: ' + err.message, 'error');
}
}
// ========== 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');
}
function generateQR(text) {
const container = document.getElementById('qrCode');
container.innerHTML = '';
try {
new QRCode(container, {
text: text, width: 280, height: 280,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
} catch (e) {
console.error('QR lib error:', e);
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${_('qr_error')}</div>`;
}
}
// ========== 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];
}
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 ==========
checkServer();
loadServerStats();
</script>
{% endblock %}