Template
Speed up API reads with data cache and faster server check/stats.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -167,6 +168,11 @@ load_translations()
|
|||||||
DATA_LOCK = asyncio.Lock()
|
DATA_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
async def load_data_async():
|
||||||
|
"""Load panel state off the event loop (uses in-process cache)."""
|
||||||
|
return await asyncio.to_thread(load_data)
|
||||||
|
|
||||||
|
|
||||||
async def save_data_async(data):
|
async def save_data_async(data):
|
||||||
"""Saves panel state to PostgreSQL in a thread-safe way."""
|
"""Saves panel state to PostgreSQL in a thread-safe way."""
|
||||||
async with DATA_LOCK:
|
async with DATA_LOCK:
|
||||||
@@ -183,6 +189,37 @@ def get_ssh(server):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request, data: Optional[dict] = None):
|
||||||
|
user_id = request.session.get('user_id')
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
snapshot = data if data is not None else load_data()
|
||||||
|
for u in snapshot.get('users', []):
|
||||||
|
if u['id'] == user_id:
|
||||||
|
return u
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def tpl(request, template, **kwargs):
|
||||||
|
data = load_data()
|
||||||
|
lang = request.cookies.get('lang', 'en')
|
||||||
|
ctx = {
|
||||||
|
'request': request,
|
||||||
|
'current_user': get_current_user(request, data),
|
||||||
|
'site_settings': data.get('settings', {}).get('appearance', {}),
|
||||||
|
'captcha_settings': data.get('settings', {}).get('captcha', {}),
|
||||||
|
'telegram_settings': data.get('settings', {}).get('telegram', {}),
|
||||||
|
'bot_running': tg_bot.is_running(),
|
||||||
|
'lang': lang,
|
||||||
|
'_': lambda text_id: _t(text_id, lang),
|
||||||
|
'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))),
|
||||||
|
# Keep for legacy JS; prefer translations_json on new pages
|
||||||
|
'all_translations_json': json.dumps(TRANSLATIONS),
|
||||||
|
}
|
||||||
|
ctx.update(kwargs)
|
||||||
|
return templates.TemplateResponse(template, ctx)
|
||||||
|
|
||||||
|
|
||||||
def get_panel_local_url(request: Optional[Request] = None):
|
def get_panel_local_url(request: Optional[Request] = None):
|
||||||
data = load_data()
|
data = load_data()
|
||||||
ssl_conf = data.get('settings', {}).get('ssl', {})
|
ssl_conf = data.get('settings', {}).get('ssl', {})
|
||||||
@@ -1337,36 +1374,6 @@ async def sync_users_with_remnawave(data: dict):
|
|||||||
return 0, f"Error: {str(e)}"
|
return 0, f"Error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request):
|
|
||||||
user_id = request.session.get('user_id')
|
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
data = load_data()
|
|
||||||
for u in data.get('users', []):
|
|
||||||
if u['id'] == user_id:
|
|
||||||
return u
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def tpl(request, template, **kwargs):
|
|
||||||
data = load_data()
|
|
||||||
lang = request.cookies.get('lang', 'en')
|
|
||||||
ctx = {
|
|
||||||
'request': request,
|
|
||||||
'current_user': get_current_user(request),
|
|
||||||
'site_settings': data.get('settings', {}).get('appearance', {}),
|
|
||||||
'captcha_settings': data.get('settings', {}).get('captcha', {}),
|
|
||||||
'telegram_settings': data.get('settings', {}).get('telegram', {}),
|
|
||||||
'bot_running': tg_bot.is_running(),
|
|
||||||
'lang': lang,
|
|
||||||
'_': lambda text_id: _t(text_id, lang),
|
|
||||||
'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))),
|
|
||||||
'all_translations_json': json.dumps(TRANSLATIONS)
|
|
||||||
}
|
|
||||||
ctx.update(kwargs)
|
|
||||||
return templates.TemplateResponse(template, ctx)
|
|
||||||
|
|
||||||
|
|
||||||
# ======================== Pydantic Models ========================
|
# ======================== Pydantic Models ========================
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -1946,22 +1953,24 @@ async def server_detail(request: Request, server_id: int):
|
|||||||
|
|
||||||
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
|
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
|
||||||
async def users_page(request: Request):
|
async def users_page(request: Request):
|
||||||
user = get_current_user(request)
|
data = load_data()
|
||||||
|
user = get_current_user(request, data)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url='/login', status_code=302)
|
return RedirectResponse(url='/login', status_code=302)
|
||||||
if user['role'] not in ('admin', 'support'):
|
if user['role'] not in ('admin', 'support'):
|
||||||
return RedirectResponse(url='/my', status_code=302)
|
return RedirectResponse(url='/my', status_code=302)
|
||||||
data = load_data()
|
|
||||||
users_list = data.get('users', [])
|
users_list = data.get('users', [])
|
||||||
# Count connections per user
|
# Count connections per user
|
||||||
conns = data.get('user_connections', [])
|
conns = data.get('user_connections', [])
|
||||||
|
conn_counts = Counter(c.get('user_id') for c in conns if c.get('user_id'))
|
||||||
for u in users_list:
|
for u in users_list:
|
||||||
u['connections_count'] = sum(1 for c in conns if c['user_id'] == u['id'])
|
u['connections_count'] = conn_counts.get(u['id'], 0)
|
||||||
servers = data['servers']
|
servers = data['servers']
|
||||||
return tpl(
|
return tpl(
|
||||||
request, 'users.html',
|
request, 'users.html',
|
||||||
users=users_list,
|
users=users_list,
|
||||||
servers=servers,
|
servers=servers,
|
||||||
|
current_user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2309,54 +2318,97 @@ rm -rf /opt/amnezia
|
|||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_container_inventory(ssh) -> dict:
|
||||||
|
"""One SSH round-trip: map container name -> running bool."""
|
||||||
|
out, _, _ = ssh.run_sudo_command(
|
||||||
|
"docker ps -a --format '{{.Names}}|{{.Status}}' 2>/dev/null"
|
||||||
|
)
|
||||||
|
inventory = {}
|
||||||
|
for line in (out or '').splitlines():
|
||||||
|
if '|' not in line:
|
||||||
|
continue
|
||||||
|
name, status = line.split('|', 1)
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
inventory[name] = status.strip().lower().startswith('up')
|
||||||
|
return inventory
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_is_ready(ssh) -> bool:
|
||||||
|
ver, _, vcode = ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
return vcode == 0 and bool((ver or '').strip())
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/servers/{server_id}/stats', tags=["Servers"])
|
@app.post('/api/servers/{server_id}/stats', tags=["Servers"])
|
||||||
async def api_server_stats(request: Request, server_id: int):
|
async def api_server_stats(request: Request, server_id: int):
|
||||||
if not _check_admin(request):
|
if not _check_admin(request):
|
||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
try:
|
try:
|
||||||
data = load_data()
|
data = await load_data_async()
|
||||||
if server_id >= len(data['servers']):
|
if server_id >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
server = data['servers'][server_id]
|
server = data['servers'][server_id]
|
||||||
ssh = get_ssh(server)
|
|
||||||
ssh.connect()
|
def _fetch_stats():
|
||||||
stats = {}
|
ssh = get_ssh(server)
|
||||||
out, _, _ = ssh.run_command(
|
ssh.connect()
|
||||||
"top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1 2>/dev/null || "
|
try:
|
||||||
"awk '{u=$2+$4; t=$2+$4+$5; if(NR==1){pu=u;pt=t} else printf \"%.1f\", (u-pu)/(t-pt)*100}' "
|
script = r"""
|
||||||
"<(grep 'cpu ' /proc/stat) <(sleep 0.5 && grep 'cpu ' /proc/stat) 2>/dev/null"
|
cpu=$(top -bn1 2>/dev/null | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1)
|
||||||
)
|
if [ -z "$cpu" ]; then cpu=0; fi
|
||||||
try:
|
ram=$(free -b 2>/dev/null | awk 'NR==2{printf "%d %d", $3, $2}')
|
||||||
stats['cpu'] = round(float(out.strip().split('\n')[0]), 1)
|
disk=$(df -B1 / 2>/dev/null | awk 'NR==2{printf "%d %d", $3, $2}')
|
||||||
except (ValueError, IndexError):
|
dev=$(ip route 2>/dev/null | awk '/default/ {print $5; exit}')
|
||||||
stats['cpu'] = 0
|
net=$(awk -v d="${dev}:" '$1==d{printf "%d %d", $2, $10}' /proc/net/dev 2>/dev/null)
|
||||||
out, _, _ = ssh.run_command("free -b | awk 'NR==2{printf \"%d %d\", $3, $2}'")
|
up=$(uptime -p 2>/dev/null || uptime)
|
||||||
try:
|
printf 'CPU|%s\nRAM|%s\nDISK|%s\nNET|%s\nUP|%s\n' "${cpu:-0}" "${ram:-0 0}" "${disk:-0 0}" "${net:-0 0}" "$up"
|
||||||
parts = out.strip().split()
|
"""
|
||||||
used, total = int(parts[0]), int(parts[1])
|
out, _, _ = ssh.run_command(script)
|
||||||
stats.update(ram_used=used, ram_total=total, ram_percent=round(used / total * 100, 1) if total > 0 else 0)
|
finally:
|
||||||
except (ValueError, IndexError):
|
ssh.disconnect()
|
||||||
stats.update(ram_used=0, ram_total=0, ram_percent=0)
|
|
||||||
out, _, _ = ssh.run_command("df -B1 / | awk 'NR==2{printf \"%d %d\", $3, $2}'")
|
stats = {
|
||||||
try:
|
'cpu': 0,
|
||||||
parts = out.strip().split()
|
'ram_used': 0, 'ram_total': 0, 'ram_percent': 0,
|
||||||
used, total = int(parts[0]), int(parts[1])
|
'disk_used': 0, 'disk_total': 0, 'disk_percent': 0,
|
||||||
stats.update(disk_used=used, disk_total=total, disk_percent=round(used / total * 100, 1) if total > 0 else 0)
|
'net_rx': 0, 'net_tx': 0,
|
||||||
except (ValueError, IndexError):
|
'uptime': '',
|
||||||
stats.update(disk_used=0, disk_total=0, disk_percent=0)
|
}
|
||||||
out, _, _ = ssh.run_command(
|
for line in (out or '').splitlines():
|
||||||
"DEV=$(ip route | awk '/default/ {print $5}' | head -1); "
|
if '|' not in line:
|
||||||
"cat /proc/net/dev | awk -v dev=\"$DEV:\" '$1==dev{printf \"%d %d\", $2, $10}'"
|
continue
|
||||||
)
|
key, val = line.split('|', 1)
|
||||||
try:
|
key, val = key.strip(), val.strip()
|
||||||
parts = out.strip().split()
|
try:
|
||||||
stats['net_rx'], stats['net_tx'] = int(parts[0]), int(parts[1])
|
if key == 'CPU':
|
||||||
except (ValueError, IndexError):
|
stats['cpu'] = round(float(val.split()[0] if val else 0), 1)
|
||||||
stats['net_rx'] = stats['net_tx'] = 0
|
elif key == 'RAM':
|
||||||
out, _, _ = ssh.run_command("uptime -p 2>/dev/null || uptime")
|
parts = val.split()
|
||||||
stats['uptime'] = out.strip()
|
used, total = int(parts[0]), int(parts[1])
|
||||||
ssh.disconnect()
|
stats.update(
|
||||||
return stats
|
ram_used=used,
|
||||||
|
ram_total=total,
|
||||||
|
ram_percent=round(used / total * 100, 1) if total > 0 else 0,
|
||||||
|
)
|
||||||
|
elif key == 'DISK':
|
||||||
|
parts = val.split()
|
||||||
|
used, total = int(parts[0]), int(parts[1])
|
||||||
|
stats.update(
|
||||||
|
disk_used=used,
|
||||||
|
disk_total=total,
|
||||||
|
disk_percent=round(used / total * 100, 1) if total > 0 else 0,
|
||||||
|
)
|
||||||
|
elif key == 'NET':
|
||||||
|
parts = val.split()
|
||||||
|
stats['net_rx'], stats['net_tx'] = int(parts[0]), int(parts[1])
|
||||||
|
elif key == 'UP':
|
||||||
|
stats['uptime'] = val
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
return stats
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_fetch_stats)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error getting server stats")
|
logger.exception("Error getting server stats")
|
||||||
return JSONResponse({'error': str(e)}, status_code=500)
|
return JSONResponse({'error': str(e)}, status_code=500)
|
||||||
@@ -2367,128 +2419,130 @@ async def api_check_server(request: Request, server_id: int):
|
|||||||
if not _check_admin(request):
|
if not _check_admin(request):
|
||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
try:
|
try:
|
||||||
data = load_data()
|
data = await load_data_async()
|
||||||
if server_id >= len(data['servers']):
|
if server_id >= len(data['servers']):
|
||||||
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
return JSONResponse({'error': 'Server not found'}, status_code=404)
|
||||||
server = data['servers'][server_id]
|
server = data['servers'][server_id]
|
||||||
ssh = get_ssh(server)
|
|
||||||
ssh.connect()
|
|
||||||
# Just use awg's docker checker since it uses the same command
|
|
||||||
manager = get_protocol_manager(ssh, 'awg')
|
|
||||||
status = {'connection': 'ok', 'docker_installed': manager.check_docker_installed(), 'protocols': {}}
|
|
||||||
|
|
||||||
changed = False
|
|
||||||
if 'protocols' not in server:
|
|
||||||
server['protocols'] = {}
|
|
||||||
|
|
||||||
def merge_saved_protocol_status(proto, result=None, error=None):
|
def _run_check():
|
||||||
"""Merge live status with saved protocol metadata.
|
ssh = get_ssh(server)
|
||||||
|
ssh.connect()
|
||||||
Multi-instance protocols are source-of-truth in data.json because
|
|
||||||
they cannot be discovered from BASE_PROTOCOLS alone. A transient
|
|
||||||
check failure must not delete awg2__2/awg2__3 from the panel.
|
|
||||||
"""
|
|
||||||
db_proto = server.get('protocols', {}).get(proto, {}) or {}
|
|
||||||
merged = dict(result or {})
|
|
||||||
merged.setdefault('protocol', proto)
|
|
||||||
if error:
|
|
||||||
merged['error'] = error
|
|
||||||
if not merged.get('port') and db_proto.get('port'):
|
|
||||||
merged['port'] = db_proto['port']
|
|
||||||
if db_proto.get('awg_params') and not merged.get('awg_params'):
|
|
||||||
merged['awg_params'] = db_proto.get('awg_params')
|
|
||||||
merged['base_protocol'] = db_proto.get('base_protocol') or protocol_base(proto)
|
|
||||||
merged['instance'] = db_proto.get('instance') or protocol_instance(proto)
|
|
||||||
merged['display_name'] = db_proto.get('display_name') or protocol_display_name(proto)
|
|
||||||
merged['container_name'] = db_proto.get('container_name') or protocol_container_name(proto)
|
|
||||||
if protocol_base(proto) == 'adguard':
|
|
||||||
for key in ('web_port', 'mode', 'internal_ip', 'expose_web'):
|
|
||||||
if db_proto.get(key) not in (None, ''):
|
|
||||||
if key == 'web_port' and merged.get('web_exposed'):
|
|
||||||
continue
|
|
||||||
merged[key] = db_proto[key]
|
|
||||||
if 'expose_web' in db_proto and 'web_exposed' not in merged:
|
|
||||||
merged['web_exposed'] = bool(db_proto.get('expose_web'))
|
|
||||||
if protocol_base(proto) == 'nginx':
|
|
||||||
for key in ('domain', 'email', 'site_url'):
|
|
||||||
if db_proto.get(key) not in (None, ''):
|
|
||||||
merged[key] = db_proto[key]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
def should_preserve_saved_protocol(proto, result=None, err=None):
|
|
||||||
"""Return True when check must not remove a saved protocol record."""
|
|
||||||
db_proto = server.get('protocols', {}).get(proto)
|
|
||||||
if not db_proto:
|
|
||||||
return False
|
|
||||||
# Additional AWG-family instances are only known by their saved
|
|
||||||
# dynamic keys (awg__2/awg2__2/awg_legacy__2). Keep them unless
|
|
||||||
# the user explicitly uninstalls them.
|
|
||||||
if protocol_base(proto) in MULTI_INSTANCE_PROTOCOLS and protocol_instance(proto) > 1:
|
|
||||||
return True
|
|
||||||
# Do not delete any saved protocol on command/check errors; only a
|
|
||||||
# clean live result with container_exists=False may prune base apps.
|
|
||||||
if err or (result and result.get('error')):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def check_proto(proto):
|
|
||||||
try:
|
try:
|
||||||
p_manager = get_protocol_manager(ssh, proto)
|
status = {
|
||||||
result = _manager_call(p_manager, 'get_server_status', proto)
|
'connection': 'ok',
|
||||||
return proto, merge_saved_protocol_status(proto, result), None
|
'docker_installed': _docker_is_ready(ssh),
|
||||||
except Exception as e:
|
'protocols': {},
|
||||||
return proto, merge_saved_protocol_status(proto, {}, str(e)), str(e)
|
}
|
||||||
|
inventory = _docker_container_inventory(ssh) if status['docker_installed'] else {}
|
||||||
|
|
||||||
protocols_to_check = list(dict.fromkeys(BASE_PROTOCOLS + list(server.get('protocols', {}).keys())))
|
changed = False
|
||||||
# Run checks sequentially. Several managers use the same SSH connection;
|
if 'protocols' not in server:
|
||||||
# checking them in parallel through one SSH object can produce false
|
server['protocols'] = {}
|
||||||
# negatives and previously caused dynamic AWG instances to be removed.
|
|
||||||
for proto in protocols_to_check:
|
def merge_saved_protocol_status(proto, result=None, error=None):
|
||||||
proto, result, err = check_proto(proto)
|
db_proto = server.get('protocols', {}).get(proto, {}) or {}
|
||||||
status['protocols'][proto] = result
|
merged = dict(result or {})
|
||||||
if err:
|
merged.setdefault('protocol', proto)
|
||||||
continue
|
if error:
|
||||||
if result.get('container_exists'):
|
merged['error'] = error
|
||||||
if proto not in server['protocols']:
|
if not merged.get('port') and db_proto.get('port'):
|
||||||
server['protocols'][proto] = {
|
merged['port'] = db_proto['port']
|
||||||
'installed': True,
|
if db_proto.get('awg_params') and not merged.get('awg_params'):
|
||||||
'port': result.get('port', '55424'),
|
merged['awg_params'] = db_proto.get('awg_params')
|
||||||
'awg_params': result.get('awg_params', {}),
|
merged['base_protocol'] = db_proto.get('base_protocol') or protocol_base(proto)
|
||||||
'base_protocol': protocol_base(proto),
|
merged['instance'] = db_proto.get('instance') or protocol_instance(proto)
|
||||||
'instance': protocol_instance(proto),
|
merged['display_name'] = db_proto.get('display_name') or protocol_display_name(proto)
|
||||||
'display_name': protocol_display_name(proto),
|
merged['container_name'] = db_proto.get('container_name') or protocol_container_name(proto)
|
||||||
'container_name': protocol_container_name(proto),
|
|
||||||
}
|
|
||||||
if protocol_base(proto) == 'adguard':
|
if protocol_base(proto) == 'adguard':
|
||||||
server['protocols'][proto].update({
|
for key in ('web_port', 'mode', 'internal_ip', 'expose_web'):
|
||||||
'mode': result.get('mode'),
|
if db_proto.get(key) not in (None, ''):
|
||||||
'internal_ip': result.get('internal_ip'),
|
if key == 'web_port' and merged.get('web_exposed'):
|
||||||
'web_port': result.get('web_port'),
|
continue
|
||||||
'expose_web': result.get('web_exposed'),
|
merged[key] = db_proto[key]
|
||||||
})
|
if 'expose_web' in db_proto and 'web_exposed' not in merged:
|
||||||
|
merged['web_exposed'] = bool(db_proto.get('expose_web'))
|
||||||
if protocol_base(proto) == 'nginx':
|
if protocol_base(proto) == 'nginx':
|
||||||
server['protocols'][proto].update({
|
for key in ('domain', 'email', 'site_url'):
|
||||||
'domain': result.get('domain'),
|
if db_proto.get(key) not in (None, ''):
|
||||||
'email': result.get('email'),
|
merged[key] = db_proto[key]
|
||||||
'site_url': result.get('site_url'),
|
return merged
|
||||||
})
|
|
||||||
changed = True
|
def should_preserve_saved_protocol(proto, result=None, err=None):
|
||||||
else:
|
db_proto = server.get('protocols', {}).get(proto)
|
||||||
if proto in server['protocols']:
|
if not db_proto:
|
||||||
if should_preserve_saved_protocol(proto, result, err):
|
return False
|
||||||
# Keep saved dynamic instances visible as installed but stopped/unchecked.
|
if protocol_base(proto) in MULTI_INSTANCE_PROTOCOLS and protocol_instance(proto) > 1:
|
||||||
status['protocols'][proto]['container_exists'] = True
|
return True
|
||||||
status['protocols'][proto].setdefault('container_running', False)
|
if err or (result and result.get('error')):
|
||||||
status['protocols'][proto]['status_preserved'] = True
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_proto(proto):
|
||||||
|
cname = protocol_container_name(proto)
|
||||||
|
# Fast path: skip deep manager probes when container is absent
|
||||||
|
if cname and cname not in inventory:
|
||||||
|
return proto, merge_saved_protocol_status(proto, {
|
||||||
|
'container_exists': False,
|
||||||
|
'container_running': False,
|
||||||
|
'protocol': proto,
|
||||||
|
}), None
|
||||||
|
try:
|
||||||
|
p_manager = get_protocol_manager(ssh, proto)
|
||||||
|
result = _manager_call(p_manager, 'get_server_status', proto)
|
||||||
|
return proto, merge_saved_protocol_status(proto, result), None
|
||||||
|
except Exception as e:
|
||||||
|
return proto, merge_saved_protocol_status(proto, {}, str(e)), str(e)
|
||||||
|
|
||||||
|
protocols_to_check = list(dict.fromkeys(
|
||||||
|
BASE_PROTOCOLS + list(server.get('protocols', {}).keys())
|
||||||
|
))
|
||||||
|
for proto in protocols_to_check:
|
||||||
|
proto, result, err = check_proto(proto)
|
||||||
|
status['protocols'][proto] = result
|
||||||
|
if err:
|
||||||
|
continue
|
||||||
|
if result.get('container_exists'):
|
||||||
|
if proto not in server['protocols']:
|
||||||
|
server['protocols'][proto] = {
|
||||||
|
'installed': True,
|
||||||
|
'port': result.get('port', '55424'),
|
||||||
|
'awg_params': result.get('awg_params', {}),
|
||||||
|
'base_protocol': protocol_base(proto),
|
||||||
|
'instance': protocol_instance(proto),
|
||||||
|
'display_name': protocol_display_name(proto),
|
||||||
|
'container_name': protocol_container_name(proto),
|
||||||
|
}
|
||||||
|
if protocol_base(proto) == 'adguard':
|
||||||
|
server['protocols'][proto].update({
|
||||||
|
'mode': result.get('mode'),
|
||||||
|
'internal_ip': result.get('internal_ip'),
|
||||||
|
'web_port': result.get('web_port'),
|
||||||
|
'expose_web': result.get('web_exposed'),
|
||||||
|
})
|
||||||
|
if protocol_base(proto) == 'nginx':
|
||||||
|
server['protocols'][proto].update({
|
||||||
|
'domain': result.get('domain'),
|
||||||
|
'email': result.get('email'),
|
||||||
|
'site_url': result.get('site_url'),
|
||||||
|
})
|
||||||
|
changed = True
|
||||||
else:
|
else:
|
||||||
del server['protocols'][proto]
|
if proto in server['protocols']:
|
||||||
changed = True
|
if should_preserve_saved_protocol(proto, result, err):
|
||||||
|
status['protocols'][proto]['container_exists'] = True
|
||||||
if changed:
|
status['protocols'][proto].setdefault('container_running', False)
|
||||||
save_data(data)
|
status['protocols'][proto]['status_preserved'] = True
|
||||||
|
else:
|
||||||
ssh.disconnect()
|
del server['protocols'][proto]
|
||||||
return status
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
save_data(data)
|
||||||
|
return status
|
||||||
|
finally:
|
||||||
|
ssh.disconnect()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_run_check)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error checking server")
|
logger.exception("Error checking server")
|
||||||
return JSONResponse({'error': str(e), 'connection': 'failed'}, status_code=500)
|
return JSONResponse({'error': str(e), 'connection': 'failed'}, status_code=500)
|
||||||
@@ -3470,9 +3524,10 @@ async def api_toggle_connection(request: Request, server_id: int, req: ToggleCon
|
|||||||
async def api_list_users(request: Request, search: str = '', page: int = 1, size: int = 10):
|
async def api_list_users(request: Request, search: str = '', page: int = 1, size: int = 10):
|
||||||
if not _check_admin(request):
|
if not _check_admin(request):
|
||||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||||
data = load_data()
|
data = await load_data_async()
|
||||||
all_users = data.get('users', [])
|
all_users = data.get('users', [])
|
||||||
conns = data.get('user_connections', [])
|
conns = data.get('user_connections', [])
|
||||||
|
conn_counts = Counter(c.get('user_id') for c in conns if c.get('user_id'))
|
||||||
|
|
||||||
# Filter
|
# Filter
|
||||||
filtered = []
|
filtered = []
|
||||||
@@ -3500,7 +3555,7 @@ async def api_list_users(request: Request, search: str = '', page: int = 1, size
|
|||||||
'telegramId': u.get('telegramId'),
|
'telegramId': u.get('telegramId'),
|
||||||
'email': u.get('email'),
|
'email': u.get('email'),
|
||||||
'description': u.get('description'),
|
'description': u.get('description'),
|
||||||
'connections_count': sum(1 for c in conns if c['user_id'] == u['id']),
|
'connections_count': conn_counts.get(u['id'], 0),
|
||||||
'traffic_used': u.get('traffic_used', 0),
|
'traffic_used': u.get('traffic_used', 0),
|
||||||
'traffic_total': u.get('traffic_total', 0),
|
'traffic_total': u.get('traffic_total', 0),
|
||||||
'traffic_limit': u.get('traffic_limit', 0),
|
'traffic_limit': u.get('traffic_limit', 0),
|
||||||
|
|||||||
+36
-1
@@ -6,10 +6,12 @@ FastAPI handlers keep working without a full rewrite.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -21,6 +23,11 @@ from .connection import get_pool, init_schema
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Process-wide snapshot cache. load_data() returns a deep copy so callers can
|
||||||
|
# mutate safely; save_data() refreshes the cache after a successful write.
|
||||||
|
_DATA_CACHE: Optional[dict] = None
|
||||||
|
_DATA_CACHE_LOCK = threading.RLock()
|
||||||
|
|
||||||
DEFAULT_SETTINGS = {
|
DEFAULT_SETTINGS = {
|
||||||
'appearance': {
|
'appearance': {
|
||||||
'title': 'Amnezia',
|
'title': 'Amnezia',
|
||||||
@@ -208,7 +215,13 @@ def _row_to_invite(row) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_data() -> dict:
|
def invalidate_data_cache() -> None:
|
||||||
|
global _DATA_CACHE
|
||||||
|
with _DATA_CACHE_LOCK:
|
||||||
|
_DATA_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_data_from_db() -> dict:
|
||||||
init_schema()
|
init_schema()
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
with pool.connection() as conn:
|
with pool.connection() as conn:
|
||||||
@@ -266,8 +279,20 @@ def load_data() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_data() -> dict:
|
||||||
|
"""Return panel state. Uses an in-process cache; always returns a deep copy."""
|
||||||
|
global _DATA_CACHE
|
||||||
|
with _DATA_CACHE_LOCK:
|
||||||
|
if _DATA_CACHE is not None:
|
||||||
|
return copy.deepcopy(_DATA_CACHE)
|
||||||
|
data = _fetch_data_from_db()
|
||||||
|
_DATA_CACHE = data
|
||||||
|
return copy.deepcopy(data)
|
||||||
|
|
||||||
|
|
||||||
def save_data(data: dict) -> None:
|
def save_data(data: dict) -> None:
|
||||||
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
|
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
|
||||||
|
global _DATA_CACHE
|
||||||
init_schema()
|
init_schema()
|
||||||
servers = data.get('servers') or []
|
servers = data.get('servers') or []
|
||||||
users = data.get('users') or []
|
users = data.get('users') or []
|
||||||
@@ -419,6 +444,16 @@ def save_data(data: dict) -> None:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Keep caller dict and cache aligned with what was actually persisted
|
||||||
|
data['servers'] = servers
|
||||||
|
data['users'] = users
|
||||||
|
data['user_connections'] = connections
|
||||||
|
data['api_tokens'] = tokens
|
||||||
|
data['invite_links'] = invite_links
|
||||||
|
data['settings'] = settings
|
||||||
|
with _DATA_CACHE_LOCK:
|
||||||
|
_DATA_CACHE = copy.deepcopy(data)
|
||||||
|
|
||||||
|
|
||||||
def export_data_dict() -> dict:
|
def export_data_dict() -> dict:
|
||||||
"""Export current DB state as the legacy data.json document."""
|
"""Export current DB state as the legacy data.json document."""
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class SSHManager:
|
|||||||
if not self.client:
|
if not self.client:
|
||||||
raise ConnectionError("Not connected to server")
|
raise ConnectionError("Not connected to server")
|
||||||
|
|
||||||
logger.info(f"Running command: {command[:100]}...")
|
logger.debug(f"Running command: {command[:100]}...")
|
||||||
stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
|
stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout)
|
||||||
|
|
||||||
# Crucial: set timeout on the channel to prevent hanging indefinitely
|
# Crucial: set timeout on the channel to prevent hanging indefinitely
|
||||||
|
|||||||
Reference in New Issue
Block a user