Fixed merge requests
Reset user traffic consumption
Implemented automatic backups
This commit is contained in:
Максимов Владимир Сергеевич
2026-08-24 19:14:04 +03:00
parent 2f7d2cb643
commit dec0e90d11
10 changed files with 362 additions and 58 deletions
+210 -36
View File
@@ -19,7 +19,7 @@ import time
import urllib.request
import zipfile
import signal
from datetime import datetime
from datetime import datetime, timedelta
import io
from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse
from starlette.background import BackgroundTask
@@ -162,21 +162,39 @@ def load_data():
data.setdefault('users', [])
data.setdefault('user_connections', [])
data.setdefault('api_tokens', [])
data.setdefault('settings', {
'appearance': {
'title': 'Amnezia',
'logo': '❤️',
'subtitle': 'Web Panel'
},
'sync': {
'remnawave_url': '',
'remnawave_api_key': '',
'remnawave_sync': False,
'remnawave_sync_users': False,
'remnawave_create_conns': False,
'remnawave_server_id': 0,
'remnawave_protocol': 'awg'
}
settings = data.setdefault('settings', {})
settings.setdefault('appearance', {
'title': 'Amnezia',
'logo': '❤️',
'subtitle': 'Web Panel'
})
settings.setdefault('sync', {
'remnawave_url': '',
'remnawave_api_key': '',
'remnawave_sync': False,
'remnawave_sync_users': False,
'remnawave_create_conns': False,
'remnawave_server_id': 0,
'remnawave_protocol': 'awg'
})
settings.setdefault('captcha', {'enabled': False})
settings.setdefault('telegram', {'token': '', 'enabled': False})
settings.setdefault('ssl', {
'enabled': False,
'domain': '',
'cert_path': '',
'key_path': '',
'cert_text': '',
'key_text': '',
'panel_port': 5000
})
settings.setdefault('auto_backup', {
'enabled': False,
'interval_hours': 24,
'last_run_at': None,
'last_status': None,
'last_created_count': 0,
'last_error': None
})
return data
@@ -1595,6 +1613,11 @@ class TelegramSettings(BaseModel):
enabled: bool = False
class AutoBackupSettings(BaseModel):
enabled: bool = False
interval_hours: int = 24
class UpdateUserRequest(BaseModel):
@@ -1614,6 +1637,7 @@ class SaveSettingsRequest(BaseModel):
captcha: CaptchaSettings
telegram: TelegramSettings
ssl: SSLSettings
auto_backup: AutoBackupSettings = AutoBackupSettings()
class ToggleUserRequest(BaseModel):
@@ -1705,20 +1729,26 @@ async def startup():
changed = True
logger.info("Initialised empty api_tokens collection")
# SSL settings migration
if 'ssl' not in data.get('settings', {}):
if 'settings' not in data: data['settings'] = {}
data['settings']['ssl'] = {
'enabled': False,
'domain': '',
'cert_path': '',
'key_path': '',
'cert_text': '',
'key_text': '',
'panel_port': 5000
}
# Auto backup settings migration
auto_backup = data.setdefault('settings', {}).setdefault('auto_backup', {})
if 'enabled' not in auto_backup:
auto_backup['enabled'] = False
changed = True
if 'interval_hours' not in auto_backup:
auto_backup['interval_hours'] = 24
changed = True
if 'last_run_at' not in auto_backup:
auto_backup['last_run_at'] = None
changed = True
if 'last_status' not in auto_backup:
auto_backup['last_status'] = None
changed = True
if 'last_created_count' not in auto_backup:
auto_backup['last_created_count'] = 0
changed = True
if 'last_error' not in auto_backup:
auto_backup['last_error'] = None
changed = True
logger.info("Migrated SSL settings")
if changed:
save_data(data)
@@ -1733,6 +1763,112 @@ async def startup():
tg_bot.launch_bot(tg_cfg['token'], load_data, generate_vpn_link, save_data)
def _auto_backup_due(auto_backup: dict, now: Optional[datetime] = None) -> bool:
if not auto_backup.get('enabled'):
return False
try:
interval_hours = max(1, min(24, int(auto_backup.get('interval_hours') or 24)))
except (TypeError, ValueError):
interval_hours = 24
last_run_at = auto_backup.get('last_run_at')
if not last_run_at:
return True
try:
last_run = datetime.fromisoformat(str(last_run_at))
except ValueError:
return True
now = now or datetime.now()
return now - last_run >= timedelta(hours=interval_hours)
def _create_auto_backups_once(data: dict) -> dict:
started_at = datetime.now()
created = []
errors = []
for server_id, server in enumerate(data.get('servers', [])):
protocols = server.get('protocols', {}) or {}
installed_protocols = [
proto for proto, info in protocols.items()
if isinstance(info, dict) and info.get('installed') and is_valid_protocol(proto) and protocol_container_name(proto)
]
if not installed_protocols:
continue
ssh = None
try:
ssh = get_ssh(server)
ssh.connect()
backup_manager = BackupManager(ssh)
for proto in installed_protocols:
try:
result = backup_manager.create_backup(proto, protocol_container_name(proto))
if result.get('status') == 'success':
created.append({
'server_id': server_id,
'protocol': proto,
'name': result.get('backup', {}).get('name')
})
else:
errors.append({
'server_id': server_id,
'protocol': proto,
'error': result.get('message', 'Failed to create backup')
})
except Exception as e:
errors.append({'server_id': server_id, 'protocol': proto, 'error': str(e)})
except Exception as e:
for proto in installed_protocols:
errors.append({'server_id': server_id, 'protocol': proto, 'error': str(e)})
finally:
if ssh:
try:
ssh.disconnect()
except Exception:
pass
status = 'success' if not errors else ('partial' if created else 'error')
return {
'status': status,
'started_at': started_at.isoformat(),
'finished_at': datetime.now().isoformat(),
'created_count': len(created),
'created': created,
'errors': errors,
'error': '; '.join(f"server {e['server_id']} {e['protocol']}: {e['error']}" for e in errors[:5]) if errors else None
}
async def run_auto_backups_if_due():
data = load_data()
auto_backup = data.get('settings', {}).get('auto_backup', {})
if not _auto_backup_due(auto_backup):
return None
logger.info("Starting scheduled auto backups...")
result = await asyncio.to_thread(_create_auto_backups_once, data)
async with DATA_LOCK:
curr_data = load_data()
curr_auto = curr_data.setdefault('settings', {}).setdefault('auto_backup', {})
curr_auto['enabled'] = bool(curr_auto.get('enabled', auto_backup.get('enabled', False)))
try:
curr_auto['interval_hours'] = max(1, min(24, int(curr_auto.get('interval_hours') or auto_backup.get('interval_hours') or 24)))
except (TypeError, ValueError):
curr_auto['interval_hours'] = 24
curr_auto['last_run_at'] = result['finished_at']
curr_auto['last_status'] = result['status']
curr_auto['last_created_count'] = result['created_count']
curr_auto['last_error'] = result.get('error')
save_data(curr_data)
logger.info(
"Auto backups finished: status=%s created=%s errors=%s",
result['status'], result['created_count'], len(result.get('errors', []))
)
return result
def _scrape_server_traffic(server, sid, my_conns):
server_updates = []
try:
@@ -1851,7 +1987,10 @@ async def periodic_background_tasks():
logger.info(f"Traffic limit reached, disabling users: {to_disable_uids}")
await perform_mass_operations(toggle_uids=[(uid, False) for uid in to_disable_uids])
# --- 2. REMNAWAVE SYNC ---
# --- 2. AUTO BACKUP ---
await run_auto_backups_if_due()
# --- 3. REMNAWAVE SYNC ---
logger.info("Starting background Remnawave sync...")
data = load_data()
if data.get('settings', {}).get('sync', {}).get('remnawave_sync_users'):
@@ -3436,6 +3575,29 @@ async def api_toggle_user(request: Request, user_id: str, req: ToggleUserRequest
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/users/{user_id}/traffic/reset', tags=["Users"])
async def api_reset_user_traffic(request: Request, user_id: str):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = load_data()
user = next((u for u in data.get('users', []) if u.get('id') == user_id), None)
if not user:
return JSONResponse({'error': 'User not found'}, status_code=404)
user['traffic_used'] = 0
user['last_reset_at'] = datetime.now().isoformat()
save_data(data)
return {
'status': 'success',
'traffic_used': user['traffic_used'],
'last_reset_at': user['last_reset_at']
}
except Exception as e:
logger.exception("Error resetting user traffic")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/users/{user_id}/connections/add', tags=["Users"])
async def api_add_user_connection(request: Request, user_id: str, req: AddUserConnectionRequest):
if not _check_admin(request):
@@ -3806,13 +3968,25 @@ async def save_settings(request: Request, payload: SaveSettingsRequest):
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
data = load_data()
data['settings']['appearance'] = payload.appearance.dict()
data['settings']['sync'] = payload.sync.dict()
data['settings']['captcha'] = payload.captcha.dict()
data['settings']['telegram'] = payload.telegram.dict()
data['settings']['ssl'] = payload.ssl.dict()
settings = data.setdefault('settings', {})
settings['appearance'] = payload.appearance.dict()
settings['sync'] = payload.sync.dict()
settings['captcha'] = payload.captcha.dict()
settings['telegram'] = payload.telegram.dict()
settings['ssl'] = payload.ssl.dict()
old_auto_backup = settings.get('auto_backup', {}) or {}
interval_hours = max(1, min(24, int(payload.auto_backup.interval_hours or 24)))
settings['auto_backup'] = {
'enabled': bool(payload.auto_backup.enabled),
'interval_hours': interval_hours,
'last_run_at': old_auto_backup.get('last_run_at'),
'last_status': old_auto_backup.get('last_status'),
'last_created_count': old_auto_backup.get('last_created_count', 0),
'last_error': old_auto_backup.get('last_error')
}
save_data(data)
logger.info("Settings saved (including captcha and telegram)")
logger.info("Settings saved (including captcha, telegram and auto backup)")
# Handle bot start/stop based on new telegram settings
tg_cfg = payload.telegram
+6 -6
View File
@@ -1278,10 +1278,10 @@ AllowedIPs = {allowed_ips}
# Standard fields (dual-stack when the client has an IPv6 address)
address_line = f"{client_ip}/32" + (f", {client_ipv6}/128" if client_ipv6 else "")
dns_line = f"{dns1}, {dns2}" + (", 2606:4700:4700::1111" if client_ipv6 else "")
dns_line = dns + (", 2606:4700:4700::1111" if client_ipv6 else "")
config_lines = [
f"Address = {client_ip}/32",
f"DNS = {dns}",
f"Address = {address_line}",
f"DNS = {dns_line}",
f"PrivateKey = {client_priv_key}",
f"MTU = {mtu}"
]
@@ -1370,10 +1370,10 @@ PersistentKeepalive = 25
# Standard fields (dual-stack when the client has an IPv6 address)
address_line = f"{client_ip}/32" + (f", {client_ipv6}/128" if client_ipv6 else "")
dns_line = f"{dns1}, {dns2}" + (", 2606:4700:4700::1111" if client_ipv6 else "")
dns_line = dns + (", 2606:4700:4700::1111" if client_ipv6 else "")
config_lines = [
f"Address = {client_ip}/32",
f"DNS = {dns}",
f"Address = {address_line}",
f"DNS = {dns_line}",
f"PrivateKey = {client_priv_key}",
f"MTU = {mtu}"
]
+1 -1
View File
@@ -48,7 +48,7 @@ class BackupManager:
def inst_path(path, suffix_fmt='-{idx}'):
return path if idx <= 1 else f"{path}{suffix_fmt.format(idx=idx)}"
if base in ('awg', 'awg2', 'awg_legacy'):
if base in ('awg', 'awg2', 'awg3', 'awg_legacy'):
paths['host'] = ['/opt/amnezia/awg', f'/opt/amnezia/{container_name}']
paths['container'] = ['/opt/amnezia/awg', '/opt/amnezia/start.sh']
elif base == 'wireguard':
+41 -1
View File
@@ -360,6 +360,40 @@
</button>
</div>
</div>
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
<h4 style="margin: 0 0 var(--space-sm) 0; font-size: 0.95rem;">{{ _('auto_backup_title') }}</h4>
<form id="autoBackupForm">
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
<input type="checkbox" name="enabled" id="autoBackupEnabled" {% if settings.auto_backup.enabled %}checked{% endif %}>
{{ _('enable_auto_backup') }}
</label>
</div>
<div class="form-group">
<label class="form-label">{{ _('auto_backup_interval_label') }}</label>
<select class="form-select" name="interval_hours" id="autoBackupInterval">
{% set auto_backup_interval = settings.get('auto_backup', {}).get('interval_hours', 24) | int %}
{% for hour in range(1, 25) %}
<option value="{{ hour }}" {% if auto_backup_interval == hour %}selected{% endif %}>{{ hour }} {{ _('hours_suffix') }}</option>
{% endfor %}
</select>
</div>
</form>
<p class="form-hint">{{ _('auto_backup_hint') }}</p>
{% if settings.auto_backup.last_run_at %}
<div class="form-hint" style="margin-top: var(--space-xs);">
{{ _('auto_backup_last_run') }}: {{ settings.auto_backup.last_run_at }} ·
{{ _('auto_backup_last_status') }}: {{ settings.auto_backup.last_status or '-' }} ·
{{ _('auto_backup_created_count') }}: {{ settings.auto_backup.last_created_count or 0 }}
</div>
{% endif %}
{% if settings.auto_backup.last_error %}
<div class="form-hint" style="margin-top: var(--space-xs); color: var(--danger);">
{{ settings.auto_backup.last_error }}
</div>
{% endif %}
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-sm);">
{{ _('restore_confirm') }}
@@ -805,11 +839,17 @@
panel_port: parseInt(document.getElementById('panel_port').value || '5000')
};
const autoBackupForm = document.getElementById('autoBackupForm');
const auto_backup = {
enabled: autoBackupForm.enabled.checked,
interval_hours: parseInt(autoBackupForm.interval_hours.value || '24')
};
try {
const res = await fetch('/api/settings/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl })
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, auto_backup })
});
if (!res.ok) throw new Error(_('error'));
showToast(_('settings_saved'), 'success');
+36 -6
View File
@@ -213,12 +213,18 @@
</div>
<div class="form-group">
<label class="form-label">{{ _('traffic_reset_strategy_label') }}</label>
<select class="form-select" id="editUserTrafficResetStrategy">
<option value="never">{{ _('traffic_reset_never') }}</option>
<option value="daily">{{ _('traffic_reset_daily') }}</option>
<option value="weekly">{{ _('traffic_reset_weekly') }}</option>
<option value="monthly">{{ _('traffic_reset_monthly') }}</option>
</select>
<div style="display: grid; grid-template-columns: 1fr auto; gap: var(--space-sm); align-items: center;">
<select class="form-select" id="editUserTrafficResetStrategy">
<option value="never">{{ _('traffic_reset_never') }}</option>
<option value="daily">{{ _('traffic_reset_daily') }}</option>
<option value="weekly">{{ _('traffic_reset_weekly') }}</option>
<option value="monthly">{{ _('traffic_reset_monthly') }}</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="resetTrafficBtn" onclick="resetUserTraffic()">
<span id="resetTrafficBtnText">{{ _('reset_traffic') }}</span>
<div class="spinner hidden" id="resetTrafficSpinner"></div>
</button>
</div>
</div>
</div>
<div class="form-group">
@@ -985,6 +991,30 @@
openModal('editUserModal');
}
async function resetUserTraffic() {
const uid = document.getElementById('editUserId').value;
if (!uid || !confirm(_('reset_traffic_confirm'))) return;
const btn = document.getElementById('resetTrafficBtn');
const text = document.getElementById('resetTrafficBtnText');
const spinner = document.getElementById('resetTrafficSpinner');
btn.disabled = true;
text.textContent = _('resetting');
spinner.classList.remove('hidden');
try {
await apiCall(`/api/users/${uid}/traffic/reset`, 'POST');
showToast(_('traffic_reset_success'), 'success');
refreshUsersList();
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
} finally {
btn.disabled = false;
text.textContent = _('reset_traffic');
spinner.classList.add('hidden');
}
}
async function saveEditUser() {
const uid = document.getElementById('editUserId').value;
const btn = document.getElementById('saveEditBtn');
+13 -1
View File
@@ -262,6 +262,14 @@
"restore_backup": "Restore from file",
"restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.",
"restore_success": "Restore successful! Restarting...",
"auto_backup_title": "Auto backup",
"enable_auto_backup": "Enable server auto backups",
"auto_backup_interval_label": "Creation interval",
"auto_backup_hint": "The panel will create backups of installed protocols on every server at the selected interval.",
"auto_backup_last_run": "Last run",
"auto_backup_last_status": "Status",
"auto_backup_created_count": "Backups created",
"hours_suffix": "h",
"invalid_backup_file": "Invalid backup file or structure",
"config_unavailable": "Configuration unavailable",
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user's device and cannot be recovered by the server.",
@@ -424,5 +432,9 @@
"rename": "Rename",
"connection_renamed": "Connection renamed",
"enter_connection_name": "Enter a new connection name",
"config_saved": "Config saved"
"config_saved": "Config saved",
"reset_traffic": "Reset traffic",
"resetting": "Resetting...",
"reset_traffic_confirm": "Reset this user's current traffic? Total statistics will be kept.",
"traffic_reset_success": "Traffic reset"
}
+14 -2
View File
@@ -259,7 +259,15 @@
"download_backup": "دانلود data.json",
"restore_backup": "بازیابی از فایل",
"restore_confirm": "آیا مطمئن هستید؟ داده‌های فعلی بازنویسی می‌شوند و برنامه دوباره راه‌اندازی خواهد شد.",
"restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...",
"restore_success": "بازیابی موفق بود! در حال راهاندازی مجدد...",
"auto_backup_title": "Auto backup",
"enable_auto_backup": "فعالسازی پشتیبانگیری خودکار سرورها",
"auto_backup_interval_label": "فاصله زمانی ایجاد",
"auto_backup_hint": "پنل از پروتکلهای نصبشده روی هر سرور در بازه انتخابشده پشتیبان میگیرد.",
"auto_backup_last_run": "آخرین اجرا",
"auto_backup_last_status": "وضعیت",
"auto_backup_created_count": "پشتیبانهای ایجادشده",
"hours_suffix": "ساعت",
"invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است",
"config_unavailable": "پیکربندی در دسترس نیست",
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.",
@@ -391,5 +399,9 @@
"rename": "تغییر نام",
"connection_renamed": "اتصال تغییر نام یافت",
"enter_connection_name": "نام جدید اتصال را وارد کنید",
"config_saved": "پیکربندی ذخیره شد"
"config_saved": "پیکربندی ذخیره شد",
"reset_traffic": "بازنشانی ترافیک",
"resetting": "در حال بازنشانی...",
"reset_traffic_confirm": "ترافیک فعلی این کاربر بازنشانی شود؟ آمار کل حفظ میشود.",
"traffic_reset_success": "ترافیک بازنشانی شد"
}
+14 -2
View File
@@ -260,7 +260,15 @@
"restore_backup": "Restaurer depuis un fichier",
"restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l'application redémarrera.",
"restore_success": "Restauration réussie ! Redémarrage...",
"invalid_backup_file": "Fichier de sauvegarde ou structure invalide",
"auto_backup_title": "Auto backup",
"enable_auto_backup": "Activer les sauvegardes automatiques des serveurs",
"auto_backup_interval_label": "Intervalle de création",
"auto_backup_hint": "Le panneau créera des sauvegardes des protocoles installés sur chaque serveur selon l'intervalle choisi.",
"auto_backup_last_run": "Dernier lancement",
"auto_backup_last_status": "Statut",
"auto_backup_created_count": "Sauvegardes créées",
"hours_suffix": "h",
"invalid_backup_file": "Fichier ou structure de sauvegarde invalide",
"config_unavailable": "Configuration indisponible",
"config_unavailable_desc": "Ce client a été créé via l'application native Amnezia.\\nLa clé privée est stockée uniquement sur l'appareil de l'utilisateur et ne peut pas être récupérée par le serveur.",
"client_public_key": "Clé publique du client :",
@@ -391,5 +399,9 @@
"rename": "Renommer",
"connection_renamed": "Connexion renommée",
"enter_connection_name": "Saisir un nouveau nom de connexion",
"config_saved": "Configuration enregistrée"
"config_saved": "Configuration enregistrée",
"reset_traffic": "Réinitialiser le trafic",
"resetting": "Réinitialisation...",
"reset_traffic_confirm": "Réinitialiser le trafic actuel de cet utilisateur ? Les statistiques totales seront conservées.",
"traffic_reset_success": "Trafic réinitialisé"
}
+13 -1
View File
@@ -262,6 +262,14 @@
"restore_backup": "Восстановить из файла",
"restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.",
"restore_success": "Восстановление успешно! Перезагрузка...",
"auto_backup_title": "Auto backup",
"enable_auto_backup": "Включить автобекапы серверов",
"auto_backup_interval_label": "Интервал создания",
"auto_backup_hint": "Панель будет создавать бекапы установленных протоколов на каждом сервере с выбранным интервалом.",
"auto_backup_last_run": "Последний запуск",
"auto_backup_last_status": "Статус",
"auto_backup_created_count": "Создано бекапов",
"hours_suffix": "ч",
"invalid_backup_file": "Неверный файл резервной копии или структура",
"config_unavailable": "Конфигурация недоступна",
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
@@ -424,5 +432,9 @@
"rename": "Переименовать",
"connection_renamed": "Подключение переименовано",
"enter_connection_name": "Введите новое имя подключения",
"config_saved": "Конфиг сохранён"
"config_saved": "Конфиг сохранён",
"reset_traffic": "Сбросить трафик",
"resetting": "Сброс...",
"reset_traffic_confirm": "Сбросить текущий трафик пользователя? Общая статистика сохранится.",
"traffic_reset_success": "Трафик сброшен"
}
+14 -2
View File
@@ -260,7 +260,15 @@
"restore_backup": "从文件恢复",
"restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。",
"restore_success": "恢复成功!正在重启...",
"invalid_backup_file": "备份文件无效或结构错误",
"auto_backup_title": "Auto backup",
"enable_auto_backup": "启用服务器自动备份",
"auto_backup_interval_label": "创建间隔",
"auto_backup_hint": "面板会按所选间隔为每台服务器上已安装的协议创建备份。",
"auto_backup_last_run": "上次运行",
"auto_backup_last_status": "状态",
"auto_backup_created_count": "已创建备份",
"hours_suffix": "小时",
"invalid_backup_file": "备份文件或结构无效",
"config_unavailable": "配置文件不可用",
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
"client_public_key": "客户端公钥:",
@@ -391,5 +399,9 @@
"rename": "重命名",
"connection_renamed": "连接已重命名",
"enter_connection_name": "请输入新的连接名称",
"config_saved": "配置已保存"
"config_saved": "配置已保存",
"reset_traffic": "重置流量",
"resetting": "正在重置...",
"reset_traffic_confirm": "重置此用户的当前流量?总统计将保留。",
"traffic_reset_success": "流量已重置"
}