Files
Amnezia-Web-Panel-main/templates/server.html
T

2975 lines
150 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" %}
{% from "macros/icons.html" import icon %}
{% block title_extra %} — {{ server.name }}{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
<style>
.client-actions {
display: flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: var(--space-xs) !important;
align-items: center;
}
.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;
}
.installed-app-hidden,
.promo-block.installed-app-hidden,
.protocol-cards > .promo-block {
display: none !important;
}
.protocol-card.installed-app-visible {
display: block !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);
}
.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">{{ _('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;">{{ icon('server') }}</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="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>{{ icon('cart') }}</span> {{ _('marketplace') }}
</button>
<button class="btn btn-secondary btn-sm" onclick="openManagementModal()" id="managementBtn">
<span>{{ icon('settings') }}</span> {{ _('management') }}
</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">{{ icon('cart') }}</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" 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">{{ icon('sparkles') }}</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">{{ icon('shield') }}</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">{{ icon('radio') }}</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">{{ icon('zap') }}</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">{{ icon('plane') }}</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>
<!-- Hysteria Card -->
<div class="card card-hover protocol-card protocol-hysteria" id="proto-hysteria">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('refresh') }}</div>
<div class="flex gap-sm" id="hysteria-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">Hysteria 2</div>
<div class="protocol-desc">
{{ _('hysteria_desc') }}
</div>
<div class="protocol-status" id="hysteria-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="hysteria-info" class="hidden">
<div class="protocol-info" id="hysteria-info-grid"></div>
</div>
<div class="flex gap-sm" id="hysteria-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('hysteria')" id="hysteria-install-btn"
style="flex:1">
{{ _('install') }}
</button>
</div>
</div>
<!-- NaiveProxy Card -->
<div class="card card-hover protocol-card protocol-naiveproxy" id="proto-naiveproxy">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('link') }}</div>
<div class="flex gap-sm" id="naiveproxy-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">NaiveProxy <span
style="font-size:0.65rem; background:var(--success, #16a34a); color:#fff; padding:2px 6px; border-radius:8px; vertical-align:middle;">STABLE</span></div>
<div class="protocol-desc">
{{ _('naiveproxy_desc') }}
</div>
<div class="protocol-status" id="naiveproxy-status">
<span class="badge badge-warn"><span class="badge-dot"></span> {{ _('not_checked') }}</span>
</div>
<div id="naiveproxy-info" class="hidden">
<div class="protocol-info" id="naiveproxy-info-grid"></div>
</div>
<div class="flex gap-sm" id="naiveproxy-actions">
<button class="btn btn-primary btn-sm" onclick="openInstallModal('naiveproxy')" id="naiveproxy-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">{{ icon('lock') }}</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">{{ icon('lock') }} {{ _('coming_soon') }}</span>
<div class="promo-content">
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</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 style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('search') }}</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">{{ icon('shield-check') }}</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">{{ icon('network') }}</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>
<!-- NGINX Card -->
<div class="card card-hover protocol-card protocol-nginx" id="proto-nginx">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:var(--space-sm);">
<div class="protocol-icon">{{ icon('globe') }}</div>
<div class="flex gap-sm" id="nginx-ctrl" style="display:none!important;"></div>
</div>
<div class="protocol-name">NGINX</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">{{ icon('lock') }} {{ _('coming_soon') }}</span>
<div class="promo-content">
<div class="promo-icon" aria-hidden="true">{{ icon('shield-check') }}</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">{{ icon('lock') }} Coming soon</span>
<div class="promo-content">
<div class="promo-icon" aria-hidden="true">{{ icon('brain') }}</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">
<h2 class="section-title" style="margin-bottom:0;">
<span class="icon">{{ icon('link') }}</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>{{ icon('plus') }}</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">{{ icon('link') }}</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 ===== -->
<div class="modal-backdrop" id="marketplaceModal">
<div class="modal" style="max-width: 980px;">
<div class="modal-header">
<div>
<h2 class="modal-title">{{ icon('cart') }} {{ _('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">{{ icon('settings') }} {{ _('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" id="installPortGroup">
<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="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 id="hysteriaOptions"
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-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
{{ _('hysteria_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP) *</label>
<input class="form-input" type="number" id="installHysteriaPort" value="8998" min="1" max="65535">
<div class="form-hint">{{ _('hysteria_port_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="installHysteriaDomain" placeholder="vpn.example.com" oninput="updateHysteriaDnsHint()">
<div class="form-hint" id="hysteriaDnsHint"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_email') }}</label>
<input class="form-input" type="email" id="installHysteriaEmail" placeholder="admin@example.com">
</div>
<div class="form-hint">{{ _('hysteria_install_hint') }}</div>
</div>
<div id="naiveproxyOptions"
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-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35); color: var(--text);">
{{ _('naiveproxy_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('naiveproxy_domain') }}</label>
<input class="form-input" type="text" id="installNaiveproxyDomain" placeholder="vpn.example.com" oninput="updateNaiveproxyDnsHint()">
<div class="form-hint" id="naiveproxyDnsHint"></div>
</div>
<div class="form-group">
<label class="form-label">{{ _('naiveproxy_email') }}</label>
<input class="form-input" type="email" id="installNaiveproxyEmail" placeholder="admin@example.com">
</div>
<div class="form-hint">{{ _('naiveproxy_install_hint') }}</div>
<div class="form-hint" style="margin-top: var(--space-sm);">{{ _('naiveproxy_port_hint') }}</div>
<div class="form-hint" style="margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">{{ _('naiveproxy_client_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>
<!-- ===== Hysteria Settings Modal ===== -->
<div class="modal-backdrop" id="hysteriaSettingsModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="hysteriaSettingsTitle">{{ _('hysteria_settings_title') }}</h2>
<button class="modal-close" onclick="closeModal('hysteriaSettingsModal')">×</button>
</div>
<input type="hidden" id="hysteriaSetProto" value="hysteria" />
<div class="form-hint" style="margin-bottom: var(--space-md); padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); background: rgba(234,179,8,0.12); border: 1px solid rgba(234,179,8,0.35);">
{{ _('hysteria_ports_warning') }}
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_domain') }}</label>
<input class="form-input" type="text" id="hysteriaSetDomain" placeholder="vpn.example.com">
<div class="form-hint">{{ _('hysteria_ssl_change_hint') }}</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('hysteria_email') }}</label>
<input class="form-input" type="email" id="hysteriaSetEmail" placeholder="admin@example.com">
</div>
<div class="form-group">
<label class="form-label">{{ _('port') }} (UDP)</label>
<input class="form-input" type="number" id="hysteriaSetPort" min="1" max="65535" value="8998">
<div class="form-hint">{{ _('hysteria_port_change_hint') }}</div>
</div>
<div class="modal-footer" style="flex-wrap: wrap; gap: var(--space-sm);">
<button class="btn btn-secondary" onclick="closeModal('hysteriaSettingsModal')">{{ _('cancel') }}</button>
<button class="btn btn-secondary" onclick="renewHysteriaSsl()" id="hysteriaRenewBtn">
<span id="hysteriaRenewBtnText">{{ _('hysteria_renew_ssl') }}</span>
<div class="spinner hidden" id="hysteriaRenewSpinner"></div>
</button>
<button class="btn btn-primary" onclick="saveHysteriaSettings()" id="hysteriaSaveBtn">
<span id="hysteriaSaveBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="hysteriaSaveSpinner"></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="config-actions" style="margin-bottom:var(--space-md); flex-wrap:wrap;">
<button class="btn btn-primary btn-sm" onclick="createProtocolBackup()" id="createBackupBtn" style="flex:1">
{{ _('create_backup') }}
</button>
<button class="btn btn-secondary btn-sm hidden" onclick="exportProtocolClients()" id="exportClientsBtn" style="flex:1">
{{ _('export_client_configs') }}
</button>
<button class="btn btn-secondary btn-sm" onclick="loadProtocolBackups()">{{ _('refresh') }}</button>
</div>
<div class="form-hint hidden" id="exportClientsHint" style="margin-bottom:var(--space-md);">{{ _('export_client_configs_hint') }}</div>
<div id="backupBusy" class="hidden" style="display:none; align-items:center; gap:var(--space-sm); margin-bottom:var(--space-md); padding:var(--space-md); border-radius:12px; background:var(--accent-glow); border:1px solid color-mix(in srgb, var(--accent) 35%, transparent);">
<div class="spinner" style="width:18px;height:18px;border-width:2px;"></div>
<span id="backupBusyText">{{ _('restoring_protocol_backup') }}</span>
</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">{{ icon('package') }}</div>
<div class="empty-title">{{ _('no_backups') }}</div>
<div class="empty-desc">{{ _('no_backups_desc') }}</div>
</div>
<div id="backupList" style="display:flex; flex-direction:column; gap:var(--space-sm);"></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="form-hint hidden" id="configClientHint" style="margin-top: var(--space-sm);"></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="" />
<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>
<!-- ===== Container Logs Modal (live) ===== -->
<div class="modal-backdrop" id="containerLogsModal">
<div class="modal" style="max-width:820px;">
<div class="modal-header">
<h2 class="modal-title" id="containerLogsModalTitle">{{ _('container_logs') }}</h2>
<button class="modal-close" onclick="closeContainerLogsModal()">×</button>
</div>
<input type="hidden" id="containerLogsProto" value="" />
<div id="containerLogsMeta" class="form-hint" style="margin-bottom:var(--space-sm);"></div>
<div class="log-output" id="containerLogsOutput"
style="min-height:280px; max-height:55vh; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:0.78rem; white-space:pre-wrap; word-break:break-word;"></div>
<div class="modal-footer" style="display:flex; gap:var(--space-sm); flex-wrap:wrap;">
<label style="display:flex; align-items:center; gap:6px; margin-right:auto; cursor:pointer;">
<input type="checkbox" id="containerLogsLive" checked onchange="toggleContainerLogsLive()">
<span>{{ _('logs_live') }}</span>
</label>
<button class="btn btn-secondary btn-sm" onclick="refreshContainerLogs()">{{ _('refresh') }}</button>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('containerLogsOutput').textContent)">{{ _('copy') }}</button>
<button class="btn btn-primary btn-sm" onclick="closeContainerLogsModal()">{{ _('close') }}</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/nginx/nginx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/properties/properties.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script>
const SERVER_ID = {{ server_id }};
const SERVER_HOST = "{{ server.host }}";
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
const MARKETPLACE_APPS = [
{ proto: 'awg2', category: 'protocols', icon: 'sparkles', title: 'AmneziaWG 2.0', descKey: 'awg_desc', badge: 'NEW' },
{ proto: 'awg', category: 'protocols', icon: 'shield', title: 'AmneziaWG', descKey: 'awg_desc' },
{ proto: 'awg_legacy', category: 'protocols', icon: 'radio', title: 'AmneziaWG Legacy', descKey: 'awg_legacy_desc' },
{ proto: 'xray', category: 'protocols', icon: 'zap', title: 'Xray (VLESS-Reality)', descKey: 'xray_desc' },
{ proto: 'telemt', category: 'protocols', icon: 'plane', title: 'Telemt (Telegram Proxy)', descKey: 'telemt_desc' },
{ proto: 'hysteria', category: 'protocols', icon: 'refresh', title: 'Hysteria 2', descKey: 'hysteria_desc' },
{ proto: 'naiveproxy', category: 'protocols', icon: 'link', title: 'NaiveProxy', descKey: 'naiveproxy_desc', badge: 'STABLE' },
{ proto: 'wireguard', category: 'protocols', icon: 'lock', title: 'WireGuard', descKey: 'wireguard_desc' },
{ proto: 'dns', category: 'services', icon: 'search', title: 'AmneziaDNS', descKey: 'dns_desc' },
{ proto: 'adguard', category: 'services', icon: 'shield-check', title: 'AdGuard Home', descKey: 'adguard_desc' },
{ proto: 'socks5', category: 'services', icon: 'network', title: 'SOCKS5 Proxy', descKey: 'socks5_desc' },
{ proto: 'nginx', category: 'web_servers', icon: 'globe', title: 'NGINX', descKey: 'nginx_desc' },
];
const MARKETPLACE_CATEGORIES = [
{ id: 'protocols', icon: 'lock', titleKey: 'protocols' },
{ id: 'services', icon: 'wrench', titleKey: 'services' },
{ id: 'web_servers', icon: 'globe', titleKey: 'web_servers' },
];
let currentInstallProto = 'awg';
let currentInstallAnother = false;
let currentConfig = '';
let currentVpnLink = '';
let currentConfigName = 'connection';
let installedProtocols = {};
let currentProtocolStatus = {};
let installedAppsLoading = true;
let currentSocks5Proto = 'socks5';
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', 'awg_legacy', 'xray', 'telemt', 'hysteria', '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);
}
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; display:inline-flex;">${uiIcon('refresh')}</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; display:inline-flex;">${uiIcon('refresh')}</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; display:inline-flex;">${uiIcon('trash')}</span> ${_('clear_server')}
</button>
<button class="btn btn-danger" onclick="closeModal('managementModal'); deleteServer();" style="justify-content: flex-start; padding: 12px 20px;">
<span style="font-size: 1.2rem; margin-right: 8px; display:inline-flex;">${uiIcon('trash')}</span> ${_('remove_server')}
</button>
</div>
`;
}
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>${uiIcon(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;">${uiIcon(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');
if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
if (empty && installedAppsLoading) empty.classList.add('hidden');
}
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);
}
function getProtoTitle(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 'awg_legacy': title = 'AmneziaWG Legacy'; break;
case 'xray': title = 'Xray'; break;
case 'telemt': title = 'Telemt'; break;
case 'hysteria': title = 'Hysteria 2'; break;
case 'naiveproxy': title = 'NaiveProxy'; 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;
default: title = proto;
}
return idx > 1 ? `${title} #${idx}` : title;
}
// ========== 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">${uiIcon('zap')} ${stats.cpu || 0}%</span>
<span class="stat-pill" title="RAM: ${formatBytes(stats.ram_used)} / ${formatBytes(stats.ram_total)}">${uiIcon('brain')} ${stats.ram_percent || 0}%</span>
<span class="stat-pill" title="Disk: ${formatBytes(stats.disk_used)} / ${formatBytes(stats.disk_total)}">${uiIcon('package')} ${stats.disk_percent || 0}%</span>
<span class="stat-pill" title="Network">${uiIcon('globe')}${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', 'awg_legacy', 'xray', 'telemt', 'hysteria', 'naiveproxy', '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: false,
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'];
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 === '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', 'naiveproxy'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
if (protoBase(proto) === 'hysteria' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
}
if (protoBase(proto) === 'naiveproxy' && info.domain) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('naiveproxy_domain')}</span><span class="protocol-info-value">${info.domain}</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 (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) === 'hysteria') {
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="openHysteriaSettings('${proto}')">${_('hysteria_change_port')}</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();
} else if (protoBase(proto) === 'naiveproxy') {
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();
} 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 = '';
const logsBtn = `<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')" title="${_('container_logs')}">${_('logs_btn')}</button>`;
if (protoBase(proto) === 'nginx') {
ctrlEl.innerHTML = `
${logsBtn}
<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 = `
${logsBtn}
<button class="btn btn-danger btn-sm" onclick="toggleContainer('${proto}', true)" title="${_('stop_container_confirm').replace('{}', '')}">${_('stop_btn')}</button>
`;
} else {
ctrlEl.innerHTML = `
${logsBtn}
<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;
const errText = String(info.error || '').trim();
const portBusy = !!info.port_busy || /address already in use|UDP port \d+ already in use|already in use/i.test(errText);
statusEl.innerHTML = errText
? `<span class="badge badge-danger" style="max-width:100%; white-space:normal; text-align:left; line-height:1.35;"><span class="badge-dot"></span> ${_('stop')}: ${escapeHtml(errText.slice(0, 120))}</span>`
: `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
if (infoEl && infoGrid) {
infoEl.classList.remove('hidden');
let grid = '';
if (info.port) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/UDP</span></div>`;
}
if (info.container_status) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('container_status')}</span><span class="protocol-info-value">${escapeHtml(String(info.container_status))}</span></div>`;
}
if (info.exit_code !== undefined && info.exit_code !== null) {
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('exit_code')}</span><span class="protocol-info-value">${info.exit_code}</span></div>`;
}
if (errText) {
grid += `<div class="protocol-info-item" style="grid-column:1/-1;"><span class="protocol-info-label">${_('error')}</span><span class="protocol-info-value" style="color:var(--danger); word-break:break-word;">${escapeHtml(errText)}</span></div>`;
}
if (protoBase(proto) === 'hysteria') {
const curPort = parseInt(info.port, 10) || 443;
const suggest = (curPort === 443 || portBusy) ? 8998 : curPort;
grid += `
<div class="protocol-info-item" style="grid-column:1/-1; display:block; padding-top:8px;">
<span class="protocol-info-label" style="display:block; margin-bottom:6px;">${_('hysteria_change_port')} (UDP)</span>
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<input class="form-input" type="number" id="hysteriaInlinePort-${protoDomKey(proto)}"
value="${suggest}" min="1" max="65535" style="width:120px; flex:0 0 auto;">
<button class="btn btn-primary btn-sm" type="button"
onclick="applyHysteriaPort('${proto}', document.getElementById('hysteriaInlinePort-${protoDomKey(proto)}').value)">
${_('save')} &amp; ${_('start_btn')}
</button>
<button class="btn btn-secondary btn-sm" type="button" onclick="openHysteriaSettings('${proto}')">${_('hysteria_settings_title')}</button>
</div>
<div class="form-hint" style="margin-top:6px;">${_('hysteria_port_change_hint')}</div>
</div>
`;
}
infoGrid.innerHTML = grid;
}
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="showContainerLogs('${proto}')">${_('logs_btn')}</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) === 'hysteria') {
actionsEl.innerHTML = `
<button class="btn btn-primary btn-sm" onclick="openHysteriaSettings('${proto}')" style="flex:1">${_('hysteria_change_port')}</button>
<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')">${_('logs_btn')}</button>
<button class="btn btn-secondary btn-sm" onclick="openInstallModal('${proto}')">${_('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-secondary btn-sm" onclick="showContainerLogs('${proto}')">${_('logs_btn')}</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 = '';
const logsBtn = `<button class="btn btn-secondary btn-sm" onclick="showContainerLogs('${proto}')" title="${_('container_logs')}">${_('logs_btn')}</button>`;
if (protoBase(proto) === 'nginx') {
ctrlEl.innerHTML = `
${logsBtn}
<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 = `
${logsBtn}
<button class="btn btn-primary btn-sm" onclick="toggleContainer('${proto}', false)" title="${_('start_container_confirm').replace('{}', '')}">${_('start_btn')}</button>
`;
} else {
ctrlEl.innerHTML = `
${logsBtn}
<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');
setTimeout(() => checkServer(), 1500);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
// Show live logs so admin can see why start failed
showContainerLogs(proto);
setTimeout(() => checkServer(), 800);
}
}
// ===== Live container logs =====
let containerLogsES = null;
let containerLogsProto = '';
function renderContainerLogsText(text, stateStr) {
const out = document.getElementById('containerLogsOutput');
const meta = document.getElementById('containerLogsMeta');
if (!out) return;
const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
out.textContent = text || _('no_logs');
if (atBottom) out.scrollTop = out.scrollHeight;
if (meta) {
const parts = String(stateStr || '').split('|');
const status = parts[0] || '';
const running = String(parts[1] || '').toLowerCase() === 'true';
const exitCode = parts[2] || '';
const err = parts[3] || '';
let line = running
? `<span class="badge badge-success"><span class="badge-dot"></span> ${_('run')}</span>`
: `<span class="badge badge-danger"><span class="badge-dot"></span> ${_('stop')}</span>`;
if (status) line += ` · ${escapeHtml(status)}`;
if (exitCode !== '' && exitCode !== '0') line += ` · ${_('exit_code')}: ${escapeHtml(exitCode)}`;
if (err) line += ` · <span style="color:var(--danger)">${escapeHtml(err)}</span>`;
meta.innerHTML = line;
}
}
function stopContainerLogsStream() {
if (containerLogsES) {
try { containerLogsES.close(); } catch (_) {}
containerLogsES = null;
}
}
function startContainerLogsStream(proto) {
stopContainerLogsStream();
const live = document.getElementById('containerLogsLive');
if (live && !live.checked) return;
const url = `/api/servers/${SERVER_ID}/container/logs/stream?protocol=${encodeURIComponent(proto)}&tail=200`;
containerLogsES = new EventSource(url);
containerLogsES.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.error) {
renderContainerLogsText(data.error, '');
return;
}
renderContainerLogsText(data.logs || '', data.state || '');
} catch (_) {}
};
containerLogsES.onerror = () => {
// Browser will retry; show a soft hint once
const meta = document.getElementById('containerLogsMeta');
if (meta && !meta.dataset.streamErr) {
meta.dataset.streamErr = '1';
meta.innerHTML += ` · <span class="form-hint">${_('logs_reconnect')}</span>`;
}
};
}
async function refreshContainerLogs() {
const proto = document.getElementById('containerLogsProto').value || containerLogsProto;
if (!proto) return;
try {
const res = await apiCall(`/api/servers/${SERVER_ID}/container/logs`, 'POST', {
protocol: proto, tail: 200,
});
const state = [
res.container_status || '',
res.running ? 'true' : 'false',
res.exit_code != null ? String(res.exit_code) : '',
res.error || '',
].join('|');
renderContainerLogsText(res.logs || '', state);
} catch (err) {
renderContainerLogsText(_('error') + ': ' + err.message, '');
}
}
function toggleContainerLogsLive() {
const live = document.getElementById('containerLogsLive');
if (live && live.checked) {
startContainerLogsStream(containerLogsProto);
} else {
stopContainerLogsStream();
}
}
async function showContainerLogs(proto) {
containerLogsProto = proto;
document.getElementById('containerLogsProto').value = proto;
document.getElementById('containerLogsModalTitle').textContent =
`${_('container_logs')}${getProtoTitle(proto)}`;
document.getElementById('containerLogsOutput').textContent = _('loading');
document.getElementById('containerLogsMeta').innerHTML = '';
document.getElementById('containerLogsMeta').dataset.streamErr = '';
const live = document.getElementById('containerLogsLive');
if (live) live.checked = true;
openModal('containerLogsModal');
await refreshContainerLogs();
startContainerLogsStream(proto);
}
function closeContainerLogsModal() {
stopContainerLogsStream();
closeModal('containerLogsModal');
}
// Stop live stream if modal is closed via backdrop / Escape
document.addEventListener('click', (e) => {
if (e.target && e.target.id === 'containerLogsModal') stopContainerLogsStream();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') stopContainerLogsStream();
});
// 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');
}
}
// 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&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function updateHysteriaDnsHint() {
const input = document.getElementById('installHysteriaDomain');
const hint = document.getElementById('hysteriaDnsHint');
if (!input || !hint) return;
const domain = (input.value || '').trim() || 'vpn.example.com';
hint.innerHTML = `${_('hysteria_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${SERVER_HOST}</code>`;
}
function updateNaiveproxyDnsHint() {
const input = document.getElementById('installNaiveproxyDomain');
const hint = document.getElementById('naiveproxyDnsHint');
if (!input || !hint) return;
const domain = (input.value || '').trim() || 'vpn.example.com';
hint.innerHTML = `${_('naiveproxy_dns_hint')} <code>A&nbsp;&nbsp;${domain}&nbsp;&nbsp;${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 portGroup = document.getElementById('installPortGroup');
const telemtOpts = document.getElementById('telemtOptions');
const socks5Opts = document.getElementById('socks5Options');
const adguardOpts = document.getElementById('adguardOptions');
const nginxOpts = document.getElementById('nginxOptions');
const hysteriaOpts = document.getElementById('hysteriaOptions');
const naiveproxyOpts = document.getElementById('naiveproxyOptions');
telemtOpts.style.display = 'none';
socks5Opts.style.display = 'none';
adguardOpts.style.display = 'none';
nginxOpts.style.display = 'none';
hysteriaOpts.style.display = 'none';
if (naiveproxyOpts) naiveproxyOpts.style.display = 'none';
if (portGroup) portGroup.style.display = '';
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';
const nxDomain = document.getElementById('installNginxDomain');
const nxEmail = document.getElementById('installNginxEmail');
if (nxDomain && !nxDomain.value && SERVER_SSL_DOMAIN) nxDomain.value = SERVER_SSL_DOMAIN;
if (nxEmail && !nxEmail.value && SERVER_SSL_EMAIL) nxEmail.value = SERVER_SSL_EMAIL;
updateNginxDnsHint();
} else if (base === 'hysteria') {
if (portGroup) portGroup.style.display = 'none';
const suggested = currentInstallAnother ? nextSuggestedPort(currentInstallProto, 8998) : '8998';
portInput.value = suggested;
portInput.disabled = false;
hysteriaOpts.style.display = 'block';
const hyPort = document.getElementById('installHysteriaPort');
if (hyPort) {
hyPort.value = suggested;
hyPort.oninput = () => { portInput.value = hyPort.value; };
}
const hyDomain = document.getElementById('installHysteriaDomain');
const hyEmail = document.getElementById('installHysteriaEmail');
if (hyDomain && !hyDomain.value && SERVER_SSL_DOMAIN) hyDomain.value = SERVER_SSL_DOMAIN;
if (hyEmail && !hyEmail.value && SERVER_SSL_EMAIL) hyEmail.value = SERVER_SSL_EMAIL;
updateHysteriaDnsHint();
} else if (base === 'naiveproxy') {
if (portGroup) portGroup.style.display = 'none';
portInput.value = '443';
portInput.disabled = false;
if (naiveproxyOpts) naiveproxyOpts.style.display = 'block';
const npDomain = document.getElementById('installNaiveproxyDomain');
const npEmail = document.getElementById('installNaiveproxyEmail');
if (npDomain && !npDomain.value && SERVER_SSL_DOMAIN) npDomain.value = SERVER_SSL_DOMAIN;
if (npEmail && !npEmail.value && SERVER_SSL_EMAIL) npEmail.value = SERVER_SSL_EMAIL;
updateNaiveproxyDnsHint();
} 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');
}
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">${_('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) === 'hysteria') {
const hyPortEl = document.getElementById('installHysteriaPort');
if (hyPortEl && hyPortEl.value) {
params.port = hyPortEl.value;
}
params.hysteria_domain = document.getElementById('installHysteriaDomain').value.trim();
params.hysteria_email = document.getElementById('installHysteriaEmail').value.trim();
} else if (protoBase(currentInstallProto) === 'naiveproxy') {
params.port = '443';
params.naiveproxy_domain = document.getElementById('installNaiveproxyDomain').value.trim();
params.naiveproxy_email = document.getElementById('installNaiveproxyEmail').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 => {
logEl.innerHTML += `<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 isWgBackupFamily(proto) {
return ['awg', 'awg2', 'awg_legacy', 'wireguard'].includes(protoBase(proto));
}
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)}`;
const wg = isWgBackupFamily(proto);
document.getElementById('exportClientsBtn').classList.toggle('hidden', !wg);
document.getElementById('exportClientsHint').classList.toggle('hidden', !wg);
openModal('backupModal');
await loadProtocolBackups();
}
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="protocol-info-item" style="align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
<div style="min-width:0; flex:1;">
<div class="protocol-info-value" style="word-break:break-all;">${b.name}</div>
<div class="protocol-info-label">${formatBytes(b.size)}${formatBackupTime(b.mtime)}</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="downloadProtocolBackup('${proto}', '${b.name}')">${_('download')}</button>
<button class="btn btn-danger btn-sm" onclick="restoreProtocolBackup('${proto}', '${b.name}')">${_('restore_protocol_backup')}</button>
</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;
}
}
async function exportProtocolClients() {
const proto = document.getElementById('backupProto').value;
const btn = document.getElementById('exportClientsBtn');
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = _('exporting_client_configs');
try {
const res = await fetch(`/api/servers/${SERVER_ID}/backups/export-clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ protocol: proto })
});
if (!res.ok) {
let message = 'Export failed';
try {
const data = await res.json();
message = data.error || message;
} catch (_) {}
throw new Error(message);
}
const blob = await res.blob();
const cd = res.headers.get('content-disposition') || '';
const match = cd.match(/filename="?([^"]+)"?/i);
const filename = match ? match[1] : `${proto}-clients.zip`;
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);
showToast(_('export_client_configs_done'), 'success');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
}
async function restoreProtocolBackup(proto, filename) {
if (!confirm(_('restore_protocol_backup_confirm'))) return;
const busy = document.getElementById('backupBusy');
const busyText = document.getElementById('backupBusyText');
const createBtn = document.getElementById('createBackupBtn');
const exportBtn = document.getElementById('exportClientsBtn');
const list = document.getElementById('backupList');
const restoreButtons = list ? Array.from(list.querySelectorAll('button')) : [];
if (busyText) busyText.textContent = _('restoring_protocol_backup');
if (busy) {
busy.classList.remove('hidden');
busy.style.display = 'flex';
}
if (createBtn) createBtn.disabled = true;
if (exportBtn) exportBtn.disabled = true;
restoreButtons.forEach(b => { b.disabled = true; });
showToast(_('restoring_protocol_backup'), 'info');
try {
await apiCall(`/api/servers/${SERVER_ID}/backups/restore`, 'POST', { protocol: proto, filename });
showToast(_('restore_protocol_backup_done'), 'success');
await loadProtocolBackups();
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
await loadProtocolBackups();
} finally {
if (busy) {
busy.classList.add('hidden');
busy.style.display = 'none';
}
if (createBtn) createBtn.disabled = false;
if (exportBtn) exportBtn.disabled = false;
}
}
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 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');
}
}
// ========== Hysteria Settings (port) ==========
async function applyHysteriaPort(proto, portValue) {
const port = parseInt(portValue, 10);
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
try {
showToast(_('saving') || 'Saving...', 'info');
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto || 'hysteria', port,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
setTimeout(() => checkServer(), 800);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
openHysteriaSettings(proto);
}
}
async function openHysteriaSettings(proto = 'hysteria') {
const live = currentProtocolStatus[proto] || {};
const livePort = live.port || 443;
const suggest = (parseInt(livePort, 10) === 443) ? 8998 : (livePort || 8998);
document.getElementById('hysteriaSetProto').value = proto;
document.getElementById('hysteriaSettingsTitle').textContent =
`${_('hysteria_settings_title')}${getProtoTitle(proto)}`;
document.getElementById('hysteriaSetDomain').value = live.domain || SERVER_SSL_DOMAIN || '';
document.getElementById('hysteriaSetEmail').value = live.email || SERVER_SSL_EMAIL || '';
document.getElementById('hysteriaSetPort').value = suggest;
openModal('hysteriaSettingsModal');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings?protocol=${encodeURIComponent(proto)}`);
if (data.domain) document.getElementById('hysteriaSetDomain').value = data.domain;
if (data.email) document.getElementById('hysteriaSetEmail').value = data.email;
const p = parseInt(data.port, 10);
if (p && p !== 443) document.getElementById('hysteriaSetPort').value = p;
else if (p === 443) document.getElementById('hysteriaSetPort').value = 8998;
} catch (_) { /* modal already open with local values */ }
}
async function saveHysteriaSettings(opts = {}) {
const btn = document.getElementById('hysteriaSaveBtn');
const text = document.getElementById('hysteriaSaveBtnText');
const spinner = document.getElementById('hysteriaSaveSpinner');
const renewBtn = document.getElementById('hysteriaRenewBtn');
const renewText = document.getElementById('hysteriaRenewBtnText');
const renewSpinner = document.getElementById('hysteriaRenewSpinner');
const proto = document.getElementById('hysteriaSetProto').value || 'hysteria';
const port = parseInt(document.getElementById('hysteriaSetPort').value, 10);
const domain = document.getElementById('hysteriaSetDomain').value.trim();
const email = document.getElementById('hysteriaSetEmail').value.trim();
const renewSsl = !!opts.renew_ssl;
if (!port || port < 1 || port > 65535) {
showToast(_('error') + ': invalid port', 'error');
return;
}
if (port === 80) {
showToast(_('error') + ': port 80 reserved', 'error');
return;
}
if ((renewSsl || domain) && (!domain || !email)) {
showToast(_('error') + ': ' + _('hysteria_ssl_required'), 'error');
return;
}
btn.disabled = true;
if (renewBtn) renewBtn.disabled = true;
if (renewSsl) {
renewText.textContent = _('saving') || 'Saving...';
renewSpinner.classList.remove('hidden');
} else {
text.textContent = _('saving') || 'Saving...';
spinner.classList.remove('hidden');
}
try {
const res = await apiCall(`/api/servers/${SERVER_ID}/hysteria/settings`, 'POST', {
protocol: proto, port, domain, email, renew_ssl: renewSsl,
});
showToast(res.message || _('hysteria_settings_saved'), 'success');
closeModal('hysteriaSettingsModal');
setTimeout(() => checkServer(), 800);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
if (renewBtn) renewBtn.disabled = false;
text.textContent = _('save');
spinner.classList.add('hidden');
if (renewText) renewText.textContent = _('hysteria_renew_ssl');
if (renewSpinner) renewSpinner.classList.add('hidden');
}
}
async function renewHysteriaSsl() {
return saveHysteriaSettings({ renew_ssl: true });
}
// ========== SOCKS5 Settings ==========
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() {
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' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy';
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 ? uiIcon('pause') : uiIcon('play');
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 config 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' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy'})" title="${_('config')}">${uiIcon('file')}</button>
${proto === 'telemt' ? `<button class="btn btn-secondary btn-sm btn-icon" onclick="editConnection('${escapeJs(client.clientId)}')" title="${_('edit')}">${uiIcon('pencil')}</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')}">${uiIcon('trash')}</button>
</div>
</div>
`;
});
} catch (err) {
loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + 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(_('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, '_');
document.getElementById('configModalTitle').textContent = `${_('config')}${connName}`;
document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
switchConfigTab('conf');
openModal('configModal');
generateQR(result.config);
}
}
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');
}
}
async function showConnectionConfig(clientId, connName, hasKey) {
const proto = document.getElementById('connProtoSelect').value;
// Restore tabs visibility first
document.getElementById('panel-vpn').style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? 'none' : '');
document.getElementById('panel-qr').style.display = '';
document.querySelectorAll('.config-tab')[1].style.display = (proto === 'telemt' || protoBase(proto) === 'hysteria' || protoBase(proto) === 'naiveproxy' ? '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;
const hintEl = document.getElementById('configClientHint');
if (hintEl) {
if (protoBase(proto) === 'naiveproxy') {
hintEl.textContent = _('naiveproxy_client_hint');
hintEl.classList.remove('hidden');
} else {
hintEl.textContent = '';
hintEl.classList.add('hidden');
}
}
switchConfigTab('conf');
openModal('configModal');
generateQR(result.config);
}
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
async function toggleConnection(clientId, enable) {
const proto = document.getElementById('connProtoSelect').value;
const action = enable ? _('enable_connection_progress') : _('disable_connection_progress');
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');
loadConnections();
} catch (err) {
showToast(_('error') + ': ' + 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 ==========
applyInstalledAppsVisibility();
checkServer();
loadServerStats();
</script>
{% endblock %}