Compare commits

...
4 Commits
Author SHA1 Message Date
orohi 2973b96713 v2.5.4: move selected connections between servers 2026-07-28 10:14:08 +03:00
orohi 5d63e5d6ef v2.5.3: fix disabled Update panel button 2026-07-28 10:02:57 +03:00
orohi a5b8f26db1 v2.5.2: redesign Tunnels section in Settings 2026-07-28 09:48:19 +03:00
orohi 599e0a487c Enable true one-click panel update from Settings (v2.5.1).
Download release ZIP from git.evilfox.cc when git is unavailable, add upgrade_panel API, and show a primary Update panel button.
2026-07-28 09:41:51 +03:00
11 changed files with 1355 additions and 203 deletions
+12
View File
@@ -228,6 +228,18 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork) ## 📋 Fix / changelog (this fork)
### v2.5.4
* **Move connections between servers** — on a server page, select configs and move them to another server (recreates peers, keeps user links).
### v2.5.3
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
### v2.5.2
* **Tunnels UI redesign** — Settings → Tunnels: grouped layout (public access / outbound VPN), provider cards, status pills, responsive grid.
### v2.5.1
* **One-click panel update** — Settings → About → **Update panel**: downloads release ZIP from Gitea (or `git checkout` when `.git` exists), runs `pip install`, restarts.
### v2.5.0 ### v2.5.0
* **NordVPN in Tunnels** — connect/disconnect outbound NordVPN from Settings (official `nordvpn` CLI; optional country/city). * **NordVPN in Tunnels** — connect/disconnect outbound NordVPN from Settings (official `nordvpn` CLI; optional country/city).
+287 -18
View File
@@ -102,7 +102,7 @@ else:
application_path = os.path.dirname(__file__) application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.5.0" CURRENT_VERSION = "v2.5.4"
RELEASES_REPO_URL = repo_url() RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url() RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -1311,6 +1311,186 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool:
return True return True
def resolve_protocol_on_server(server: dict, protocol: str) -> Optional[str]:
"""Pick an installed protocol key on server matching the requested protocol/base."""
protocols = server.get('protocols') or {}
if protocol in protocols and protocols[protocol].get('installed'):
return protocol
base = protocol_base(protocol)
candidates = [
key for key, info in protocols.items()
if protocol_base(key) == base and info.get('installed')
]
if not candidates:
return None
return sorted(candidates, key=protocol_instance)[0]
def _client_display_name(client: dict) -> str:
user_data = client.get('userData') or {}
return (
user_data.get('clientName')
or client.get('clientName')
or client.get('clientId')
or 'Connection'
)
def _create_remote_client(manager, protocol: str, server: dict, name: str, source_client: Optional[dict] = None):
proto_info = server.get('protocols', {}).get(protocol, {})
port = proto_info.get('port', '55424')
base = protocol_base(protocol)
if base == 'telemt':
user_data = (source_client or {}).get('userData') or {}
return manager.add_client(
protocol, name, server['host'], port,
telemt_quota=user_data.get('quota'),
telemt_expiry=user_data.get('expiry'),
secret=user_data.get('token'),
user_ad_tag=user_data.get('user_ad_tag'),
max_tcp_conns=user_data.get('max_tcp_conns'),
)
if base == 'wireguard':
return manager.add_client(name, server['host'])
return manager.add_client(protocol, name, server['host'], port)
def _move_connections_sync(
source_server_id: int,
target_server_id: int,
protocol: str,
client_ids: List[str],
target_protocol: Optional[str] = None,
delete_source: bool = True,
) -> dict:
data = load_data()
if source_server_id >= len(data['servers']) or target_server_id >= len(data['servers']):
raise ValueError('Server not found')
if source_server_id == target_server_id:
raise ValueError('Source and target server must be different')
if protocol_base(protocol) == 'xui':
raise ValueError('Moving 3x-ui connections between servers is not supported')
source_server = data['servers'][source_server_id]
target_server = data['servers'][target_server_id]
resolved_target_protocol = target_protocol or resolve_protocol_on_server(target_server, protocol)
if not resolved_target_protocol:
raise ValueError(
f'Target server does not have {protocol_display_name(protocol)} installed'
)
source_ssh = get_ssh(source_server)
target_ssh = get_ssh(target_server)
source_ssh.connect()
target_ssh.connect()
source_manager = get_protocol_manager(source_ssh, protocol)
source_clients = _manager_call(source_manager, 'get_clients', protocol) or []
clients_map = {c.get('clientId'): c for c in source_clients if c.get('clientId')}
target_manager = get_protocol_manager(target_ssh, resolved_target_protocol)
moved = []
failed = []
try:
for client_id in client_ids:
client = clients_map.get(client_id)
if not client:
failed.append({'client_id': client_id, 'error': 'Client not found on source server'})
continue
user_data = client.get('userData') or {}
base = protocol_base(protocol)
if user_data.get('externalClient') and not user_data.get('clientPrivateKey') and base in (
'awg', 'awg2', 'awg_legacy', 'wireguard',
):
failed.append({
'client_id': client_id,
'error': 'External/native client cannot be moved (no private key)',
})
continue
name = _client_display_name(client)
enabled = client.get('enabled', True)
if user_data.get('enabled') is False:
enabled = False
try:
created = _create_remote_client(
target_manager, resolved_target_protocol, target_server, name, client,
)
new_client_id = created.get('client_id')
if not new_client_id:
failed.append({'client_id': client_id, 'error': 'Failed to create client on target server'})
continue
if not enabled:
_manager_call(
target_manager, 'toggle_client',
resolved_target_protocol, new_client_id, False,
)
user_conn = next(
(
uc for uc in data.get('user_connections', [])
if uc.get('client_id') == client_id
and uc.get('server_id') == source_server_id
and uc.get('protocol') == protocol
),
None,
)
if user_conn:
user_conn['server_id'] = target_server_id
user_conn['client_id'] = new_client_id
user_conn['protocol'] = resolved_target_protocol
user_conn['name'] = name
elif client.get('assigned_user_id'):
data.setdefault('user_connections', []).append({
'id': str(uuid.uuid4()),
'user_id': client['assigned_user_id'],
'server_id': target_server_id,
'protocol': resolved_target_protocol,
'client_id': new_client_id,
'name': name,
'created_at': datetime.now().isoformat(),
})
if delete_source:
_manager_call(source_manager, 'remove_client', protocol, client_id)
data['user_connections'] = [
uc for uc in data.get('user_connections', [])
if not (
uc.get('client_id') == client_id
and uc.get('server_id') == source_server_id
and uc.get('protocol') == protocol
)
]
moved.append({
'client_id': client_id,
'new_client_id': new_client_id,
'name': name,
})
except Exception as exc:
logger.exception('Failed to move client %s', client_id)
failed.append({'client_id': client_id, 'error': str(exc)})
finally:
source_ssh.disconnect()
target_ssh.disconnect()
if moved:
save_data(data)
return {
'status': 'success' if moved else 'error',
'moved': moved,
'failed': failed,
'target_server_id': target_server_id,
'target_protocol': resolved_target_protocol,
'message': f'Moved {len(moved)} connection(s)' + (f', {len(failed)} failed' if failed else ''),
}
async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: List[tuple] = None, create_conns: List[dict] = None): async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: List[tuple] = None, create_conns: List[dict] = None):
""" """
Executes multiple SSH operations efficiently. Executes multiple SSH operations efficiently.
@@ -1929,10 +2109,12 @@ class ConnectionActionRequest(BaseModel):
client_id: str = '' client_id: str = ''
class ToggleConnectionRequest(BaseModel): class MoveConnectionsRequest(BaseModel):
protocol: str = 'awg' protocol: str = 'awg'
client_id: str = '' target_server_id: int
enable: bool = True client_ids: List[str]
target_protocol: Optional[str] = None
delete_source: bool = True
class AddUserRequest(BaseModel): class AddUserRequest(BaseModel):
@@ -2457,7 +2639,22 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/') return RedirectResponse(url='/')
server = data['servers'][server_id] server = data['servers'][server_id]
users_list = data.get('users', []) users_list = data.get('users', [])
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list) servers_for_move = [
{
'id': idx,
'name': srv.get('name') or srv.get('host') or f'Server {idx + 1}',
'host': srv.get('host') or '',
}
for idx, srv in enumerate(data['servers'])
]
return tpl(
request,
'server.html',
server=server,
server_id=server_id,
users=users_list,
servers_for_move=servers_for_move,
)
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"]) @app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
@@ -4272,6 +4469,35 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
return JSONResponse({'error': str(e)}, status_code=500) return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/connections/move', tags=["Connections"])
async def api_move_connections(request: Request, server_id: int, req: MoveConnectionsRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if not req.client_ids:
return JSONResponse({'error': 'No connections selected'}, status_code=400)
try:
result = await asyncio.to_thread(
_move_connections_sync,
server_id,
req.target_server_id,
req.protocol,
req.client_ids,
req.target_protocol,
bool(req.delete_source),
)
if not result.get('moved'):
return JSONResponse(
{'error': result.get('message') or 'Move failed', **result},
status_code=400,
)
return result
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=400)
except Exception as e:
logger.exception('Error moving connections')
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"]) @app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"])
async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest): async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest):
if not _check_admin(request): if not _check_admin(request):
@@ -5682,7 +5908,7 @@ async def api_check_updates(request: Request):
'releases_url': f'{RELEASES_REPO_URL}/releases', 'releases_url': f'{RELEASES_REPO_URL}/releases',
'body': body, 'body': body,
'can_auto_update': bool(mode.get('can_auto_update')), 'can_auto_update': bool(mode.get('can_auto_update')),
'update_mode': mode.get('mode'), 'update_mode': mode.get('update_method') or mode.get('mode'),
'mode': mode, 'mode': mode,
} }
except Exception as e: except Exception as e:
@@ -5690,26 +5916,28 @@ async def api_check_updates(request: Request):
return JSONResponse({'error': str(e)}, status_code=502) return JSONResponse({'error': str(e)}, status_code=502)
async def _fetch_latest_release_tag() -> str:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(RELEASES_API_LATEST, headers={'Accept': 'application/json'})
if resp.status_code >= 400:
raise RuntimeError(f'Release API returned HTTP {resp.status_code}')
data = resp.json() if resp.content else {}
return (data.get('tag_name') or data.get('name') or '').strip()
@app.post('/api/settings/apply_update', tags=["Settings"]) @app.post('/api/settings/apply_update', tags=["Settings"])
async def api_apply_update(request: Request, req: ApplyUpdateRequest): async def api_apply_update(request: Request, req: ApplyUpdateRequest):
"""Pull the selected release tag from git.evilfox.cc and restart the panel.""" """Install a release tag (git checkout or archive download) and restart the panel."""
if not _check_admin(request): if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403) return JSONResponse({'error': 'Forbidden'}, status_code=403)
updater = UpdateManager(application_path, CURRENT_VERSION) updater = UpdateManager(application_path, CURRENT_VERSION)
target = (req.target_version or '').strip() target = (req.target_version or '').strip()
if not target: if not target:
# Resolve latest if UI did not pass a tag
try: try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: target = await _fetch_latest_release_tag()
resp = await client.get( except Exception as e:
RELEASES_API_LATEST,
headers={'Accept': 'application/json'},
)
if resp.status_code < 400 and resp.content:
payload = resp.json()
target = (payload.get('tag_name') or payload.get('name') or '').strip()
except Exception:
logger.exception('Failed to resolve latest tag for apply_update') logger.exception('Failed to resolve latest tag for apply_update')
return JSONResponse({'error': str(e)}, status_code=502)
if not target: if not target:
return JSONResponse({'error': 'No target version specified'}, status_code=400) return JSONResponse({'error': 'No target version specified'}, status_code=400)
@@ -5719,13 +5947,54 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest):
bool(req.allow_dirty), bool(req.allow_dirty),
) )
if result.get('status') != 'success': if result.get('status') != 'success':
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500) status_code = 409 if result.get('needs_confirm_dirty') else 500
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=status_code)
if result.get('restart'): if result.get('restart'):
UpdateManager.schedule_restart(1.5) UpdateManager.schedule_restart(1.5)
return result return result
@app.post('/api/settings/upgrade_panel', tags=["Settings"])
async def api_upgrade_panel(request: Request):
"""One-click: fetch latest release, install if newer, restart."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
updater = UpdateManager(application_path, CURRENT_VERSION)
mode = updater.detect_mode()
if not mode.get('can_auto_update'):
return JSONResponse({
'error': (
'In-panel update is not available for this install. '
'Use git clone / writable app directory, or rebuild Docker on the host.'
),
'mode': mode,
}, status_code=400)
try:
latest = await _fetch_latest_release_tag()
except Exception as e:
return JSONResponse({'error': str(e)}, status_code=502)
if not latest:
return JSONResponse({'error': 'Could not resolve latest release tag'}, status_code=502)
if not version_gt(latest, CURRENT_VERSION):
return {
'status': 'success',
'up_to_date': True,
'current_version': CURRENT_VERSION,
'latest_version': latest,
'message': 'Already on the latest version',
}
result = await asyncio.to_thread(updater.apply_update, latest, True)
if result.get('status') != 'success':
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500)
if result.get('restart'):
UpdateManager.schedule_restart(1.5)
result['up_to_date'] = False
result['previous_version'] = CURRENT_VERSION
return result
@app.get('/api/settings/tunnels/status', tags=["Settings"]) @app.get('/api/settings/tunnels/status', tags=["Settings"])
async def api_tunnels_status(request: Request): async def api_tunnels_status(request: Request):
if not _check_admin(request): if not _check_admin(request):
+203 -55
View File
@@ -1,14 +1,19 @@
"""Panel self-update from the EvilFox Gitea repository.""" """Panel self-update from the EvilFox Gitea repository (git or release archive)."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import tempfile
import threading import threading
import time import time
import urllib.error
import urllib.request
import zipfile
from typing import Optional from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,6 +23,13 @@ DEFAULT_GIT_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main.git'
DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest' DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest'
UPDATE_REMOTE = 'panel-update' UPDATE_REMOTE = 'panel-update'
ARCHIVE_SKIP_DIRS = frozenset({
'__pycache__', '.git', 'bin', 'data', 'node_modules', '.venv', 'venv', '.cursor',
})
ARCHIVE_SKIP_FILES = frozenset({
'.env', 'data.json', 'tunnels_state.json',
})
def _env(name: str, default: str) -> str: def _env(name: str, default: str) -> str:
return (os.environ.get(name) or default).strip() return (os.environ.get(name) or default).strip()
@@ -51,7 +63,6 @@ def parse_version(value: str) -> tuple:
parts.append(int(m.group(1)) if m else 0) parts.append(int(m.group(1)) if m else 0)
while len(parts) < 3: while len(parts) < 3:
parts.append(0) parts.append(0)
# release > prerelease: empty pre sorts higher
pre_rank = 0 if not pre else 1 pre_rank = 0 if not pre else 1
return (parts[0], parts[1], parts[2], pre_rank, pre) return (parts[0], parts[1], parts[2], pre_rank, pre)
@@ -60,6 +71,13 @@ def version_gt(a: str, b: str) -> bool:
return parse_version(a) > parse_version(b) return parse_version(a) > parse_version(b)
def normalize_tag(value: str) -> str:
tag = (value or '').strip()
if not tag:
return ''
return tag if tag.startswith('v') else f'v{tag}'
def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]: def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]:
try: try:
proc = subprocess.run( proc = subprocess.run(
@@ -83,23 +101,65 @@ class UpdateManager:
self.app_root = os.path.abspath(app_root) self.app_root = os.path.abspath(app_root)
self.current_version = current_version self.current_version = current_version
def _in_docker(self) -> bool:
return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
def _docker_update_allowed(self) -> bool:
if os.environ.get('PANEL_UPDATE_DISABLE_DOCKER', '').strip().lower() in ('1', 'true', 'yes'):
return False
# In-container archive updates are allowed by default when /app is writable.
return True
def _app_root_writable(self) -> bool:
probe = os.path.join(self.app_root, '.panel_update_write_probe')
try:
with open(probe, 'w', encoding='utf-8') as fh:
fh.write('ok')
os.remove(probe)
return True
except OSError:
return False
def detect_mode(self) -> dict: def detect_mode(self) -> dict:
frozen = bool(getattr(sys, 'frozen', False)) frozen = bool(getattr(sys, 'frozen', False))
in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1' in_docker = self._in_docker()
git_dir = os.path.join(self.app_root, '.git') git_dir = os.path.join(self.app_root, '.git')
is_git = os.path.isdir(git_dir) is_git = os.path.isdir(git_dir)
git_ok = False git_ok = False
if is_git and not frozen: if is_git and not frozen:
code, _, _ = _run(['git', '--version'], self.app_root, timeout=10) code, _, _ = _run(['git', '--version'], self.app_root, timeout=10)
git_ok = code == 0 git_ok = code == 0
can_auto = bool(git_ok and not frozen)
mode = 'git' if can_auto else ('docker' if in_docker else ('binary' if frozen else 'manual')) writable = self._app_root_writable()
can_git = bool(git_ok and is_git and not frozen)
can_archive = bool(
writable
and not frozen
and (not in_docker or self._docker_update_allowed())
)
can_auto = can_git or can_archive
if can_git:
method = 'git'
elif can_archive:
method = 'archive'
elif in_docker:
method = 'docker'
elif frozen:
method = 'binary'
else:
method = 'manual'
return { return {
'mode': mode, 'mode': method,
'update_method': method,
'can_auto_update': can_auto, 'can_auto_update': can_auto,
'can_git_update': can_git,
'can_archive_update': can_archive,
'is_git': is_git, 'is_git': is_git,
'frozen': frozen, 'frozen': frozen,
'in_docker': in_docker, 'in_docker': in_docker,
'writable': writable,
'app_root': self.app_root, 'app_root': self.app_root,
'repo_url': repo_url(), 'repo_url': repo_url(),
'git_url': git_url(), 'git_url': git_url(),
@@ -131,31 +191,27 @@ class UpdateManager:
code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30) code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30)
return code == 0 and bool(out.strip()) return code == 0 and bool(out.strip())
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict: def _pip_install(self) -> tuple[bool, str]:
mode = self.detect_mode() req = os.path.join(self.app_root, 'requirements.txt')
if not mode['can_auto_update']: if not os.path.isfile(req):
return { return True, ''
'status': 'error', code_p, out_p, err_p = _run(
'message': ( [sys.executable, '-m', 'pip', 'install', '-r', req],
'Auto-update requires a git checkout of the panel sources. ' self.app_root,
'Docker/binary installs: pull a new image or download a release manually.' timeout=300,
), )
'mode': mode, log = (out_p or err_p or '')[:1500]
} return code_p == 0, log
target = (target_version or '').strip()
if not re.match(r'^v?\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
if not target.startswith('v'):
target = 'v' + target
def _apply_git_update(self, target: str, allow_dirty: bool) -> dict:
if not allow_dirty and self.working_tree_dirty(): if not allow_dirty and self.working_tree_dirty():
return { return {
'status': 'error', 'status': 'error',
'message': ( 'message': (
'Working tree has local changes. Commit/stash them or set allow_dirty, ' 'Working tree has local changes. Confirm update anyway to force checkout, '
'then retry auto-update.' 'or commit/stash changes first.'
), ),
'needs_confirm_dirty': True,
} }
err = self._ensure_update_remote() err = self._ensure_update_remote()
@@ -168,35 +224,28 @@ class UpdateManager:
timeout=180, timeout=180,
) )
if code != 0: if code != 0:
return { return {'status': 'error', 'message': f'git fetch failed: {stderr or out or code}'}
'status': 'error',
'message': f'git fetch failed: {stderr or out or code}',
}
# Prefer annotated/lightweight tag; fall back to remote main tip if tag missing # Ensure tag is present locally
code, _, _ = _run( code, _, _ = _run(
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'], ['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'],
self.app_root, self.app_root,
timeout=15, timeout=15,
) )
if code == 0: if code != 0:
checkout_ref = target _run(
else: ['git', 'fetch', '--depth', '1', UPDATE_REMOTE, f'refs/tags/{target}:refs/tags/{target}'],
# tag might only exist as FETCH_HEAD / remote tag
code2, _, _ = _run(
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}^{{}}'],
self.app_root, self.app_root,
timeout=15, timeout=120,
) )
checkout_ref = target if code2 == 0 else f'tags/{target}'
checkout_ref = f'tags/{target}'
code, out, stderr = _run( code, out, stderr = _run(
['git', 'checkout', '--force', checkout_ref], ['git', 'checkout', '--force', checkout_ref],
self.app_root, self.app_root,
timeout=60, timeout=60,
) )
if code != 0: if code != 0:
# last resort: checkout remote main
code_m, out_m, err_m = _run( code_m, out_m, err_m = _run(
['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'], ['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'],
self.app_root, self.app_root,
@@ -209,31 +258,130 @@ class UpdateManager:
} }
checkout_ref = f'{UPDATE_REMOTE}/main' checkout_ref = f'{UPDATE_REMOTE}/main'
req = os.path.join(self.app_root, 'requirements.txt') ok, pip_log = self._pip_install()
pip_log = '' if not ok:
if os.path.isfile(req): return {
code_p, out_p, err_p = _run( 'status': 'error',
[sys.executable, '-m', 'pip', 'install', '-r', req], 'message': f'pip install failed after checkout: {pip_log[:500]}',
self.app_root, 'checkout': checkout_ref,
timeout=300, 'method': 'git',
) }
pip_log = (out_p or err_p or '')[:1500]
if code_p != 0:
return {
'status': 'error',
'message': f'pip install failed after checkout: {err_p or out_p or code_p}',
'checkout': checkout_ref,
}
return { return {
'status': 'success', 'status': 'success',
'message': f'Updated to {target}. Panel will restart.', 'message': f'Updated to {target} via git. Panel will restart.',
'target_version': target, 'target_version': target,
'checkout': checkout_ref, 'checkout': checkout_ref,
'pip': pip_log[:500], 'pip': pip_log[:500],
'method': 'git',
'restart': True, 'restart': True,
} }
def _archive_urls(self, tag: str) -> list[str]:
base = repo_url()
return [
f'{base}/archive/{tag}.zip',
f'{base}/archive/{tag.lstrip("v")}.zip',
f'{api_latest_url().rsplit("/releases/latest", 1)[0]}/archive/{tag}.zip',
]
def _download_archive(self, tag: str, dest_path: str) -> None:
last_err = 'unknown error'
for url in self._archive_urls(tag):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Amnezia-Web-Panel-Updater'})
with urllib.request.urlopen(req, timeout=180) as resp:
with open(dest_path, 'wb') as out:
shutil.copyfileobj(resp, out)
if os.path.getsize(dest_path) > 1024:
return
last_err = f'empty archive from {url}'
except urllib.error.HTTPError as e:
last_err = f'HTTP {e.code} for {url}'
except Exception as e:
last_err = str(e)
raise RuntimeError(f'Failed to download release archive: {last_err}')
@staticmethod
def _extract_zip_root(extract_dir: str) -> str:
entries = [e for e in os.listdir(extract_dir) if e not in ('.', '..')]
if len(entries) == 1:
only = os.path.join(extract_dir, entries[0])
if os.path.isdir(only):
return only
return extract_dir
def _sync_tree(self, src_root: str) -> None:
for root, dirs, files in os.walk(src_root):
dirs[:] = [d for d in dirs if d not in ARCHIVE_SKIP_DIRS]
rel = os.path.relpath(root, src_root)
dest_root = self.app_root if rel in ('.', '') else os.path.join(self.app_root, rel)
os.makedirs(dest_root, exist_ok=True)
for fname in files:
if fname in ARCHIVE_SKIP_FILES:
continue
src_file = os.path.join(root, fname)
dest_file = os.path.join(dest_root, fname)
shutil.copy2(src_file, dest_file)
def _apply_archive_update(self, target: str) -> dict:
tmpdir = tempfile.mkdtemp(prefix='panel-update-')
zip_path = os.path.join(tmpdir, f'{target}.zip')
extract_dir = os.path.join(tmpdir, 'extract')
try:
self._download_archive(target, zip_path)
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(extract_dir)
src_root = self._extract_zip_root(extract_dir)
if not os.path.isdir(src_root):
return {'status': 'error', 'message': 'Invalid release archive layout'}
self._sync_tree(src_root)
except Exception as e:
return {'status': 'error', 'message': str(e), 'method': 'archive'}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
ok, pip_log = self._pip_install()
if not ok:
return {
'status': 'error',
'message': f'Files updated but pip install failed: {pip_log[:500]}',
'method': 'archive',
}
return {
'status': 'success',
'message': f'Updated to {target} from release archive. Panel will restart.',
'target_version': target,
'pip': pip_log[:500],
'method': 'archive',
'restart': True,
}
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict:
mode = self.detect_mode()
target = normalize_tag(target_version)
if not target or not re.match(r'^v\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
if mode.get('can_git_update'):
return self._apply_git_update(target, allow_dirty)
if mode.get('can_archive_update'):
return self._apply_archive_update(target)
hint = (
'Cannot update from inside this install. '
'Use a git clone or writable source directory, or rebuild the Docker image on the host.'
)
if mode.get('in_docker'):
hint = (
'Docker image install: on the host run '
'`git pull && docker compose up -d --build` '
'(or set PANEL_UPDATE_ALLOW_DOCKER=1 if /app is bind-mounted).'
)
return {'status': 'error', 'message': hint, 'mode': mode}
@staticmethod @staticmethod
def schedule_restart(delay_sec: float = 1.5) -> None: def schedule_restart(delay_sec: float = 1.5) -> None:
def _restart(): def _restart():
+353
View File
@@ -1282,6 +1282,7 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg); padding: var(--space-md) var(--space-lg);
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1294,6 +1295,45 @@ a:hover {
background: var(--bg-card-hover); background: var(--bg-card-hover);
} }
.conn-select-wrap {
flex-shrink: 0;
display: flex;
align-items: center;
cursor: pointer;
}
.conn-select {
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.connections-bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: 1px dashed var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
}
.connections-select-all {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.85rem;
color: var(--text-secondary);
cursor: pointer;
}
.connections-selected-count {
font-size: 0.8rem;
color: var(--text-muted);
}
.client-info { .client-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1623,6 +1663,319 @@ a:hover {
gap: var(--space-md); gap: var(--space-md);
} }
/* ===== Tunnels (Settings) ===== */
.tunnels-card .tunnels-header {
margin-bottom: var(--space-lg);
}
.tunnels-card .tunnels-header .card-title {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.tunnels-card .tunnels-subtitle {
margin: var(--space-xs) 0 0;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.45;
}
.tunnels-local {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
margin-bottom: var(--space-lg);
border-radius: var(--radius-md);
border: 1px solid var(--border-focus);
background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.06));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.tunnels-local-icon {
flex-shrink: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
background: var(--accent-gradient);
font-size: 1.25rem;
box-shadow: var(--shadow-glow);
}
.tunnels-local-body {
flex: 1;
min-width: 0;
}
.tunnels-local-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: var(--space-xs);
}
.tunnels-local-url {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.tunnels-local-url code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
color: var(--text-primary);
background: transparent;
padding: 0;
}
.tunnels-section {
margin-bottom: var(--space-lg);
}
.tunnels-section:last-of-type {
margin-bottom: var(--space-md);
}
.tunnels-section-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 var(--space-sm) var(--space-xs);
}
.tunnels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
}
.tunnel-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-md);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-primary);
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
.tunnel-card:hover {
border-color: var(--border-hover);
}
.tunnel-card--running {
border-color: var(--success-border);
box-shadow: 0 0 0 1px var(--success-border), inset 0 1px 0 rgba(34, 197, 94, 0.06);
}
.tunnel-card--connected {
border-color: var(--success-border);
box-shadow: 0 0 0 1px var(--success-border);
}
.tunnel-card-head {
display: flex;
align-items: flex-start;
gap: var(--space-md);
}
.tunnel-card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 1.1rem;
font-weight: 700;
color: #fff;
}
.tunnel-card-icon--cloudflare { background: linear-gradient(135deg, #f38020, #faae40); }
.tunnel-card-icon--ngrok { background: linear-gradient(135deg, #1f2d3d, #3d5a80); }
.tunnel-card-icon--warp { background: linear-gradient(135deg, #f38020, #e85d04); }
.tunnel-card-icon--nordvpn { background: linear-gradient(135deg, #4687ff, #2b4eff); }
.tunnel-card-icon--local { background: var(--accent-gradient); }
.tunnel-card-meta {
flex: 1;
min-width: 0;
}
.tunnel-card-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.tunnel-card-desc {
font-size: 0.78rem;
color: var(--text-muted);
margin: var(--space-xs) 0 0;
line-height: 1.4;
}
.tunnel-status-pill {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--radius-full);
white-space: nowrap;
background: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.tunnel-status-pill--ok {
background: var(--success-bg);
color: var(--success);
border-color: var(--success-border);
}
.tunnel-status-pill--info {
background: var(--info-bg);
color: var(--info);
border-color: rgba(59, 130, 246, 0.25);
}
.tunnel-card-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tunnel-url-box {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.45;
min-height: 2.5rem;
}
.tunnel-url-box--empty {
font-style: italic;
color: var(--text-muted);
}
.tunnel-url-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.tunnel-url-row:first-child {
margin-top: 0;
}
.tunnel-url-row code {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
color: var(--text-accent);
background: transparent;
padding: 0;
}
.tunnel-status-line {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.45;
}
.tunnel-status-line--server {
font-family: ui-monospace, monospace;
font-size: 0.78rem;
color: var(--text-accent);
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.tunnel-hint {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
margin: 0;
}
.tunnel-card-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
margin-top: auto;
}
.tunnel-card-actions .btn {
flex: 1;
min-width: 7rem;
justify-content: center;
}
.tunnel-card-actions--triple .btn {
min-width: 5.5rem;
}
.tunnel-nordvpn-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
}
.tunnels-footnote {
margin: var(--space-md) 0 0;
padding: var(--space-md);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
border-left: 3px solid var(--accent);
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.5;
}
@media (max-width: 640px) {
.tunnels-local {
flex-direction: column;
align-items: stretch;
text-align: center;
}
.tunnels-local-url {
flex-direction: column;
}
.tunnels-grid {
grid-template-columns: 1fr;
}
.tunnel-nordvpn-fields {
grid-template-columns: 1fr;
}
}
.mt-md { .mt-md {
margin-top: var(--space-md); margin-top: var(--space-md);
} }
+137
View File
@@ -544,12 +544,23 @@
<option value="telemt">Telemt</option> <option value="telemt">Telemt</option>
<option value="wireguard">WireGuard</option> <option value="wireguard">WireGuard</option>
</select> </select>
<button type="button" class="btn btn-secondary btn-sm hidden" id="moveConnectionsBtn" onclick="openMoveConnectionsModal()">
<span>{{ icon('share') }}</span> {{ _('move_connections') }}
</button>
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()"> <button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
<span>{{ icon('plus') }}</span> {{ _('add') }} <span>{{ icon('plus') }}</span> {{ _('add') }}
</button> </button>
</div> </div>
</div> </div>
<div id="connectionsBulkBar" class="connections-bulk-bar hidden">
<label class="connections-select-all">
<input type="checkbox" id="connSelectAll" onchange="toggleSelectAllConnections(this.checked)">
<span>{{ _('select_all') }}</span>
</label>
<span class="connections-selected-count" id="connSelectedCount">0</span>
</div>
<div id="connectionsList"> <div id="connectionsList">
<div class="loading-overlay" id="connectionsLoading" style="display:none;"> <div class="loading-overlay" id="connectionsLoading" style="display:none;">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@@ -969,6 +980,35 @@
</div> </div>
</div> </div>
<!-- ===== Move Connections Modal ===== -->
<div class="modal-backdrop" id="moveConnectionsModal">
<div class="modal" style="max-width: 520px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('move_connections_title') }}</h2>
<button class="modal-close" onclick="closeModal('moveConnectionsModal')">×</button>
</div>
<p class="form-hint">{{ _('move_connections_hint') }}</p>
<div class="form-group">
<label class="form-label" for="moveTargetServer">{{ _('move_connections_target') }}</label>
<select class="form-select" id="moveTargetServer"></select>
</div>
<label class="form-check" style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom: var(--space-md);">
<input type="checkbox" id="moveDeleteSource" checked>
<span>{{ _('move_connections_delete_source') }}</span>
</label>
<div class="form-hint" style="border-left: 3px solid var(--warning); padding-left: var(--space-sm);">
{{ _('move_connections_warning') }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('moveConnectionsModal')">{{ _('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="moveSelectedConnections()" id="moveConnectionsSubmitBtn">
<span id="moveConnectionsSubmitText">{{ _('move_connections') }}</span>
<div class="spinner hidden" id="moveConnectionsSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
</div>
<!-- ===== Connection Config Modal (with Tabs) ===== --> <!-- ===== Connection Config Modal (with Tabs) ===== -->
<div class="modal-backdrop" id="configModal"> <div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;"> <div class="modal" style="max-width: 600px;">
@@ -1090,6 +1130,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script> <script>
const SERVER_ID = {{ server_id }}; const SERVER_ID = {{ server_id }};
const ALL_SERVERS = {{ servers_for_move | tojson }};
const SERVER_HOST = "{{ server.host }}"; const SERVER_HOST = "{{ server.host }}";
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }}; 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 SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
@@ -2608,6 +2649,95 @@
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' }); document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
} }
const selectedConnectionIds = new Set();
function updateMoveSelection() {
selectedConnectionIds.clear();
document.querySelectorAll('.conn-select:checked').forEach((el) => {
if (el.dataset.clientId) selectedConnectionIds.add(el.dataset.clientId);
});
const count = selectedConnectionIds.size;
const bulkBar = document.getElementById('connectionsBulkBar');
const moveBtn = document.getElementById('moveConnectionsBtn');
const countEl = document.getElementById('connSelectedCount');
const selectAll = document.getElementById('connSelectAll');
const boxes = document.querySelectorAll('.conn-select');
if (bulkBar) bulkBar.classList.toggle('hidden', boxes.length === 0);
if (moveBtn) moveBtn.classList.toggle('hidden', boxes.length === 0);
if (countEl) countEl.textContent = _('connections_selected_count').replace('{}', String(count));
if (selectAll) {
selectAll.indeterminate = count > 0 && count < boxes.length;
selectAll.checked = boxes.length > 0 && count === boxes.length;
}
}
function toggleSelectAllConnections(checked) {
document.querySelectorAll('.conn-select').forEach((el) => { el.checked = checked; });
updateMoveSelection();
}
function openMoveConnectionsModal() {
if (!selectedConnectionIds.size) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
const select = document.getElementById('moveTargetServer');
select.innerHTML = '';
(ALL_SERVERS || []).forEach((srv) => {
if (srv.id === SERVER_ID) return;
const opt = document.createElement('option');
opt.value = String(srv.id);
opt.textContent = `${srv.name} (${srv.host})`;
select.appendChild(opt);
});
if (!select.options.length) {
showToast(_('move_connections_no_targets'), 'error');
return;
}
openModal('moveConnectionsModal');
}
async function moveSelectedConnections() {
const targetServerId = parseInt(document.getElementById('moveTargetServer').value, 10);
const deleteSource = document.getElementById('moveDeleteSource').checked;
const proto = document.getElementById('connProtoSelect').value;
const clientIds = Array.from(selectedConnectionIds);
if (!clientIds.length || Number.isNaN(targetServerId)) {
showToast(_('move_connections_none_selected'), 'error');
return;
}
if (!confirm(_('move_connections_confirm').replace('{}', String(clientIds.length)))) return;
const btn = document.getElementById('moveConnectionsSubmitBtn');
const text = document.getElementById('moveConnectionsSubmitText');
const spinner = document.getElementById('moveConnectionsSpinner');
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = _('loading');
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections/move`, 'POST', {
protocol: proto,
target_server_id: targetServerId,
client_ids: clientIds,
delete_source: deleteSource,
});
const moved = (data.moved || []).length;
const failed = (data.failed || []).length;
let msg = _('move_connections_success').replace('{}', String(moved));
if (failed) msg += '. ' + _('move_connections_partial_fail').replace('{}', String(failed));
showToast(msg, failed && !moved ? 'error' : 'success');
closeModal('moveConnectionsModal');
selectedConnectionIds.clear();
loadConnections();
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = _('move_connections');
}
}
async function loadConnections() { async function loadConnections() {
const proto = document.getElementById('connProtoSelect').value; const proto = document.getElementById('connProtoSelect').value;
const loading = document.getElementById('connectionsLoading'); const loading = document.getElementById('connectionsLoading');
@@ -2622,11 +2752,14 @@
loading.style.display = ''; loading.style.display = '';
emptyEl.classList.add('hidden'); emptyEl.classList.add('hidden');
listEl.innerHTML = ''; listEl.innerHTML = '';
selectedConnectionIds.clear();
updateMoveSelection();
try { try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`); const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
loading.style.display = 'none'; loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) { if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden'); emptyEl.classList.remove('hidden');
updateMoveSelection();
return; return;
} }
window.connectionsStore = {}; window.connectionsStore = {};
@@ -2670,6 +2803,9 @@
listEl.innerHTML += ` listEl.innerHTML += `
<div class="client-item" style="${disabledStyle}"> <div class="client-item" style="${disabledStyle}">
<label class="conn-select-wrap">
<input type="checkbox" class="conn-select" data-client-id="${escapeJs(client.clientId)}" onchange="updateMoveSelection()">
</label>
<div class="client-info"> <div class="client-info">
<div class="client-avatar">${initial}</div> <div class="client-avatar">${initial}</div>
<div> <div>
@@ -2686,6 +2822,7 @@
</div> </div>
`; `;
}); });
updateMoveSelection();
} catch (err) { } catch (err) {
loading.style.display = 'none'; loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + err.message, 'error'); showToast(_('connections_load_error') + ': ' + err.message, 'error');
+225 -122
View File
@@ -204,112 +204,147 @@
</div> </div>
<!-- BLOCK Tunnels --> <!-- BLOCK Tunnels -->
<div class="card"> <div class="card tunnels-card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3> <div class="tunnels-header">
<div style="display: flex; flex-direction: column; gap: var(--space-md);"> <h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </div>
<strong>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span> <div class="tunnels-local">
</div> <div class="tunnels-local-icon" aria-hidden="true">🏠</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;"> <div class="tunnels-local-body">
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code> <div class="tunnels-local-label">{{ _('local_server') }}</div>
<div class="tunnels-local-url">
<code id="localServerUrl">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
</div> </div>
</div> </div>
<span class="tunnel-status-pill tunnel-status-pill--ok">{{ _('active') }}</span>
</div>
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <div class="tunnels-section">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
<strong>Cloudflare Quick Tunnel</strong> <div class="tunnels-grid">
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span> <article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
</div> <div class="tunnel-card-head">
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> <div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true"></div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-meta">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')"> <h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')"> </div>
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span> <div class="tunnel-card-body">
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div> <div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</button> </div>
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')"> <div class="tunnel-card-actions tunnel-card-actions--triple">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span> <button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div> <span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
</button> <div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</div> </button>
</div> <button type="button" class="btn btn-secondary btn-sm hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> <article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> <div class="tunnel-card-head">
<strong>ngrok Tunnel + Authtoken</strong> <div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span> <div class="tunnel-card-meta">
</div> <h5 class="tunnel-card-title">ngrok</h5>
<div class="form-group" style="margin-bottom: var(--space-sm);"> <p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off"> </div>
</div> <span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div> </div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);"> <div class="tunnel-card-body">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')"> <input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span> <div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div> </div>
</button> <div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')"> <button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span> <span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button> </button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')"> <button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span> <span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button> </button>
</div> <button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
</div> <span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);"> </div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);"> </article>
<strong>Cloudflare WARP</strong>
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="warpConnectBtn" onclick="connectWarp()">
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<div class="tunnel-row" data-provider="nordvpn" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>NordVPN</strong>
<span class="badge badge-secondary" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="nordvpnStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_status_unknown') }}</div>
<div id="nordvpnServerText" class="form-hint hidden" style="margin-bottom: var(--space-sm);"></div>
<div id="nordvpnHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('nordvpn_hint') }}</div>
<div class="grid" style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="nordvpnConnectBtn" onclick="connectNordvpn()">
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div> </div>
</div> </div>
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
<div class="tunnels-section">
<h4 class="tunnels-section-title">{{ _('tunnels_section_outbound') }}</h4>
<div class="tunnels-grid">
<article class="tunnel-card tunnel-row" data-provider="warp" id="tunnelCard-warp">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--warp" aria-hidden="true">W</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">Cloudflare WARP</h5>
<p class="tunnel-card-desc">{{ _('tunnel_warp_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="warpInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<p id="warpStatusText" class="tunnel-status-line">{{ _('warp_status_unknown') }}</p>
<p id="warpHintText" class="tunnel-hint">{{ _('warp_hint') }}</p>
</div>
<div class="tunnel-card-actions">
<button type="button" class="btn btn-primary btn-sm" id="warpConnectBtn" onclick="connectWarp()">
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
<article class="tunnel-card tunnel-row" data-provider="nordvpn" id="tunnelCard-nordvpn">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--nordvpn" aria-hidden="true">N</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">NordVPN</h5>
<p class="tunnel-card-desc">{{ _('tunnel_nordvpn_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<p id="nordvpnStatusText" class="tunnel-status-line">{{ _('nordvpn_status_unknown') }}</p>
<p id="nordvpnServerText" class="tunnel-status-line tunnel-status-line--server hidden"></p>
<p id="nordvpnHintText" class="tunnel-hint">{{ _('nordvpn_hint') }}</p>
<div class="tunnel-nordvpn-fields">
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
</div>
</div>
<div class="tunnel-card-actions">
<button type="button" class="btn btn-primary btn-sm" id="nordvpnConnectBtn" onclick="connectNordvpn()">
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
</div>
</div>
<p class="tunnels-footnote">{{ _('tunnels_hint') }}</p>
</div> </div>
<!-- BLOCK SSL/HTTPS --> <!-- BLOCK SSL/HTTPS -->
@@ -622,11 +657,15 @@
</div> </div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;"> <div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;">
<button type="button" class="btn btn-primary" onclick="upgradePanel()" id="upgradePanelBtn" style="flex:1; min-width:180px;">
<span id="upgradePanelBtnText">⬆ {{ _('upgrade_panel') }}</span>
<div class="spinner hidden" id="upgradePanelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1; min-width:140px;"> <button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1; min-width:140px;">
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span> <span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button> </button>
<button type="button" class="btn btn-primary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;"> <button type="button" class="btn btn-secondary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
<span id="applyUpdateBtnText">⬆ {{ _('apply_update') }}</span> <span id="applyUpdateBtnText">⬆ {{ _('apply_update') }}</span>
<div class="spinner hidden" id="applyUpdateSpinner" style="width:14px; height:14px;"></div> <div class="spinner hidden" id="applyUpdateSpinner" style="width:14px; height:14px;"></div>
</button> </button>
@@ -945,31 +984,45 @@
const el = document.getElementById(`${provider}PublicUrls`); const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return; if (!el) return;
if (!urls || !urls.length) { if (!urls || !urls.length) {
el.className = 'tunnel-url-box tunnel-url-box--empty';
el.textContent = _('tunnel_no_public_url'); el.textContent = _('tunnel_no_public_url');
return; return;
} }
el.className = 'tunnel-url-box';
el.innerHTML = urls.map((url, idx) => ` el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};"> <div class="tunnel-url-row">
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code> <code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button> <button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div> </div>
`).join(''); `).join('');
} }
function setTunnelStatusPill(badge, { ok, info, text }) {
if (!badge) return;
badge.className = 'tunnel-status-pill' + (ok ? ' tunnel-status-pill--ok' : (info ? ' tunnel-status-pill--info' : ''));
badge.textContent = text;
}
function updateTunnelProvider(provider, status) { function updateTunnelProvider(provider, status) {
const badge = document.getElementById(`${provider}InstallBadge`); const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`); const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`); const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`); const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`); const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
const card = document.getElementById(`tunnelCard-${provider}`);
if (!badge || !text) return; if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary'; const running = !!status.running;
badge.textContent = status.installed ? _('installed') : _('not_installed'); setTunnelStatusPill(badge, {
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable')); ok: running,
if (startBtn) startBtn.disabled = !!status.running; info: !running && status.installed,
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running); text: running ? _('tunnel_running') : (status.installed ? _('installed') : _('not_installed')),
});
text.textContent = running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = running;
if (stopBtn) stopBtn.classList.toggle('hidden', !running);
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed); if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
if (card) card.classList.toggle('tunnel-card--running', running);
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : [])); renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
} }
@@ -993,12 +1046,16 @@
const connectBtn = document.getElementById('warpConnectBtn'); const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn'); const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText'); const connectText = document.getElementById('warpConnectBtnText');
const card = document.getElementById('tunnelCard-warp');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return; if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed; const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected'; const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary'); setTunnelStatusPill(badge, {
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')); ok: connected,
info: !connected && installed,
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `warp_status_${status.status || 'unknown'}`; const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown'); statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
@@ -1006,6 +1063,7 @@
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required'); connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected; connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected); disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
} }
async function connectWarp() { async function connectWarp() {
@@ -1058,12 +1116,16 @@
const connectBtn = document.getElementById('nordvpnConnectBtn'); const connectBtn = document.getElementById('nordvpnConnectBtn');
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn'); const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
const connectText = document.getElementById('nordvpnConnectBtnText'); const connectText = document.getElementById('nordvpnConnectBtnText');
const card = document.getElementById('tunnelCard-nordvpn');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return; if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed; const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected'; const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary'); setTunnelStatusPill(badge, {
badge.textContent = connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')); ok: connected,
info: !connected && installed,
text: connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `nordvpn_status_${status.status || 'unknown'}`; const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown'); statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
@@ -1076,6 +1138,7 @@
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required'); connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
connectBtn.disabled = connected; connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected); disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
} }
async function connectNordvpn() { async function connectNordvpn() {
@@ -1614,24 +1677,32 @@
statusDiv.classList.remove('hidden'); statusDiv.classList.remove('hidden');
if (modeHint) { if (modeHint) {
const mode = data.update_mode || (data.mode && data.mode.mode) || ''; const method = data.update_mode || (data.mode && data.mode.update_method) || (data.mode && data.mode.mode) || '';
if (data.can_auto_update) { if (!data.can_auto_update) {
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}"; if (method === 'docker') {
} else if (mode === 'docker') { modeHint.textContent = "{{ _('auto_update_docker')|default('Docker: on the host run git pull && docker compose up -d --build.') }}";
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker install: rebuild/pull image, or use a git checkout for one-click update.') }}"; } else {
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a writable install directory or git clone.') }}";
}
} else if (method === 'archive') {
modeHint.textContent = "{{ _('auto_update_archive')|default('One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.') }}";
} else { } else {
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a git clone. You can still download a release.') }}"; modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
} }
} }
const upgradeBtn = document.getElementById('upgradePanelBtn');
if (upgradeBtn) {
upgradeBtn.disabled = false;
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
}
if (data.update_available && latestVersion) { if (data.update_available && latestVersion) {
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`; statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
statusDiv.style.border = '1px solid var(--success)'; statusDiv.style.border = '1px solid var(--success)';
downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases'; downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
downloadBtn.classList.remove('hidden'); downloadBtn.classList.remove('hidden');
if (data.can_auto_update) { applyBtn.classList.remove('hidden');
applyBtn.classList.remove('hidden');
}
} else { } else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`; statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)'; statusDiv.style.border = '1px solid var(--border-color)';
@@ -1656,7 +1727,7 @@
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error'); showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
return; return;
} }
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git and restart the panel?') }} " + target)) { if (!confirm("{{ _('apply_update_confirm')|default('Install update from git.evilfox.cc and restart the panel?') }} " + target)) {
return; return;
} }
applyBtn.disabled = true; applyBtn.disabled = true;
@@ -1665,7 +1736,7 @@
try { try {
const data = await apiCall('/api/settings/apply_update', 'POST', { const data = await apiCall('/api/settings/apply_update', 'POST', {
target_version: target, target_version: target,
allow_dirty: false, allow_dirty: true,
}); });
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success'); showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 3500); setTimeout(() => { window.location.reload(); }, 3500);
@@ -1677,6 +1748,38 @@
} }
} }
async function upgradePanel() {
const btn = document.getElementById('upgradePanelBtn');
const text = document.getElementById('upgradePanelBtnText');
const spinner = document.getElementById('upgradePanelSpinner');
const latest = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
const confirmMsg = latest
? ("{{ _('upgrade_panel_confirm')|default('Download and install') }} " + latest + "?")
: "{{ _('upgrade_panel_confirm_latest')|default('Check for updates and install the latest release?') }}";
if (!confirm(confirmMsg)) return;
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
try {
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
if (data.up_to_date) {
showToast("{{ _('upgrade_panel_up_to_date')|default('Already on the latest version') }}", 'info');
await checkForUpdates(true);
return;
}
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 4000);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
await checkForUpdates(true);
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
}
}
// Auto-check once on Settings load // Auto-check once on Settings load
setTimeout(() => checkForUpdates(true), 800); setTimeout(() => checkForUpdates(true), 800);
+28 -2
View File
@@ -101,6 +101,19 @@
"connection_created": "Connection \"{}\" created", "connection_created": "Connection \"{}\" created",
"delete_connection_confirm": "Delete this connection? The configuration will stop working.", "delete_connection_confirm": "Delete this connection? The configuration will stop working.",
"connection_deleted": "Connection deleted", "connection_deleted": "Connection deleted",
"select_all": "Select all",
"connections_selected_count": "{} selected",
"move_connections": "Move",
"move_connections_title": "Move connections to another server",
"move_connections_target": "Target server",
"move_connections_confirm": "Move {} connection(s) to the selected server? Users will need new configs.",
"move_connections_hint": "Recreates selected connections on the target server and updates user assignments.",
"move_connections_warning": "Clients get new keys and IPs. Old configs stop working after move.",
"move_connections_delete_source": "Remove from current server after move",
"move_connections_success": "Moved {} connection(s)",
"move_connections_partial_fail": "{} could not be moved",
"move_connections_none_selected": "Select at least one connection",
"move_connections_no_targets": "No other servers available",
"config_tab": "📄 Config", "config_tab": "📄 Config",
"vpn_key_tab": "🔑 VPN-key", "vpn_key_tab": "🔑 VPN-key",
"qr_code_tab": "📱 QR-code", "qr_code_tab": "📱 QR-code",
@@ -267,6 +280,13 @@
"api_docs_title": "🔌 API Documentation", "api_docs_title": "🔌 API Documentation",
"api_docs_hint": "Use these interfaces to explore the panel\u0027s API capabilities", "api_docs_hint": "Use these interfaces to explore the panel\u0027s API capabilities",
"tunnels_title": "Tunnels", "tunnels_title": "Tunnels",
"tunnels_subtitle": "Expose the panel to the internet or route outbound traffic through a VPN.",
"tunnels_section_inbound": "Public access",
"tunnels_section_outbound": "Outbound VPN",
"tunnel_cloudflare_desc": "Free HTTPS URL via Cloudflare — no account required.",
"tunnel_ngrok_desc": "Stable tunnel with your ngrok authtoken.",
"tunnel_warp_desc": "Route server traffic through Cloudflare WARP.",
"tunnel_nordvpn_desc": "Connect this host to NordVPN servers.",
"local_server": "Local Server", "local_server": "Local Server",
"tunnel_install_enable": "Install \u0026 Enable", "tunnel_install_enable": "Install \u0026 Enable",
"tunnel_enable": "Enable Tunnel", "tunnel_enable": "Enable Tunnel",
@@ -488,8 +508,14 @@
"update_no_target": "No target version. Check for updates first.", "update_no_target": "No target version. Check for updates first.",
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).", "auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.", "auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.", "auto_update_docker": "Docker: one-click update downloads the release ZIP into the container. Rebuild the image to persist across container recreation.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release.", "auto_update_manual": "One-click update needs a writable install directory or git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.", "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.", "warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
"warp_install_required": "Install WARP first", "warp_install_required": "Install WARP first",
+28 -2
View File
@@ -100,6 +100,19 @@
"connection_created": "اتصال \"{}\" ایجاد شد", "connection_created": "اتصال \"{}\" ایجاد شد",
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.", "delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
"connection_deleted": "اتصال حذف شد", "connection_deleted": "اتصال حذف شد",
"select_all": "انتخاب همه",
"connections_selected_count": "{} انتخاب شده",
"move_connections": "انتقال",
"move_connections_title": "انتقال اتصال‌ها به سرور دیگر",
"move_connections_target": "سرور مقصد",
"move_connections_confirm": "{} اتصال به سرور انتخاب‌شده منتقل شود؟ کاربران به پیکربندی جدید نیاز دارند.",
"move_connections_hint": "اتصال‌های انتخاب‌شده روی سرور مقصد دوباره ساخته می‌شوند و پیوند کاربران حفظ می‌شود.",
"move_connections_warning": "کلیدها و IP جدید صادر می‌شود. پیکربندی‌های قدیمی کار نمی‌کنند.",
"move_connections_delete_source": "پس از انتقال از سرور فعلی حذف شود",
"move_connections_success": "{} اتصال منتقل شد",
"move_connections_partial_fail": "{} منتقل نشد",
"move_connections_none_selected": "حداقل یک اتصال انتخاب کنید",
"move_connections_no_targets": "سرور دیگری برای انتقال وجود ندارد",
"config_tab": "📄 فایل", "config_tab": "📄 فایل",
"vpn_key_tab": "🔑 کلید-VPN", "vpn_key_tab": "🔑 کلید-VPN",
"qr_code_tab": "📱 کد-QR", "qr_code_tab": "📱 کد-QR",
@@ -256,7 +269,14 @@
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.", "bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.",
"api_docs_title": "🔌 مستندات API", "api_docs_title": "🔌 مستندات API",
"api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید", "api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید",
"tunnels_title": "Tunnels", "tunnels_title": "تونل‌ها",
"tunnels_subtitle": "پنل را در اینترنت منتشر کنید یا ترافیک خروجی را از طریق VPN هدایت کنید.",
"tunnels_section_inbound": "دسترسی عمومی",
"tunnels_section_outbound": "VPN خروجی",
"tunnel_cloudflare_desc": "آدرس HTTPS رایگان از Cloudflare — بدون ثبت‌نام.",
"tunnel_ngrok_desc": "تونل پایدار با authtoken ngrok شما.",
"tunnel_warp_desc": "هدایت ترافیک سرور از طریق Cloudflare WARP.",
"tunnel_nordvpn_desc": "اتصال این میزبان به سرورهای NordVPN.",
"local_server": "سرور محلی", "local_server": "سرور محلی",
"tunnel_install_enable": "نصب و فعال‌سازی", "tunnel_install_enable": "نصب و فعال‌سازی",
"tunnel_enable": "فعال‌سازی تونل", "tunnel_enable": "فعال‌سازی تونل",
@@ -552,5 +572,11 @@
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).", "auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.", "auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.", "auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release." "auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
} }
+27 -1
View File
@@ -100,6 +100,19 @@
"connection_created": "Connexion \"{}\" créée", "connection_created": "Connexion \"{}\" créée",
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.", "delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
"connection_deleted": "Connexion supprimée", "connection_deleted": "Connexion supprimée",
"select_all": "Tout sélectionner",
"connections_selected_count": "{} sélectionné(s)",
"move_connections": "Déplacer",
"move_connections_title": "Déplacer les connexions vers un autre serveur",
"move_connections_target": "Serveur cible",
"move_connections_confirm": "Déplacer {} connexion(s) vers le serveur sélectionné ? Les utilisateurs auront besoin de nouveaux configs.",
"move_connections_hint": "Recrée les connexions sur le serveur cible et conserve les affectations utilisateur.",
"move_connections_warning": "Les clients reçoivent de nouvelles clés et IP. Les anciens configs cessent de fonctionner.",
"move_connections_delete_source": "Supprimer du serveur actuel après le déplacement",
"move_connections_success": "{} connexion(s) déplacée(s)",
"move_connections_partial_fail": "{} n'ont pas pu être déplacées",
"move_connections_none_selected": "Sélectionnez au moins une connexion",
"move_connections_no_targets": "Aucun autre serveur disponible",
"config_tab": "📄 Config", "config_tab": "📄 Config",
"vpn_key_tab": "🔑 Clé-VPN", "vpn_key_tab": "🔑 Clé-VPN",
"qr_code_tab": "📱 Code-QR", "qr_code_tab": "📱 Code-QR",
@@ -257,6 +270,13 @@
"api_docs_title": "🔌 Documentation API", "api_docs_title": "🔌 Documentation API",
"api_docs_hint": "Utilisez ces interfaces pour explorer l\u0027API", "api_docs_hint": "Utilisez ces interfaces pour explorer l\u0027API",
"tunnels_title": "Tunnels", "tunnels_title": "Tunnels",
"tunnels_subtitle": "Exposez le panneau sur Internet ou routez le trafic sortant via un VPN.",
"tunnels_section_inbound": "Accès public",
"tunnels_section_outbound": "VPN sortant",
"tunnel_cloudflare_desc": "URL HTTPS gratuite via Cloudflare — sans compte.",
"tunnel_ngrok_desc": "Tunnel stable avec votre authtoken ngrok.",
"tunnel_warp_desc": "Routez le trafic du serveur via Cloudflare WARP.",
"tunnel_nordvpn_desc": "Connectez cet hôte aux serveurs NordVPN.",
"local_server": "Serveur local", "local_server": "Serveur local",
"tunnel_install_enable": "Installer et activer", "tunnel_install_enable": "Installer et activer",
"tunnel_enable": "Activer le tunnel", "tunnel_enable": "Activer le tunnel",
@@ -552,5 +572,11 @@
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).", "auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.", "auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.", "auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release." "auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
} }
+28 -2
View File
@@ -101,6 +101,19 @@
"connection_created": "Подключение \"{}\" создано", "connection_created": "Подключение \"{}\" создано",
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.", "delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
"connection_deleted": "Подключение удалено", "connection_deleted": "Подключение удалено",
"select_all": "Выбрать все",
"connections_selected_count": "Выбрано: {}",
"move_connections": "Перенести",
"move_connections_title": "Перенос подключений на другой сервер",
"move_connections_target": "Целевой сервер",
"move_connections_confirm": "Перенести {} подключение(й) на выбранный сервер? Пользователям понадобятся новые конфиги.",
"move_connections_hint": "Подключения будут созданы заново на целевом сервере, привязки к пользователям сохранятся.",
"move_connections_warning": "У клиентов будут новые ключи и IP. Старые конфиги перестанут работать.",
"move_connections_delete_source": "Удалить с текущего сервера после переноса",
"move_connections_success": "Перенесено подключений: {}",
"move_connections_partial_fail": "Не удалось перенести: {}",
"move_connections_none_selected": "Выберите хотя бы одно подключение",
"move_connections_no_targets": "Нет других серверов для переноса",
"config_tab": "📄 Конфиг", "config_tab": "📄 Конфиг",
"vpn_key_tab": "🔑 VPN-ключ", "vpn_key_tab": "🔑 VPN-ключ",
"qr_code_tab": "📱 QR-код", "qr_code_tab": "📱 QR-код",
@@ -267,6 +280,13 @@
"api_docs_title": "🔌 API Документация", "api_docs_title": "🔌 API Документация",
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели", "api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
"tunnels_title": "Туннели", "tunnels_title": "Туннели",
"tunnels_subtitle": "Публикация панели в интернете или исходящий трафик через VPN.",
"tunnels_section_inbound": "Публичный доступ",
"tunnels_section_outbound": "Исходящий VPN",
"tunnel_cloudflare_desc": "Бесплатный HTTPS-адрес через Cloudflare — без регистрации.",
"tunnel_ngrok_desc": "Стабильный туннель с вашим authtoken ngrok.",
"tunnel_warp_desc": "Маршрутизация трафика сервера через Cloudflare WARP.",
"tunnel_nordvpn_desc": "Подключение хоста к серверам NordVPN.",
"local_server": "Локальный сервер", "local_server": "Локальный сервер",
"tunnel_install_enable": "Установить и включить", "tunnel_install_enable": "Установить и включить",
"tunnel_enable": "Включить туннель", "tunnel_enable": "Включить туннель",
@@ -488,8 +508,14 @@
"update_no_target": "Нет целевой версии. Сначала проверьте обновления.", "update_no_target": "Нет целевой версии. Сначала проверьте обновления.",
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).", "auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
"auto_update_ready": "Для этой установки доступно автообновление через git.", "auto_update_ready": "Для этой установки доступно автообновление через git.",
"auto_update_docker": "Docker: пересоберите/подтяните образ или используйте git clone для обновления в один клик.", "auto_update_docker": "Docker: обновление в один клик скачивает ZIP релиза в контейнер. Пересоберите образ, чтобы сохранить изменения при пересоздании контейнера.",
"auto_update_manual": "Обновление в один клик требует git clone. Можно скачать релиз вручную.", "auto_update_manual": "Обновление в один клик требует git clone или каталог с правами записи. Можно скачать релиз вручную.",
"auto_update_archive": "Обновление в один клик: скачивает ZIP релиза с git.evilfox.cc и перезапускает панель.",
"upgrade_panel": "Обновить панель",
"upgrade_panel_confirm": "Скачать и установить",
"upgrade_panel_confirm_latest": "Проверить обновления и установить последний релиз?",
"upgrade_panel_working": "Обновление панели…",
"upgrade_panel_up_to_date": "Уже установлена последняя версия",
"warp_hint": "WARP направляет исходящий трафик этого хоста через Cloudflare. Он не создаёт публичный URL панели как Quick Tunnel или ngrok.", "warp_hint": "WARP направляет исходящий трафик этого хоста через Cloudflare. Он не создаёт публичный URL панели как Quick Tunnel или ngrok.",
"warp_install_hint": "Сначала установите официальный клиент Cloudflare WARP, затем перезапустите панель.", "warp_install_hint": "Сначала установите официальный клиент Cloudflare WARP, затем перезапустите панель.",
"warp_install_required": "Сначала установите WARP", "warp_install_required": "Сначала установите WARP",
+27 -1
View File
@@ -100,6 +100,19 @@
"connection_created": "连接 \"{}\" 已创建", "connection_created": "连接 \"{}\" 已创建",
"delete_connection_confirm": "确定删除此连接?配置将失效。", "delete_connection_confirm": "确定删除此连接?配置将失效。",
"connection_deleted": "连接已删除", "connection_deleted": "连接已删除",
"select_all": "全选",
"connections_selected_count": "已选 {}",
"move_connections": "迁移",
"move_connections_title": "将连接迁移到另一台服务器",
"move_connections_target": "目标服务器",
"move_connections_confirm": "将 {} 个连接迁移到所选服务器?用户需要新的配置。",
"move_connections_hint": "在目标服务器上重新创建所选连接,并保留用户绑定。",
"move_connections_warning": "客户端将获得新密钥和 IP,旧配置将失效。",
"move_connections_delete_source": "迁移后从当前服务器删除",
"move_connections_success": "已迁移 {} 个连接",
"move_connections_partial_fail": "{} 个无法迁移",
"move_connections_none_selected": "请至少选择一个连接",
"move_connections_no_targets": "没有其他可用服务器",
"config_tab": "📄 配置文件", "config_tab": "📄 配置文件",
"vpn_key_tab": "🔑 VPN 密钥", "vpn_key_tab": "🔑 VPN 密钥",
"qr_code_tab": "📱 二维码", "qr_code_tab": "📱 二维码",
@@ -257,6 +270,13 @@
"api_docs_title": "🔌 API 文档", "api_docs_title": "🔌 API 文档",
"api_docs_hint": "使用这些接口了解面板功能", "api_docs_hint": "使用这些接口了解面板功能",
"tunnels_title": "Tunnels", "tunnels_title": "Tunnels",
"tunnels_subtitle": "将面板暴露到互联网,或通过 VPN 路由出站流量。",
"tunnels_section_inbound": "公网访问",
"tunnels_section_outbound": "出站 VPN",
"tunnel_cloudflare_desc": "通过 Cloudflare 获取免费 HTTPS 地址,无需注册。",
"tunnel_ngrok_desc": "使用 ngrok authtoken 的稳定隧道。",
"tunnel_warp_desc": "通过 Cloudflare WARP 路由服务器流量。",
"tunnel_nordvpn_desc": "将此主机连接到 NordVPN 服务器。",
"local_server": "本地服务器", "local_server": "本地服务器",
"tunnel_install_enable": "安装并启用", "tunnel_install_enable": "安装并启用",
"tunnel_enable": "启用隧道", "tunnel_enable": "启用隧道",
@@ -552,5 +572,11 @@
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).", "auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.", "auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.", "auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release." "auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
} }