Template
Drop cascade manager, API, server UI, and i18n keys after unstable egress/routing behavior.
2975 lines
150 KiB
HTML
2975 lines
150 KiB
HTML
{% 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')} & ${_('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 ${domain} ${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 ${domain} ${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 ${domain} ${SERVER_HOST}</code>`;
|
||
}
|
||
|
||
function openInstallModal(proto, installAnother = false) {
|
||
const base = protoBase(proto);
|
||
currentInstallProto = installAnother ? base : proto;
|
||
currentInstallAnother = !!installAnother;
|
||
const name = getProtoTitle(proto) + (currentInstallAnother ? ` #${_('new_instance')}` : '');
|
||
document.getElementById('installModalTitle').textContent = `${_('install')} ${name}`;
|
||
|
||
const portInput = document.getElementById('installPort');
|
||
const portLabel = document.getElementById('installPortLabel');
|
||
const portHint = document.getElementById('installPortHint');
|
||
const 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 %}
|