From dec0e90d11780bcf93714ec3a11171e8399d20b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=92?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=A1=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Mon, 24 Aug 2026 19:14:04 +0300 Subject: [PATCH] v1.6.0 Fixed merge requests Reset user traffic consumption Implemented automatic backups --- app.py | 246 +++++++++++++++++++++++++++++++------ managers/awg_manager.py | 12 +- managers/backup_manager.py | 2 +- templates/settings.html | 42 ++++++- templates/users.html | 42 ++++++- translations/en.json | 14 ++- translations/fa.json | 16 ++- translations/fr.json | 16 ++- translations/ru.json | 14 ++- translations/zh.json | 16 ++- 10 files changed, 362 insertions(+), 58 deletions(-) diff --git a/app.py b/app.py index e9b4a5e..37e3390 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/managers/awg_manager.py b/managers/awg_manager.py index 9e9796a..3eda276 100644 --- a/managers/awg_manager.py +++ b/managers/awg_manager.py @@ -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}" ] diff --git a/managers/backup_manager.py b/managers/backup_manager.py index 35305ed..4a9027f 100644 --- a/managers/backup_manager.py +++ b/managers/backup_manager.py @@ -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': diff --git a/templates/settings.html b/templates/settings.html index 29d4b34..fa8621f 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -360,6 +360,40 @@ + +
+

{{ _('auto_backup_title') }}

+
+
+ +
+
+ + +
+
+

{{ _('auto_backup_hint') }}

+ {% if settings.auto_backup.last_run_at %} +
+ {{ _('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 }} +
+ {% endif %} + {% if settings.auto_backup.last_error %} +
+ {{ settings.auto_backup.last_error }} +
+ {% endif %} +

{{ _('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'); diff --git a/templates/users.html b/templates/users.html index 034c8ad..8013ac6 100644 --- a/templates/users.html +++ b/templates/users.html @@ -213,12 +213,18 @@

- +
+ + +
@@ -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'); diff --git a/translations/en.json b/translations/en.json index f0b59ea..9998b15 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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" } \ No newline at end of file diff --git a/translations/fa.json b/translations/fa.json index 5e8917e..ab10011 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -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": "ترافیک بازنشانی شد" } \ No newline at end of file diff --git a/translations/fr.json b/translations/fr.json index c1dd0f3..3b4c273 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -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é" } \ No newline at end of file diff --git a/translations/ru.json b/translations/ru.json index ef25bdf..4e6818c 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -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": "Трафик сброшен" } \ No newline at end of file diff --git a/translations/zh.json b/translations/zh.json index 00db5b2..9036cb6 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -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": "流量已重置" } \ No newline at end of file