Add system resource tracking and download speed estimation to ReticulumMeshChat

- Integrated psutil for memory and network statistics.
- Enhanced app info API to include memory usage, network stats, and download statistics.
- Implemented download speed tracking for files in NomadNetworkPage.
- Added utility functions for formatting numbers and bytes per second.
- Updated frontend components to display new statistics in real-time.
This commit is contained in:
2025-11-11 08:04:07 -06:00
parent 8b044f6dab
commit 3566c6b2da
5 changed files with 313 additions and 2 deletions

View File

@@ -22,6 +22,7 @@ import webbrowser
from peewee import SqliteDatabase from peewee import SqliteDatabase
from serial.tools import list_ports from serial.tools import list_ports
import psutil
import database import database
from src.backend.announce_handler import AnnounceHandler from src.backend.announce_handler import AnnounceHandler
@@ -147,6 +148,12 @@ class ReticulumMeshChat:
# remember websocket clients # remember websocket clients
self.websocket_clients: List[web.WebSocketResponse] = [] 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 # register audio call identity
self.audio_call_manager = AudioCallManager(identity=self.identity) self.audio_call_manager = AudioCallManager(identity=self.identity)
self.audio_call_manager.register_incoming_call_callback(self.on_incoming_audio_call) self.audio_call_manager.register_incoming_call_callback(self.on_incoming_audio_call)
@@ -954,6 +961,34 @@ class ReticulumMeshChat:
# get app info # get app info
@routes.get("/api/v1/app/info") @routes.get("/api/v1/app/info")
async def index(request): 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({ return web.json_response({
"app_info": { "app_info": {
"version": self.get_app_version(), "version": self.get_app_version(),
@@ -966,6 +1001,25 @@ class ReticulumMeshChat:
"reticulum_config_path": self.reticulum.configpath, "reticulum_config_path": self.reticulum.configpath,
"is_connected_to_shared_instance": self.reticulum.is_connected_to_shared_instance, "is_connected_to_shared_instance": self.reticulum.is_connected_to_shared_instance,
"is_transport_enabled": self.reticulum.transport_enabled(), "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 # handle successful file download
def on_file_download_success(file_name, file_bytes): 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({ AsyncUtils.run_async(client.send_str(json.dumps({
"type": "nomadnet.file.download", "type": "nomadnet.file.download",
"nomadnet_file_download": { "nomadnet_file_download": {
@@ -2282,6 +2346,7 @@ class ReticulumMeshChat:
# download the file # download the file
downloader = NomadnetFileDownloader(destination_hash, file_path, on_file_download_success, on_file_download_failure, on_file_download_progress) 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()) AsyncUtils.run_async(downloader.download())
# handle downloading a page from a nomadnet node # handle downloading a page from a nomadnet node
@@ -3054,6 +3119,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [call.audio]") 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 # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)
@@ -3075,6 +3143,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.delivery]") 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 # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)
@@ -3100,6 +3171,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxmf.propagation]") 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 # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)
@@ -3184,6 +3258,9 @@ class ReticulumMeshChat:
# log received announce # log received announce
print("Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [nomadnetwork.node]") 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 # upsert announce to database
self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash) self.db_upsert_announce(announced_identity, destination_hash, aspect, app_data, announce_packet_hash)

1
package-lock.json generated
View File

@@ -3010,6 +3010,7 @@
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",

View File

@@ -64,6 +64,141 @@
</div> </div>
</div> </div>
<!-- system resources -->
<div v-if="appInfo && appInfo.memory_usage" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
System Resources
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- memory usage -->
<div class="flex p-1">
<div class="mr-auto">
<div>Memory Usage (RSS)</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
</div>
</div>
<!-- virtual memory -->
<div class="flex p-1">
<div class="mr-auto">
<div>Virtual Memory Size</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
</div>
</div>
</div>
</div>
<!-- network statistics -->
<div v-if="appInfo && appInfo.network_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Network Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- bytes sent -->
<div class="flex p-1">
<div class="mr-auto">
<div>Data Sent</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
</div>
</div>
<!-- bytes received -->
<div class="flex p-1">
<div class="mr-auto">
<div>Data Received</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
</div>
</div>
<!-- packets sent -->
<div class="p-1">
<div>Packets Sent</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
</div>
<!-- packets received -->
<div class="p-1">
<div>Packets Received</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
</div>
</div>
</div>
<!-- reticulum statistics -->
<div v-if="appInfo && appInfo.reticulum_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Reticulum Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- total paths -->
<div class="p-1">
<div>Total Paths</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
</div>
<!-- announces per second -->
<div class="p-1">
<div>Announces per Second</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
</div>
<!-- announces per minute -->
<div class="p-1">
<div>Announces per Minute</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
</div>
<!-- announces per hour -->
<div class="p-1">
<div>Announces per Hour</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}</div>
</div>
</div>
</div>
<!-- download statistics -->
<div v-if="appInfo && appInfo.download_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
Download Statistics
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Live
</span>
</div>
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
<!-- average download speed -->
<div class="p-1">
<div>Average Download Speed</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
</span>
<span v-else>No downloads yet</span>
</div>
</div>
</div>
</div>
<!-- reticulum status --> <!-- reticulum status -->
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow"> <div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">Reticulum Status</div> <div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">Reticulum Status</div>
@@ -126,11 +261,21 @@ export default {
return { return {
appInfo: null, appInfo: null,
config: null, config: null,
updateInterval: null,
}; };
}, },
mounted() { mounted() {
this.getAppInfo(); this.getAppInfo();
this.getConfig(); this.getConfig();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
}, 5000);
},
beforeUnmount() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
}, },
methods: { methods: {
async getAppInfo() { async getAppInfo() {
@@ -166,6 +311,12 @@ export default {
formatBytes: function(bytes) { formatBytes: function(bytes) {
return Utils.formatBytes(bytes); return Utils.formatBytes(bytes);
}, },
formatNumber: function(num) {
return Utils.formatNumber(num);
},
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
}, },
computed: { computed: {
isElectron() { isElectron() {

View File

@@ -124,7 +124,12 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
</div> </div>
<div class="my-auto">Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)</div> <div class="my-auto">
Downloading: {{ nodeFilePath }} ({{ nodeFileProgress }}%)
<span v-if="nodeFileDownloadSpeed !== null" class="ml-2 text-sm">
- {{ formatBytesPerSecond(nodeFileDownloadSpeed) }}
</span>
</div>
</div> </div>
</div> </div>
@@ -179,6 +184,7 @@ import DialogUtils from "../../js/DialogUtils";
import WebSocketConnection from "../../js/WebSocketConnection"; import WebSocketConnection from "../../js/WebSocketConnection";
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue"; import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
import GlobalEmitter from "../../js/GlobalEmitter"; import GlobalEmitter from "../../js/GlobalEmitter";
import Utils from "../../js/Utils";
export default { export default {
name: 'NomadNetworkPage', name: 'NomadNetworkPage',
@@ -213,6 +219,10 @@ export default {
isDownloadingNodeFile: false, isDownloadingNodeFile: false,
nodeFilePath: null, nodeFilePath: null,
nodeFileProgress: 0, nodeFileProgress: 0,
nodeFileDownloadStartTime: null,
nodeFileLastProgressTime: null,
nodeFileLastProgressValue: 0,
nodeFileDownloadSpeed: null,
nomadnetPageDownloadCallbacks: {}, nomadnetPageDownloadCallbacks: {},
nomadnetFileDownloadCallbacks: {}, nomadnetFileDownloadCallbacks: {},
@@ -756,26 +766,72 @@ export default {
this.isDownloadingNodeFile = true; this.isDownloadingNodeFile = true;
this.nodeFilePath = parsedUrl.path.split("/").pop(); this.nodeFilePath = parsedUrl.path.split("/").pop();
this.nodeFileProgress = 0; this.nodeFileProgress = 0;
this.nodeFileDownloadStartTime = Date.now();
this.nodeFileLastProgressTime = Date.now();
this.nodeFileLastProgressValue = 0;
this.nodeFileDownloadSpeed = null;
// start file download // start file download
this.downloadNomadNetFile(destinationHash, parsedUrl.path, (fileName, fileBytesBase64) => { 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 // no longer downloading
this.isDownloadingNodeFile = false; this.isDownloadingNodeFile = false;
// download file to browser // download file to browser
this.downloadFileFromBase64(fileName, fileBytesBase64); this.downloadFileFromBase64(fileName, fileBytesBase64);
// Clear speed after a moment
setTimeout(() => {
this.nodeFileDownloadSpeed = null;
}, 2000);
}, (failureReason) => { }, (failureReason) => {
// no longer downloading // no longer downloading
this.isDownloadingNodeFile = false; this.isDownloadingNodeFile = false;
this.nodeFileDownloadSpeed = null;
// show error message // show error message
DialogUtils.alert(`Failed to download file: ${failureReason}`); DialogUtils.alert(`Failed to download file: ${failureReason}`);
}, (progress) => { }, (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; return;
@@ -829,6 +885,9 @@ export default {
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000); setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
}, },
formatBytesPerSecond: function(bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function(node) { onNodeClick: function(node) {
// update selected node // update selected node

View File

@@ -25,6 +25,13 @@ class Utils {
} }
static formatNumber(num) {
if(num === 0){
return '0';
}
return num.toLocaleString();
}
static parseSeconds(secondsToFormat) { static parseSeconds(secondsToFormat) {
secondsToFormat = Number(secondsToFormat); secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24)); 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) { static formatFrequency(hz) {
if(hz === 0 || hz == null){ if(hz === 0 || hz == null){