Compare commits

..
5 Commits
13 changed files with 1237 additions and 157 deletions
+2 -1
View File
@@ -5,6 +5,7 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_PORT=5000 \
PANEL_IN_DOCKER=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
@@ -21,6 +22,6 @@ COPY . .
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/health" >/dev/null || exit 1
CMD ["python3", "app.py"]
+15
View File
@@ -228,6 +228,21 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork)
### v2.5.6
* **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link.
### v2.5.5
* **Fix 404 after panel update** — correct tunnel/local port when `APP_PORT` is set (Docker), health check before reload, auto-restart quick tunnels after reboot, reliable archive download URLs.
### 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.
+346 -20
View File
@@ -29,7 +29,8 @@ from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Stre
from starlette.background import BackgroundTask
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi import FastAPI, Request, Query, UploadFile, File
from fastapi import FastAPI, Request, Query, UploadFile, File, HTTPException
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from pydantic import BaseModel
from typing import Optional, List, Dict
@@ -102,7 +103,7 @@ else:
application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.5.1"
CURRENT_VERSION = "v2.5.6"
RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -224,12 +225,46 @@ def tpl(request, template, **kwargs):
return templates.TemplateResponse(template, ctx)
def get_panel_listen_port(data: Optional[dict] = None) -> int:
try:
env_port = int(os.environ.get('APP_PORT', '0') or '0')
except ValueError:
env_port = 0
if env_port:
return env_port
if data is None:
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
try:
return int(ssl_conf.get('panel_port', 5000) or 5000)
except (TypeError, ValueError):
return 5000
def panel_ssl_active(data: Optional[dict] = None) -> bool:
if data is None:
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {}) or {}
if not ssl_conf.get('enabled'):
return False
# Docker/compose usually terminates TLS outside the app and serves plain HTTP inside.
if os.environ.get('APP_PORT'):
return False
cert_path = ssl_conf.get('cert_path')
key_path = ssl_conf.get('key_path')
if ssl_conf.get('cert_text') or ssl_conf.get('key_text'):
return True
return bool(
cert_path and key_path
and os.path.exists(cert_path) and os.path.exists(key_path)
)
def get_panel_local_url(request: Optional[Request] = None):
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {})
scheme = 'https' if ssl_conf.get('enabled') else 'http'
scheme = 'https' if panel_ssl_active(data) else 'http'
host = '127.0.0.1'
port = ssl_conf.get('panel_port', 5000) or 5000
port = get_panel_listen_port(data)
if request:
scheme = request.url.scheme or scheme
host = request.url.hostname or host
@@ -239,12 +274,59 @@ def get_panel_local_url(request: Optional[Request] = None):
def get_panel_tunnel_target_url():
data = load_data()
ssl_conf = data.get('settings', {}).get('ssl', {})
scheme = 'https' if ssl_conf.get('enabled') else 'http'
port = ssl_conf.get('panel_port', 5000) or 5000
scheme = 'https' if panel_ssl_active(data) else 'http'
port = get_panel_listen_port(data)
return f"{scheme}://127.0.0.1:{port}"
def get_panel_public_url(request: Optional[Request] = None) -> str:
if request is not None:
forwarded_proto = (request.headers.get('x-forwarded-proto') or '').split(',')[0].strip()
scheme = forwarded_proto or request.url.scheme or 'http'
host = (
(request.headers.get('x-forwarded-host') or '').split(',')[0].strip()
or request.headers.get('host')
or request.url.netloc
)
if host:
return f"{scheme}://{host}/"
return get_panel_local_url(request).rstrip('/') + '/'
def enrich_update_result(request: Request, result: dict) -> dict:
result['panel_url'] = get_panel_public_url(request)
return result
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
if exc.status_code != 404:
detail = exc.detail if isinstance(exc.detail, str) else 'Error'
if request.url.path.startswith('/api/'):
return JSONResponse({'error': detail}, status_code=exc.status_code)
return HTMLResponse(f'<h1>{exc.status_code}</h1><p>{detail}</p>', status_code=exc.status_code)
if request.url.path.startswith('/api/') or 'application/json' in (request.headers.get('accept') or '').lower():
return JSONResponse({'error': 'Not found'}, status_code=404)
home = get_panel_public_url(request)
return HTMLResponse(
f'''<!DOCTYPE html><html><head><meta charset="utf-8"><title>Amnezia Panel</title></head>
<body style="font-family:sans-serif;max-width:520px;margin:3rem auto;padding:0 1rem;">
<h1>Страница не найдена</h1>
<p>Адрес <code>{request.url.path}</code> не существует в панели.</p>
<p>Если вы только что обновили панель, подождите 1030 секунд и откройте:</p>
<p><a href="{home}">{home}</a></p>
<p><a href="/health">/health</a> проверка, что панель запущена.</p>
</body></html>''',
status_code=404,
)
@app.get('/health', include_in_schema=False)
@app.get('/api/health', include_in_schema=False)
async def health_check():
return {'status': 'ok', 'version': CURRENT_VERSION}
def get_warp_cli_binary():
found = shutil.which(WARP_CLI_COMMAND)
if found:
@@ -1311,6 +1393,186 @@ async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool:
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):
"""
Executes multiple SSH operations efficiently.
@@ -1929,10 +2191,12 @@ class ConnectionActionRequest(BaseModel):
client_id: str = ''
class ToggleConnectionRequest(BaseModel):
class MoveConnectionsRequest(BaseModel):
protocol: str = 'awg'
client_id: str = ''
enable: bool = True
target_server_id: int
client_ids: List[str]
target_protocol: Optional[str] = None
delete_source: bool = True
class AddUserRequest(BaseModel):
@@ -2254,6 +2518,24 @@ async def startup():
logger.info("Starting Telegram bot from saved settings...")
tg_bot.launch_bot(tg_cfg['token'], load_data, generate_vpn_link, save_data)
# Reattach quick tunnels after panel restart so public URLs keep working.
try:
tunnel_state = await asyncio.to_thread(load_tunnel_state)
local_url = get_panel_tunnel_target_url()
for provider in ('cloudflare', 'ngrok'):
state = tunnel_state.get(provider) or {}
if not (state.get('public_url') or state.get('pid') or state.get('started_at')):
continue
if state.get('pid') and pid_is_running(state.get('pid')):
continue
try:
await asyncio.to_thread(start_tunnel, provider, local_url)
logger.info('Restarted %s tunnel after panel boot -> %s', provider, local_url)
except Exception as exc:
logger.warning('Could not restart %s tunnel after panel boot: %s', provider, exc)
except Exception as exc:
logger.warning('Tunnel auto-restart skipped: %s', exc)
def _scrape_server_traffic(server, sid, my_conns):
server_updates = []
@@ -2457,7 +2739,22 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/')
server = data['servers'][server_id]
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"])
@@ -4272,6 +4569,35 @@ async def api_get_connection_config(request: Request, server_id: int, req: Conne
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"])
async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest):
if not _check_admin(request):
@@ -5617,11 +5943,11 @@ async def api_my_connection_config(request: Request, connection_id: str):
return JSONResponse({'error': str(e)}, status_code=500)
@app.get('/settings', tags=["System Templates"])
@app.get('/settings', response_class=HTMLResponse, tags=["System Templates"])
async def settings_page(request: Request):
user = _check_admin(request)
if not user:
return RedirectResponse('/login')
return RedirectResponse('/login', status_code=302)
data = load_data()
from managers.xui_servers import list_xui_servers, public_server_view, ensure_xui_servers
settings = data.setdefault('settings', {})
@@ -5725,8 +6051,8 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest):
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=status_code)
if result.get('restart'):
UpdateManager.schedule_restart(1.5)
return result
UpdateManager.schedule_restart(application_path, 1.5)
return enrich_update_result(request, result)
@app.post('/api/settings/upgrade_panel', tags=["Settings"])
@@ -5763,10 +6089,10 @@ async def api_upgrade_panel(request: Request):
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)
UpdateManager.schedule_restart(application_path, 1.5)
result['up_to_date'] = False
result['previous_version'] = CURRENT_VERSION
return result
return enrich_update_result(request, result)
@app.get('/api/settings/tunnels/status', tags=["Settings"])
@@ -6338,10 +6664,10 @@ if __name__ == '__main__':
uvicorn_kwargs = {
"app": app,
"host": "0.0.0.0",
"port": env_port or ssl_conf.get('panel_port', 5000) or 5000,
"port": env_port or get_panel_listen_port(data),
}
if ssl_conf.get('enabled') and cert_file and key_file:
if panel_ssl_active(data) and cert_file and key_file:
if os.path.exists(cert_file) and os.path.exists(key_file):
logger.info(f"Starting panel with HTTPS enabled on domain: {ssl_conf.get('domain')} at port {uvicorn_kwargs['port']}")
uvicorn_kwargs["ssl_certfile"] = cert_file
+1 -1
View File
@@ -37,7 +37,7 @@ services:
- amnezia_data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/docs >/dev/null || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
+68 -10
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import os
import re
@@ -105,7 +106,10 @@ class UpdateManager:
return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
def _docker_update_allowed(self) -> bool:
return os.environ.get('PANEL_UPDATE_ALLOW_DOCKER', '').strip().lower() in ('1', 'true', 'yes')
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')
@@ -275,12 +279,33 @@ class UpdateManager:
}
def _archive_urls(self, tag: str) -> list[str]:
urls: list[str] = []
api_base = api_latest_url().rsplit('/releases/latest', 1)[0]
for endpoint in (f'{api_base}/releases/tags/{tag}', api_latest_url()):
try:
req = urllib.request.Request(
endpoint,
headers={'Accept': 'application/json', 'User-Agent': 'Amnezia-Web-Panel-Updater'},
)
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode('utf-8', errors='replace') or '{}')
zipball = (payload.get('zipball_url') or '').strip()
if zipball:
urls.append(zipball)
except Exception:
pass
base = repo_url()
return [
urls.extend([
f'{base}/archive/{tag}.zip',
f'{base}/archive/{tag.lstrip("v")}.zip',
f'{api_latest_url().rsplit("/releases/latest", 1)[0]}/archive/{tag}.zip',
]
f'{api_base}/archive/{tag}.zip',
])
seen = set()
ordered: list[str] = []
for url in urls:
if url and url not in seen:
seen.add(url)
ordered.append(url)
return ordered
def _download_archive(self, tag: str, dest_path: str) -> None:
last_err = 'unknown error'
@@ -308,6 +333,15 @@ class UpdateManager:
return only
return extract_dir
def _verify_tree(self, root: str) -> Optional[str]:
app_py = os.path.join(root, 'app.py')
if not os.path.isfile(app_py):
return 'Release archive is missing app.py'
code, _, err = _run([sys.executable, '-m', 'py_compile', app_py], root, timeout=60)
if code != 0:
return f'Updated app.py failed validation: {err or code}'
return None
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]
@@ -333,6 +367,9 @@ class UpdateManager:
src_root = self._extract_zip_root(extract_dir)
if not os.path.isdir(src_root):
return {'status': 'error', 'message': 'Invalid release archive layout'}
verify_err = self._verify_tree(src_root)
if verify_err:
return {'status': 'error', 'message': verify_err, 'method': 'archive'}
self._sync_tree(src_root)
except Exception as e:
return {'status': 'error', 'message': str(e), 'method': 'archive'}
@@ -380,15 +417,36 @@ class UpdateManager:
return {'status': 'error', 'message': hint, 'mode': mode}
@staticmethod
def schedule_restart(delay_sec: float = 1.5) -> None:
def schedule_restart(app_root: str, delay_sec: float = 1.5) -> None:
in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
def _restart():
time.sleep(max(0.5, float(delay_sec)))
cwd = os.path.abspath(app_root or os.getcwd())
argv = [sys.executable] + sys.argv
logger.info('Restarting panel after update: %s (cwd=%s, docker=%s)', argv, cwd, in_docker)
if in_docker:
# Let Docker restart policy bring the container back with updated /app files.
os._exit(0)
try:
argv = [sys.executable] + sys.argv
logger.info('Restarting panel after update: %s', argv)
os.chdir(cwd)
proc = subprocess.Popen(
argv,
cwd=cwd,
start_new_session=True,
close_fds=(os.name != 'nt'),
)
time.sleep(2.0)
if proc.poll() is None:
logger.info('New panel process started (pid=%s), stopping old process', proc.pid)
os._exit(0)
except Exception:
logger.exception('subprocess restart failed; trying execv')
try:
os.chdir(cwd)
os.execv(sys.executable, argv)
except Exception:
logger.exception('Failed to restart after update; exiting')
os._exit(0)
logger.exception('Failed to restart after update')
os._exit(1)
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
+353
View File
@@ -1282,6 +1282,7 @@ a:hover {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--bg-card);
border: 1px solid var(--border-color);
@@ -1294,6 +1295,45 @@ a: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 {
display: flex;
align-items: center;
@@ -1623,6 +1663,319 @@ a:hover {
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 {
margin-top: var(--space-md);
}
+137
View File
@@ -544,12 +544,23 @@
<option value="telemt">Telemt</option>
<option value="wireguard">WireGuard</option>
</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()">
<span>{{ icon('plus') }}</span> {{ _('add') }}
</button>
</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 class="loading-overlay" id="connectionsLoading" style="display:none;">
<div class="loading-spinner"></div>
@@ -969,6 +980,35 @@
</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) ===== -->
<div class="modal-backdrop" id="configModal">
<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>
const SERVER_ID = {{ server_id }};
const ALL_SERVERS = {{ servers_for_move | tojson }};
const SERVER_HOST = "{{ server.host }}";
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
@@ -2608,6 +2649,95 @@
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() {
const proto = document.getElementById('connProtoSelect').value;
const loading = document.getElementById('connectionsLoading');
@@ -2622,11 +2752,14 @@
loading.style.display = '';
emptyEl.classList.add('hidden');
listEl.innerHTML = '';
selectedConnectionIds.clear();
updateMoveSelection();
try {
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
loading.style.display = 'none';
if (!data.clients || data.clients.length === 0) {
emptyEl.classList.remove('hidden');
updateMoveSelection();
return;
}
window.connectionsStore = {};
@@ -2670,6 +2803,9 @@
listEl.innerHTML += `
<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-avatar">${initial}</div>
<div>
@@ -2686,6 +2822,7 @@
</div>
`;
});
updateMoveSelection();
} catch (err) {
loading.style.display = 'none';
showToast(_('connections_load_error') + ': ' + err.message, 'error');
+208 -122
View File
@@ -204,112 +204,147 @@
</div>
<!-- BLOCK Tunnels -->
<div class="card">
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3>
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
<div 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>{{ _('local_server') }}</strong>
<span class="badge badge-info">{{ _('active') }}</span>
</div>
<div style="display:flex; gap:var(--space-sm); align-items:center;">
<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="card tunnels-card">
<div class="tunnels-header">
<h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
<p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
</div>
<div class="tunnels-local">
<div class="tunnels-local-icon" aria-hidden="true">🏠</div>
<div class="tunnels-local-body">
<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>
</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 style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>Cloudflare Quick Tunnel</strong>
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
</div>
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary 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 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>
</div>
<div class="tunnels-section">
<h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
<div class="tunnels-grid">
<article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true"></div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
<p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</div>
<div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<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);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<strong>ngrok Tunnel + Authtoken</strong>
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="form-group" style="margin-bottom: var(--space-sm);">
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
</div>
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</div>
<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 style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
<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>
<article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
<div class="tunnel-card-head">
<div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
<div class="tunnel-card-meta">
<h5 class="tunnel-card-title">ngrok</h5>
<p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
</div>
<span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
</div>
<div class="tunnel-card-body">
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
<div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
</div>
<div class="tunnel-card-actions tunnel-card-actions--triple">
<button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
</button>
</div>
</article>
</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>
<!-- BLOCK SSL/HTTPS -->
@@ -949,31 +984,45 @@
const el = document.getElementById(`${provider}PublicUrls`);
if (!el) return;
if (!urls || !urls.length) {
el.className = 'tunnel-url-box tunnel-url-box--empty';
el.textContent = _('tunnel_no_public_url');
return;
}
el.className = 'tunnel-url-box';
el.innerHTML = urls.map((url, idx) => `
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
<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>
<div class="tunnel-url-row">
<code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
</div>
`).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) {
const badge = document.getElementById(`${provider}InstallBadge`);
const text = document.getElementById(`${provider}TunnelBtnText`);
const startBtn = document.getElementById(`${provider}TunnelBtn`);
const stopBtn = document.getElementById(`${provider}StopBtn`);
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
const card = document.getElementById(`tunnelCard-${provider}`);
if (!badge || !text) return;
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
badge.textContent = status.installed ? _('installed') : _('not_installed');
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
if (startBtn) startBtn.disabled = !!status.running;
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
const running = !!status.running;
setTunnelStatusPill(badge, {
ok: running,
info: !running && status.installed,
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 (card) card.classList.toggle('tunnel-card--running', running);
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
}
@@ -997,12 +1046,16 @@
const connectBtn = document.getElementById('warpConnectBtn');
const disconnectBtn = document.getElementById('warpDisconnectBtn');
const connectText = document.getElementById('warpConnectBtnText');
const card = document.getElementById('tunnelCard-warp');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed'));
setTunnelStatusPill(badge, {
ok: connected,
info: !connected && installed,
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `warp_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
@@ -1010,6 +1063,7 @@
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
}
async function connectWarp() {
@@ -1062,12 +1116,16 @@
const connectBtn = document.getElementById('nordvpnConnectBtn');
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
const connectText = document.getElementById('nordvpnConnectBtnText');
const card = document.getElementById('tunnelCard-nordvpn');
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
const installed = !!status.installed;
const connected = !!status.connected || status.status === 'connected';
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
badge.textContent = connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed'));
setTunnelStatusPill(badge, {
ok: connected,
info: !connected && installed,
text: connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')),
});
const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
@@ -1080,6 +1138,7 @@
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
connectBtn.disabled = connected;
disconnectBtn.classList.toggle('hidden', !connected);
if (card) card.classList.toggle('tunnel-card--connected', connected);
}
async function connectNordvpn() {
@@ -1634,7 +1693,7 @@
const upgradeBtn = document.getElementById('upgradePanelBtn');
if (upgradeBtn) {
upgradeBtn.disabled = !data.can_auto_update;
upgradeBtn.disabled = false;
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
}
@@ -1643,9 +1702,7 @@
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.classList.remove('hidden');
if (data.can_auto_update) {
applyBtn.classList.remove('hidden');
}
applyBtn.classList.remove('hidden');
} else {
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
statusDiv.style.border = '1px solid var(--border-color)';
@@ -1661,6 +1718,24 @@
}
}
async function waitForPanelRestart(timeoutMs = 120000, panelUrl) {
const base = String(panelUrl || window.location.origin || '').replace(/\/$/, '');
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const res = await fetch(`${base}/api/health`, { cache: 'no-store' });
if (res.ok) {
window.location.href = base + '/';
return true;
}
} catch (_) {}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
const hint = base ? ` ${base}` : '';
showToast("{{ _('update_restart_timeout')|default('Panel did not come back in time. Refresh the page manually.') }}" + hint, 'error');
return false;
}
async function applyPanelUpdate() {
const applyBtn = document.getElementById('applyUpdateBtn');
const applyText = document.getElementById('applyUpdateBtnText');
@@ -1676,18 +1751,24 @@
applyBtn.disabled = true;
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
applySpinner.classList.remove('hidden');
let restarting = false;
try {
const data = await apiCall('/api/settings/apply_update', 'POST', {
target_version: target,
allow_dirty: true,
});
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 3500);
restarting = true;
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
await waitForPanelRestart(120000, data.panel_url);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
applyBtn.disabled = false;
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
applySpinner.classList.add('hidden');
} finally {
if (!restarting) {
applyBtn.disabled = false;
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
applySpinner.classList.add('hidden');
}
}
}
@@ -1704,6 +1785,7 @@
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
let restarting = false;
try {
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
if (data.up_to_date) {
@@ -1712,14 +1794,18 @@
return;
}
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 4000);
restarting = true;
text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
await waitForPanelRestart(120000, data.panel_url);
} 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') }}";
if (!restarting) {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
}
}
}
+23 -1
View File
@@ -101,6 +101,19 @@
"connection_created": "Connection \"{}\" created",
"delete_connection_confirm": "Delete this connection? The configuration will stop working.",
"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",
"vpn_key_tab": "🔑 VPN-key",
"qr_code_tab": "📱 QR-code",
@@ -267,6 +280,13 @@
"api_docs_title": "🔌 API Documentation",
"api_docs_hint": "Use these interfaces to explore the panel\u0027s API capabilities",
"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",
"tunnel_install_enable": "Install \u0026 Enable",
"tunnel_enable": "Enable Tunnel",
@@ -485,10 +505,12 @@
"applying_update": "Updating…",
"apply_update_confirm": "Install update from git.evilfox.cc and restart the panel?",
"update_restarting": "Updated. Restarting panel…",
"update_waiting_restart": "Waiting for panel to restart…",
"update_restart_timeout": "Panel did not come back in time. Refresh the page manually.",
"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_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 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",
+21 -1
View File
@@ -100,6 +100,19 @@
"connection_created": "اتصال \"{}\" ایجاد شد",
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
"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": "📄 فایل",
"vpn_key_tab": "🔑 کلید-VPN",
"qr_code_tab": "📱 کد-QR",
@@ -256,7 +269,14 @@
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.",
"api_docs_title": "🔌 مستندات 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": "سرور محلی",
"tunnel_install_enable": "نصب و فعال‌سازی",
"tunnel_enable": "فعال‌سازی تونل",
+20
View File
@@ -100,6 +100,19 @@
"connection_created": "Connexion \"{}\" créée",
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
"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",
"vpn_key_tab": "🔑 Clé-VPN",
"qr_code_tab": "📱 Code-QR",
@@ -257,6 +270,13 @@
"api_docs_title": "🔌 Documentation API",
"api_docs_hint": "Utilisez ces interfaces pour explorer l\u0027API",
"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",
"tunnel_install_enable": "Installer et activer",
"tunnel_enable": "Activer le tunnel",
+23 -1
View File
@@ -101,6 +101,19 @@
"connection_created": "Подключение \"{}\" создано",
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
"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": "📄 Конфиг",
"vpn_key_tab": "🔑 VPN-ключ",
"qr_code_tab": "📱 QR-код",
@@ -267,6 +280,13 @@
"api_docs_title": "🔌 API Документация",
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
"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": "Локальный сервер",
"tunnel_install_enable": "Установить и включить",
"tunnel_enable": "Включить туннель",
@@ -485,10 +505,12 @@
"applying_update": "Обновление…",
"apply_update_confirm": "Установить обновление с git.evilfox.cc и перезапустить панель?",
"update_restarting": "Обновлено. Перезапуск панели…",
"update_waiting_restart": "Ожидание перезапуска панели…",
"update_restart_timeout": "Панель не ответила вовремя. Обновите страницу вручную.",
"update_no_target": "Нет целевой версии. Сначала проверьте обновления.",
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
"auto_update_ready": "Для этой установки доступно автообновление через git.",
"auto_update_docker": "Docker: пересоберите/подтяните образ или используйте git clone для обновления в один клик.",
"auto_update_docker": "Docker: обновление в один клик скачивает ZIP релиза в контейнер. Пересоберите образ, чтобы сохранить изменения при пересоздании контейнера.",
"auto_update_manual": "Обновление в один клик требует git clone или каталог с правами записи. Можно скачать релиз вручную.",
"auto_update_archive": "Обновление в один клик: скачивает ZIP релиза с git.evilfox.cc и перезапускает панель.",
"upgrade_panel": "Обновить панель",
+20
View File
@@ -100,6 +100,19 @@
"connection_created": "连接 \"{}\" 已创建",
"delete_connection_confirm": "确定删除此连接?配置将失效。",
"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": "📄 配置文件",
"vpn_key_tab": "🔑 VPN 密钥",
"qr_code_tab": "📱 二维码",
@@ -257,6 +270,13 @@
"api_docs_title": "🔌 API 文档",
"api_docs_hint": "使用这些接口了解面板功能",
"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": "本地服务器",
"tunnel_install_enable": "安装并启用",
"tunnel_enable": "启用隧道",