diff --git a/changelog b/changelog index 003329a..24b410e 100644 --- a/changelog +++ b/changelog @@ -1,22 +1,19 @@ -# [1.15.0] - 2025-08-17 +# [1.16.0] - 2025-08-19 #### ✨ New Features -* 🚫 **Blocked User Check** +* 📊 **Dashboard Redesign** - * Subscription endpoint now validates blocked users and prevents access -* 🌐 **External Configs in Subscriptions** + * Modernized UI with detailed server stats +* 🖥️ **Server API Enhancements** - * NormalSub links now include external proxy configs -* ⚙️ **Settings Page** + * Added uptime and traffic-since-reboot metrics +* ⚡ **System Monitor Optimization** - * Added management UI for extra subscription configs -* 🖥️ **Webpanel API** + * Improved performance with async I/O + * Accurate traffic tracking since reboot - * New API endpoints for managing extra configs -* 🛠️ **CLI & Scripts** +#### 🐛 Fixes - * CLI support for extra subscription configs - * New `extra_config.py` script to manage additional proxy configs: - - * `vmess`, `vless`, `ss`, `trojan` +* 🔧 Correctly count **actual device connections** instead of unique users +* 🔥 Fixed subscription blocked page to display the right user data diff --git a/core/scripts/hysteria2/server_info.py b/core/scripts/hysteria2/server_info.py index 79d81e7..f24a465 100644 --- a/core/scripts/hysteria2/server_info.py +++ b/core/scripts/hysteria2/server_info.py @@ -2,12 +2,17 @@ import sys import json -from hysteria2_api import Hysteria2Client +import asyncio +import aiofiles import time +from concurrent.futures import ThreadPoolExecutor +from functools import lru_cache +from hysteria2_api import Hysteria2Client from init_paths import * from paths import * +@lru_cache(maxsize=1) def get_secret() -> str: if not CONFIG_FILE.exists(): print("Error: config.json file not found!", file=sys.stderr) @@ -25,24 +30,74 @@ def get_secret() -> str: def convert_bytes(bytes_val: int) -> str: - units = [("TB", 1 << 40), ("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)] - for unit, factor in units: - if bytes_val >= factor: - return f"{bytes_val / factor:.2f} {unit}" + if bytes_val >= (1 << 40): + return f"{bytes_val / (1 << 40):.2f} TB" + elif bytes_val >= (1 << 30): + return f"{bytes_val / (1 << 30):.2f} GB" + elif bytes_val >= (1 << 20): + return f"{bytes_val / (1 << 20):.2f} MB" + elif bytes_val >= (1 << 10): + return f"{bytes_val / (1 << 10):.2f} KB" return f"{bytes_val} B" -def get_cpu_usage(interval: float = 0.1) -> float: - def read_cpu_times(): - with open("/proc/stat") as f: - line = f.readline() - fields = list(map(int, line.strip().split()[1:])) - idle, total = fields[3], sum(fields) - return idle, total +def convert_speed(bytes_per_second: int) -> str: + if bytes_per_second >= (1 << 40): + return f"{bytes_per_second / (1 << 40):.2f} TB/s" + elif bytes_per_second >= (1 << 30): + return f"{bytes_per_second / (1 << 30):.2f} GB/s" + elif bytes_per_second >= (1 << 20): + return f"{bytes_per_second / (1 << 20):.2f} MB/s" + elif bytes_per_second >= (1 << 10): + return f"{bytes_per_second / (1 << 10):.2f} KB/s" + return f"{int(bytes_per_second)} B/s" - idle1, total1 = read_cpu_times() - time.sleep(interval) - idle2, total2 = read_cpu_times() + +async def read_file_async(filepath: str) -> str: + try: + async with aiofiles.open(filepath, 'r') as f: + return await f.read() + except FileNotFoundError: + return "" + + +def format_uptime(seconds: float) -> str: + seconds = int(seconds) + days, remainder = divmod(seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + return f"{days}d {hours}h {minutes}m" + + +async def get_uptime_and_boottime() -> tuple[str, str]: + try: + content = await read_file_async("/proc/uptime") + uptime_seconds = float(content.split()[0]) + boot_time_epoch = time.time() - uptime_seconds + boot_time_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(boot_time_epoch)) + uptime_str = format_uptime(uptime_seconds) + return uptime_str, boot_time_str + except (FileNotFoundError, IndexError, ValueError): + return "N/A", "N/A" + + +def parse_cpu_stats(content: str) -> tuple[int, int]: + if not content: + return 0, 0 + line = content.split('\n')[0] + fields = list(map(int, line.strip().split()[1:])) + idle, total = fields[3], sum(fields) + return idle, total + + +async def get_cpu_usage(interval: float = 0.1) -> float: + content1 = await read_file_async("/proc/stat") + idle1, total1 = parse_cpu_stats(content1) + + await asyncio.sleep(interval) + + content2 = await read_file_async("/proc/stat") + idle2, total2 = parse_cpu_stats(content2) idle_delta = idle2 - idle1 total_delta = total2 - total1 @@ -50,23 +105,18 @@ def get_cpu_usage(interval: float = 0.1) -> float: return round(cpu_usage, 1) - -def get_memory_usage() -> tuple[int, int]: +def parse_meminfo(content: str) -> tuple[int, int]: + if not content: + return 0, 0 + mem_info = {} - try: - with open("/proc/meminfo", "r") as f: - for line in f: - parts = line.split() - if len(parts) >= 2: - key = parts[0].rstrip(':') - if parts[1].isdigit(): - mem_info[key] = int(parts[1]) - except FileNotFoundError: - print("Error: /proc/meminfo not found.", file=sys.stderr) - return 0, 0 - except Exception as e: - print(f"Error reading /proc/meminfo: {e}", file=sys.stderr) - return 0, 0 + for line in content.split('\n'): + if ':' in line: + parts = line.split() + if len(parts) >= 2: + key = parts[0].rstrip(':') + if parts[1].isdigit(): + mem_info[key] = int(parts[1]) mem_total_kb = mem_info.get("MemTotal", 0) mem_free_kb = mem_info.get("MemFree", 0) @@ -75,20 +125,70 @@ def get_memory_usage() -> tuple[int, int]: sreclaimable_kb = mem_info.get("SReclaimable", 0) used_kb = mem_total_kb - mem_free_kb - buffers_kb - cached_kb - sreclaimable_kb + + used_kb = max(0, used_kb) + return mem_total_kb // 1024, used_kb // 1024 + + +async def get_memory_usage() -> tuple[int, int]: + content = await read_file_async("/proc/meminfo") + return parse_meminfo(content) + + +def parse_network_stats(content: str) -> tuple[int, int]: + if not content: + return 0, 0 - if used_kb < 0: - used_kb = mem_total_kb - mem_info.get("MemAvailable", mem_total_kb) - used_kb = max(0, used_kb) - - - total_mb = mem_total_kb // 1024 - used_mb = used_kb // 1024 + rx_bytes, tx_bytes = 0, 0 + lines = content.split('\n') - return total_mb, used_mb + for line in lines[2:]: + if not line.strip(): + continue + parts = line.split() + if len(parts) < 10: + continue + iface = parts[0].strip().replace(':', '') + if iface == 'lo': + continue + try: + rx_bytes += int(parts[1]) + tx_bytes += int(parts[9]) + except (IndexError, ValueError): + continue + + return rx_bytes, tx_bytes +async def get_network_stats() -> tuple[int, int]: + content = await read_file_async('/proc/net/dev') + return parse_network_stats(content) -def get_online_user_count(secret: str) -> int: + +async def get_network_speed(interval: float = 0.5) -> tuple[int, int]: + rx1, tx1 = await get_network_stats() + await asyncio.sleep(interval) + rx2, tx2 = await get_network_stats() + + rx_speed = (rx2 - rx1) / interval + tx_speed = (tx2 - tx1) / interval + return int(rx_speed), int(tx_speed) + + +def parse_connection_counts(tcp_content: str, udp_content: str) -> tuple[int, int]: + tcp_count = len(tcp_content.split('\n')) - 2 if tcp_content else 0 + udp_count = len(udp_content.split('\n')) - 2 if udp_content else 0 + return max(0, tcp_count), max(0, udp_count) + + +async def get_connection_counts() -> tuple[int, int]: + tcp_task = read_file_async('/proc/net/tcp') + udp_task = read_file_async('/proc/net/udp') + tcp_content, udp_content = await asyncio.gather(tcp_task, udp_task) + return parse_connection_counts(tcp_content, udp_content) + + +def get_online_user_count_sync(secret: str) -> int: try: client = Hysteria2Client( base_url=API_BASE_URL, @@ -101,47 +201,83 @@ def get_online_user_count(secret: str) -> int: return 0 -def get_total_traffic() -> tuple[int, int]: - if not USERS_FILE.exists(): +async def get_online_user_count(secret: str) -> int: + loop = asyncio.get_event_loop() + with ThreadPoolExecutor() as executor: + return await loop.run_in_executor(executor, get_online_user_count_sync, secret) + + +def parse_total_traffic(content: str) -> tuple[int, int]: + if not content: + return 0, 0 + + try: + users = json.loads(content) + total_upload = sum(int(user_data.get("upload_bytes", 0) or 0) for user_data in users.values()) + total_download = sum(int(user_data.get("download_bytes", 0) or 0) for user_data in users.values()) + return total_upload, total_download + except (json.JSONDecodeError, ValueError, AttributeError): return 0, 0 + +async def get_user_traffic() -> tuple[int, int]: + if not USERS_FILE.exists(): + return 0, 0 + try: - with USERS_FILE.open() as f: - users = json.load(f) - - total_upload = 0 - total_download = 0 - - for user_data in users.values(): - total_upload += int(user_data.get("upload_bytes", 0) or 0) - total_download += int(user_data.get("download_bytes", 0) or 0) - - return total_upload, total_download + async with aiofiles.open(USERS_FILE, 'r') as f: + content = await f.read() + return parse_total_traffic(content) except Exception as e: print(f"Error parsing traffic data: {e}", file=sys.stderr) return 0, 0 - -def main(): +async def main(): secret = get_secret() + + tasks = [ + get_uptime_and_boottime(), + get_memory_usage(), + get_connection_counts(), + get_online_user_count(secret), + get_user_traffic(), + get_cpu_usage(0.1), + get_network_speed(0.3), + get_network_stats() + ] + + results = await asyncio.gather(*tasks) + + uptime_str, boot_time_str = results[0] + mem_total, mem_used = results[1] + tcp_connections, udp_connections = results[2] + online_users = results[3] + user_upload, user_download = results[4] + cpu_usage = results[5] + download_speed, upload_speed = results[6] + reboot_rx, reboot_tx = results[7] - cpu_usage = get_cpu_usage() - mem_total, mem_used = get_memory_usage() - online_users = get_online_user_count(secret) - - print(f"📈 CPU Usage: {cpu_usage}") - print(f"📋 Total RAM: {mem_total}MB") - print(f"💻 Used RAM: {mem_used}MB") + print(f"🕒 Uptime: {uptime_str} (since {boot_time_str})") + print(f"📈 CPU Usage: {cpu_usage}%") + print(f"💻 Used RAM: {mem_used}MB / {mem_total}MB") print(f"👥 Online Users: {online_users}") print() - - total_upload, total_download = get_total_traffic() - - print(f"🔼 Uploaded Traffic: {convert_bytes(total_upload)}") - print(f"🔽 Downloaded Traffic: {convert_bytes(total_download)}") - print(f"📊 Total Traffic: {convert_bytes(total_upload + total_download)}") + print(f"🔼 Upload Speed: {convert_speed(upload_speed)}") + print(f"🔽 Download Speed: {convert_speed(download_speed)}") + print(f"📡 TCP Connections: {tcp_connections}") + print(f"📡 UDP Connections: {udp_connections}") + print() + print("📊 Traffic Since Last Reboot:") + print(f" 🔼 Total Uploaded: {convert_bytes(reboot_tx)}") + print(f" 🔽 Total Downloaded: {convert_bytes(reboot_rx)}") + print(f" 📈 Combined Traffic: {convert_bytes(reboot_tx + reboot_rx)}") + print() + print("📊 User Traffic (All Time):") + print(f" 🔼 Uploaded Traffic: {convert_bytes(user_upload)}") + print(f" 🔽 Downloaded Traffic: {convert_bytes(user_download)}") + print(f" 📈 Total Traffic: {convert_bytes(user_upload + user_download)}") if __name__ == "__main__": - main() + asyncio.run(main()) \ No newline at end of file diff --git a/core/scripts/webpanel/routers/api/v1/schema/server.py b/core/scripts/webpanel/routers/api/v1/schema/server.py index bb3ba4b..f4c4048 100644 --- a/core/scripts/webpanel/routers/api/v1/schema/server.py +++ b/core/scripts/webpanel/routers/api/v1/schema/server.py @@ -5,22 +5,35 @@ from pydantic import BaseModel # It's better to chnage the underlying script to return bytes instead of changing it here # Because of this problem we use str type instead of int as type class ServerStatusResponse(BaseModel): - # disk_usage: int + # System Info + uptime: str + boot_time: str cpu_usage: str - total_ram: str ram_usage: str + total_ram: str online_users: int - uploaded_traffic: str - downloaded_traffic: str - total_traffic: str + # Real-time Network + upload_speed: str + download_speed: str + tcp_connections: int + udp_connections: int + + # Traffic Since Reboot + reboot_uploaded_traffic: str + reboot_downloaded_traffic: str + reboot_total_traffic: str + + # User Traffic (All Time) + user_uploaded_traffic: str + user_downloaded_traffic: str + user_total_traffic: str class ServerServicesStatusResponse(BaseModel): hysteria_server: bool hysteria_webpanel: bool hysteria_iplimit: bool - # hysteria_singbox: bool hysteria_normal_sub: bool hysteria_telegram_bot: bool hysteria_warp: bool diff --git a/core/scripts/webpanel/routers/api/v1/server.py b/core/scripts/webpanel/routers/api/v1/server.py index 5de4753..c975523 100644 --- a/core/scripts/webpanel/routers/api/v1/server.py +++ b/core/scripts/webpanel/routers/api/v1/server.py @@ -11,7 +11,7 @@ async def server_status_api(): Retrieve the server status. This endpoint provides information about the current server status, - including CPU usage, RAM usage, online users, and traffic statistics. + including uptime, CPU usage, RAM usage, online users, and traffic statistics. Returns: ServerStatusResponse: A response model containing server status details. @@ -30,7 +30,6 @@ async def server_status_api(): def __parse_server_status(server_info: str) -> ServerStatusResponse: - # Initial data with default values """ Parse the server information provided by cli_api.server_info() and return a ServerStatusResponse instance. @@ -45,57 +44,87 @@ def __parse_server_status(server_info: str) -> ServerStatusResponse: ValueError: If the server information is invalid or incomplete. """ data = { + 'uptime': 'N/A', + 'boot_time': 'N/A', 'cpu_usage': '0%', 'total_ram': '0MB', 'ram_usage': '0MB', 'online_users': 0, - 'uploaded_traffic': '0KB', - 'downloaded_traffic': '0KB', - 'total_traffic': '0KB' + 'upload_speed': '0 B/s', + 'download_speed': '0 B/s', + 'tcp_connections': 0, + 'udp_connections': 0, + 'reboot_uploaded_traffic': '0 B', + 'reboot_downloaded_traffic': '0 B', + 'reboot_total_traffic': '0 B', + 'user_uploaded_traffic': '0 B', + 'user_downloaded_traffic': '0 B', + 'user_total_traffic': '0 B' } - # Example output(server_info) from cli_api.server_info(): - # 📈 CPU Usage: 9.4% - # 📋 Total RAM: 3815MB - # 💻 Used RAM: 2007MB - # 👥 Online Users: 0 - # - # 🔼 Uploaded Traffic: 0 KB - # 🔽 Downloaded Traffic: 0 KB - # 📊 Total Traffic: 0 KB + current_section = 'general' for line in server_info.splitlines(): + line = line.strip() + if not line: + continue + + if 'Traffic Since Last Reboot' in line: + current_section = 'reboot' + continue + elif 'User Traffic (All Time)' in line: + current_section = 'user' + continue + key, _, value = line.partition(":") key = key.strip().lower() value = value.strip() if not key or not value: - continue # Skip empty or malformed lines - + continue + try: - if 'cpu usage' in key: + if 'uptime' in key: + uptime_part, _, boottime_part = value.partition('(') + data['uptime'] = uptime_part.strip() + data['boot_time'] = boottime_part.replace('since ', '').replace(')', '').strip() + elif 'cpu usage' in key: data['cpu_usage'] = value - elif 'total ram' in key: - data['total_ram'] = value elif 'used ram' in key: - data['ram_usage'] = value + parts = value.split('/') + if len(parts) == 2: + data['ram_usage'] = parts[0].strip() + data['total_ram'] = parts[1].strip() elif 'online users' in key: data['online_users'] = int(value) - elif 'uploaded traffic' in key: - value = value.replace(' ', '') - data['uploaded_traffic'] = value - elif "downloaded traffic" in key: - value = value.replace(' ', '') - data['downloaded_traffic'] = value - elif 'total traffic' in key: - value = value.replace(' ', '') - data["total_traffic"] = value - except ValueError as e: + elif 'upload speed' in key: + data['upload_speed'] = value + elif 'download speed' in key: + data['download_speed'] = value + elif 'tcp connections' in key: + data['tcp_connections'] = int(value) + elif 'udp connections' in key: + data['udp_connections'] = int(value) + elif 'total uploaded' in key or 'uploaded traffic' in key: + if current_section == 'reboot': + data['reboot_uploaded_traffic'] = value + elif current_section == 'user': + data['user_uploaded_traffic'] = value + elif 'total downloaded' in key or 'downloaded traffic' in key: + if current_section == 'reboot': + data['reboot_downloaded_traffic'] = value + elif current_section == 'user': + data['user_downloaded_traffic'] = value + elif 'combined traffic' in key or 'total traffic' in key: + if current_section == 'reboot': + data['reboot_total_traffic'] = value + elif current_section == 'user': + data['user_total_traffic'] = value + except (ValueError, IndexError) as e: raise ValueError(f'Error parsing line \'{line}\': {e}') - # Validate required fields try: - return ServerStatusResponse(**data) # type: ignore + return ServerStatusResponse(**data) except Exception as e: raise ValueError(f'Invalid or incomplete server info: {e}') @@ -141,8 +170,6 @@ def __parse_services_status(services_status: dict[str, bool]) -> ServerServicesS parsed_services_status['hysteria_telegram_bot'] = status elif 'hysteria-normal-sub' in service: parsed_services_status['hysteria_normal_sub'] = status - # elif 'hysteria-singbox' in service: - # parsed_services_status['hysteria_singbox'] = status elif 'wg-quick' in service: parsed_services_status['hysteria_warp'] = status return ServerServicesStatusResponse(**parsed_services_status) diff --git a/core/scripts/webpanel/templates/index.html b/core/scripts/webpanel/templates/index.html index 7932834..1f966ab 100644 --- a/core/scripts/webpanel/templates/index.html +++ b/core/scripts/webpanel/templates/index.html @@ -15,63 +15,119 @@
+
-
- -
-
-

--%

-

CPU Usage

-
-
- +
+
+ +
+ CPU Usage + --
-
- -
-
-

--

-

RAM Usage

-
-
- +
+
+ +
+ RAM Usage + -- / --
-
- -
-
-

--

-

Total Traffic

-
-
- +
+
+ +
+ Online Users + --
-
- -
-
-

--

-

Online Users

+
+
+ +
+ Uptime + --
-
- +
+
+
+
+
+
+ +
+ Download / Upload Speed + -- / -- +
+
+
+
+
+ +
+ TCP / UDP Connections + -- / --
+ +
+
+
+
+

Traffic Since Last Reboot

+
+
+
+

Uploaded

+

--

+
+
+

Downloaded

+

--

+
+
+

Combined

+

--

+
+
+
+
+
+
+
+

User Traffic (All Time)

+
+
+
+

Uploaded

+

--

+
+
+

Downloaded

+

--

+
+
+

Combined

+

--

+
+
+
+
+
+ +

--

-

Hysteria2

+

Hysteria2 Core

@@ -93,10 +149,10 @@

--

-

IP Limit

+

IP Limit Service

- +
@@ -104,7 +160,7 @@

--

-

Normal Subscription

+

Subscription Service

@@ -122,21 +178,34 @@ fetch('{{ url_for("server_status_api") }}') .then(response => response.json()) .then(data => { + // Core Stats document.getElementById('cpu-usage').textContent = data.cpu_usage; - document.getElementById('ram-usage').textContent = data.ram_usage; + document.getElementById('ram-usage').textContent = `${data.ram_usage} / ${data.total_ram}`; document.getElementById('online-users').textContent = data.online_users; - document.getElementById('total-traffic').textContent = data.total_traffic; + document.getElementById('uptime').textContent = data.uptime; + + // Network Stats + document.getElementById('network-speed').innerHTML = `🔽 ${data.download_speed} / 🔼 ${data.upload_speed}`; + document.getElementById('network-connections').textContent = `${data.tcp_connections} / ${data.udp_connections}`; + + // Traffic Since Reboot + document.getElementById('reboot-uploaded-traffic').textContent = data.reboot_uploaded_traffic; + document.getElementById('reboot-downloaded-traffic').textContent = data.reboot_downloaded_traffic; + document.getElementById('reboot-total-traffic').textContent = data.reboot_total_traffic; + + // User Traffic (All Time) + document.getElementById('user-uploaded-traffic').textContent = data.user_uploaded_traffic; + document.getElementById('user-downloaded-traffic').textContent = data.user_downloaded_traffic; + document.getElementById('user-total-traffic').textContent = data.user_total_traffic; }) .catch(error => console.error('Error fetching server info:', error)); } function updateServiceStatuses() { - // Add services api in fetch fetch('{{ url_for("server_services_status_api") }}') .then(response => response.json()) .then(data => { - updateServiceBox('hysteria2', data.hysteria_server); updateServiceBox('telegrambot', data.hysteria_telegram_bot); updateServiceBox('iplimit', data.hysteria_iplimit); @@ -152,20 +221,19 @@ if (status === true) { statusElement.textContent = 'Active'; statusBox.classList.remove('bg-danger'); - statusBox.classList.add('bg-success'); // Add green + statusBox.classList.add('bg-success'); } else { statusElement.textContent = 'Inactive'; statusBox.classList.remove('bg-success'); - statusBox.classList.add('bg-danger'); // Add red + statusBox.classList.add('bg-danger'); } } - - - updateServerInfo(); - updateServiceStatuses(); - - setInterval(updateServerInfo, 5000); + document.addEventListener('DOMContentLoaded', function () { + updateServerInfo(); + updateServiceStatuses(); + setInterval(updateServerInfo, 2000); + setInterval(updateServiceStatuses, 10000); + }); - {% endblock %} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3787769..861e23b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ urllib3==2.5.0 yarl==1.20.1 hysteria2-api==0.1.3 schedule==1.2.2 +aiofiles==24.1.0 # webpanel annotated-types==0.7.0