diff --git a/meshchat.py b/meshchat.py index cceb8c5..b31e4dc 100644 --- a/meshchat.py +++ b/meshchat.py @@ -22,6 +22,7 @@ import webbrowser from peewee import SqliteDatabase from serial.tools import list_ports +import psutil import database from src.backend.announce_handler import AnnounceHandler @@ -147,6 +148,12 @@ class ReticulumMeshChat: # remember websocket clients self.websocket_clients: List[web.WebSocketResponse] = [] + # track announce timestamps for rate calculation + self.announce_timestamps = [] + + # track download speeds for nomadnetwork files (list of tuples: (file_size_bytes, duration_seconds)) + self.download_speeds = [] + # register audio call identity self.audio_call_manager = AudioCallManager(identity=self.identity) self.audio_call_manager.register_incoming_call_callback(self.on_incoming_audio_call) @@ -954,6 +961,34 @@ class ReticulumMeshChat: # get app info @routes.get("/api/v1/app/info") async def index(request): + # Get memory usage for current process + process = psutil.Process() + memory_info = process.memory_info() + + # Get network I/O statistics + net_io = psutil.net_io_counters() + + # Get total paths + path_table = self.reticulum.get_path_table() + total_paths = len(path_table) + + # Calculate announce rates + current_time = time.time() + announces_per_second = len([t for t in self.announce_timestamps if current_time - t <= 1.0]) + announces_per_minute = len([t for t in self.announce_timestamps if current_time - t <= 60.0]) + announces_per_hour = len([t for t in self.announce_timestamps if current_time - t <= 3600.0]) + + # Clean up old announce timestamps (older than 1 hour) + self.announce_timestamps = [t for t in self.announce_timestamps if current_time - t <= 3600.0] + + # Calculate average download speed + avg_download_speed_bps = None + if self.download_speeds: + total_bytes = sum(size for size, _ in self.download_speeds) + total_duration = sum(duration for _, duration in self.download_speeds) + if total_duration > 0: + avg_download_speed_bps = total_bytes / total_duration + return web.json_response({ "app_info": { "version": self.get_app_version(), @@ -966,6 +1001,25 @@ class ReticulumMeshChat: "reticulum_config_path": self.reticulum.configpath, "is_connected_to_shared_instance": self.reticulum.is_connected_to_shared_instance, "is_transport_enabled": self.reticulum.transport_enabled(), + "memory_usage": { + "rss": memory_info.rss, # Resident Set Size (bytes) + "vms": memory_info.vms, # Virtual Memory Size (bytes) + }, + "network_stats": { + "bytes_sent": net_io.bytes_sent, + "bytes_recv": net_io.bytes_recv, + "packets_sent": net_io.packets_sent, + "packets_recv": net_io.packets_recv, + }, + "reticulum_stats": { + "total_paths": total_paths, + "announces_per_second": announces_per_second, + "announces_per_minute": announces_per_minute, + "announces_per_hour": announces_per_hour, + }, + "download_stats": { + "avg_download_speed_bps": avg_download_speed_bps, + }, }, }) @@ -2245,6 +2299,16 @@ class ReticulumMeshChat: # handle successful file download def on_file_download_success(file_name, file_bytes): + # Track download speed + download_size = len(file_bytes) + if hasattr(downloader, 'start_time') and downloader.start_time: + download_duration = time.time() - downloader.start_time + if download_duration > 0: + self.download_speeds.append((download_size, download_duration)) + # Keep only last 100 downloads for average calculation + if len(self.download_speeds) > 100: + self.download_speeds.pop(0) + AsyncUtils.run_async(client.send_str(json.dumps({ "type": "nomadnet.file.download", "nomadnet_file_download": { @@ -2282,6 +2346,7 @@ class ReticulumMeshChat: # download the file downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress) + downloader.start_time = time.time() AsyncUtils.run_async(downloader.download()) # handle downloading a page from a nomadnet node @@ -3054,6 +3119,9 @@ class ReticulumMeshChat: # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [call.audio]") + # track announce timestamp + self.announce_timestamps.append(time.time()) + # upsert announce to database self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) @@ -3075,6 +3143,9 @@ class ReticulumMeshChat: # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.delivery]") + # track announce timestamp + self.announce_timestamps.append(time.time()) + # upsert announce to database self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) @@ -3100,6 +3171,9 @@ class ReticulumMeshChat: # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.propagation]") + # track announce timestamp + self.announce_timestamps.append(time.time()) + # upsert announce to database self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) @@ -3184,6 +3258,9 @@ class ReticulumMeshChat: # log received announce print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [nomadnetwork.node]") + # track announce timestamp + self.announce_timestamps.append(time.time()) + # upsert announce to database self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) diff --git a/package-lock.json b/package-lock.json index 2537a75..c10b4fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3010,6 +3010,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", diff --git a/src/frontend/components/about/AboutPage.vue b/src/frontend/components/about/AboutPage.vue index 1a5bc36..bed9a76 100644 --- a/src/frontend/components/about/AboutPage.vue +++ b/src/frontend/components/about/AboutPage.vue @@ -64,6 +64,141 @@ + +
+
+ System Resources + + + Live + +
+
+ + +
+
+
Memory Usage (RSS)
+
{{ formatBytes(appInfo.memory_usage.rss) }}
+
+
+ + +
+
+
Virtual Memory Size
+
{{ formatBytes(appInfo.memory_usage.vms) }}
+
+
+ +
+
+ + +
+
+ Network Statistics + + + Live + +
+
+ + +
+
+
Data Sent
+
{{ formatBytes(appInfo.network_stats.bytes_sent) }}
+
+
+ + +
+
+
Data Received
+
{{ formatBytes(appInfo.network_stats.bytes_recv) }}
+
+
+ + +
+
Packets Sent
+
{{ formatNumber(appInfo.network_stats.packets_sent) }}
+
+ + +
+
Packets Received
+
{{ formatNumber(appInfo.network_stats.packets_recv) }}
+
+ +
+
+ + +
+
+ Reticulum Statistics + + + Live + +
+
+ + +
+
Total Paths
+
{{ formatNumber(appInfo.reticulum_stats.total_paths) }}
+
+ + +
+
Announces per Second
+
{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}
+
+ + +
+
Announces per Minute
+
{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}
+
+ + +
+
Announces per Hour
+
{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}
+
+ +
+
+ + +
+
+ Download Statistics + + + Live + +
+
+ + +
+
Average Download Speed
+
+ + {{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }} + + No downloads yet +
+
+ +
+
+
Reticulum Status
@@ -126,11 +261,21 @@ export default { return { appInfo: null, config: null, + updateInterval: null, }; }, mounted() { this.getAppInfo(); this.getConfig(); + // Update stats every 5 seconds + this.updateInterval = setInterval(() => { + this.getAppInfo(); + }, 5000); + }, + beforeUnmount() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + } }, methods: { async getAppInfo() { @@ -166,6 +311,12 @@ export default { formatBytes: function(bytes) { return Utils.formatBytes(bytes); }, + formatNumber: function(num) { + return Utils.formatNumber(num); + }, + formatBytesPerSecond: function(bytesPerSecond) { + return Utils.formatBytesPerSecond(bytesPerSecond); + }, }, computed: { isElectron() { diff --git a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue index fb88e92..948df84 100644 --- a/src/frontend/components/nomadnetwork/NomadNetworkPage.vue +++ b/src/frontend/components/nomadnetwork/NomadNetworkPage.vue @@ -124,7 +124,12 @@
-
Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)
+
+ Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%) + + - {{ formatBytesPerSecond(nodeFileDownloadSpeed) }} + +
@@ -179,6 +184,7 @@ import DialogUtils from "../../js/DialogUtils"; import WebSocketConnection from "../../js/WebSocketConnection"; import NomadNetworkSidebar from "./NomadNetworkSidebar.vue"; import GlobalEmitter from "../../js/GlobalEmitter"; +import Utils from "../../js/Utils"; export default { name: 'NomadNetworkPage', @@ -213,6 +219,10 @@ export default { isDownloadingNodeFile: false, nodeFilePath: null, nodeFileProgress: 0, + nodeFileDownloadStartTime: null, + nodeFileLastProgressTime: null, + nodeFileLastProgressValue: 0, + nodeFileDownloadSpeed: null, nomadnetPageDownloadCallbacks: {}, nomadnetFileDownloadCallbacks: {}, @@ -756,26 +766,72 @@ export default { this.isDownloadingNodeFile = true; this.nodeFilePath = parsedUrl.path.split("/").pop(); this.nodeFileProgress = 0; + this.nodeFileDownloadStartTime = Date.now(); + this.nodeFileLastProgressTime = Date.now(); + this.nodeFileLastProgressValue = 0; + this.nodeFileDownloadSpeed = null; // start file download this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => { + // Calculate final download speed based on actual file size + if (this.nodeFileDownloadStartTime) { + const totalTime = (Date.now() - this.nodeFileDownloadStartTime) / 1000; // seconds + const fileSizeBytes = atob(fileBytesBase64).length; + if (totalTime > 0) { + this.nodeFileDownloadSpeed = fileSizeBytes / totalTime; + } + } + // no longer downloading this.isDownloadingNodeFile = false; // download file to browser this.downloadFileFromBase64(fileName, fileBytesBase64); + // Clear speed after a moment + setTimeout(() => { + this.nodeFileDownloadSpeed = null; + }, 2000); + }, (failureReason) => { // no longer downloading this.isDownloadingNodeFile = false; + this.nodeFileDownloadSpeed = null; // show error message DialogUtils.alert(`Failed to download file: ${failureReason}`); }, (progress) => { - this.nodeFileProgress = Math.round(progress * 100); + const currentTime = Date.now(); + const progressValue = progress; + this.nodeFileProgress = Math.round(progressValue * 100); + + // Calculate estimated download speed based on progress rate + if (this.nodeFileDownloadStartTime && progressValue > 0) { + const elapsedTime = (currentTime - this.nodeFileDownloadStartTime) / 1000; // seconds + if (elapsedTime > 0.5) { // Only calculate after at least 0.5 seconds + // Estimate total file size based on progress rate + // If we've downloaded progressValue in elapsedTime, estimate total time + const estimatedTotalTime = elapsedTime / progressValue; + // Estimate file size based on average download speed assumption + // We'll refine this when download completes with actual size + // For now, estimate based on typical mesh network file sizes (100KB-10MB range) + // Use a conservative estimate that will be updated when download completes + const estimatedFileSize = 500 * 1024; // Start with 500KB estimate + const estimatedBytesDownloaded = estimatedFileSize * progressValue; + const estimatedSpeed = estimatedBytesDownloaded / elapsedTime; + + // Only update if we have a reasonable estimate + if (estimatedSpeed > 0 && estimatedSpeed < 100 * 1024 * 1024) { // Cap at 100MB/s + this.nodeFileDownloadSpeed = estimatedSpeed; + } + } + } + + this.nodeFileLastProgressTime = currentTime; + this.nodeFileLastProgressValue = progressValue; }); return; @@ -829,6 +885,9 @@ export default { setTimeout(() => URL.revokeObjectURL(objectUrl), 10000); }, + formatBytesPerSecond: function(bytesPerSecond) { + return Utils.formatBytesPerSecond(bytesPerSecond); + }, onNodeClick: function(node) { // update selected node diff --git a/src/frontend/js/Utils.js b/src/frontend/js/Utils.js index 74fcf6a..ca28d7b 100644 --- a/src/frontend/js/Utils.js +++ b/src/frontend/js/Utils.js @@ -25,6 +25,13 @@ class Utils { } + static formatNumber(num) { + if(num === 0){ + return '0'; + } + return num.toLocaleString(); + } + static parseSeconds(secondsToFormat) { secondsToFormat = Number(secondsToFormat); var days = Math.floor(secondsToFormat / (3600 * 24)); @@ -127,6 +134,22 @@ class Utils { } + static formatBytesPerSecond(bytesPerSecond) { + + if(bytesPerSecond === 0 || bytesPerSecond == null){ + return '0 B/s'; + } + + const k = 1024; + const decimals = 1; + const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']; + + const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k)); + + return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]; + + } + static formatFrequency(hz) { if(hz === 0 || hz == null){