diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d47c27a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,110 @@ +# Contributing to PingZilla + +- [Contributing to PingZilla](#contributing-to-pingzilla) + - [Testing Changes on Your Mac](#testing-changes-on-your-mac) + - [Which workflow should I use?](#which-workflow-should-i-use) + - [Fast development workflow](#fast-development-workflow) + - [Validation without launching the app](#validation-without-launching-the-app) + - [Versioning](#versioning) + - [Testing a local production bundle](#testing-a-local-production-bundle) + +## Testing Changes on Your Mac + +You do not need to uninstall the Mac App Store or `/Applications` copy of PingZilla. Quit it before launching a development or local build so that two PingZilla processes do not monitor and write history at the same time. + +## Which workflow should I use? + +| Goal | Command | Creates a `.app` bundle? | +|------|---------|---------------------------| +| Interactively test UI or Rust/backend behavior | `pnpm tauri dev` | No | +| Reproduce installed-app behavior or test packaging, signing, sandboxing, entitlements, or a release candidate | `pnpm tauri build --bundles app` | Yes | + +For example, `pnpm tauri dev` is sufficient to test both the network-name pencil interaction and whether **Quit PingZilla** exits cleanly. Since a Quit crash can depend on the native packaged runtime, also test a production bundle once before considering that fix release-ready. You do not need to rebuild the bundle after every small UI iteration. + +## Fast development workflow + +Use this for normal frontend and backend development: + +```bash +pnpm install +pnpm tauri dev +``` + +PingZilla starts as a menu-bar app and may not open a window automatically. Click its menu-bar icon and choose **Open Dashboard…**. + +Changes made while `pnpm tauri dev` is running rebuild automatically. When finished: + +1. Stop the development process with `Control-C` in Terminal. +2. Quit the development PingZilla instance if it remains open. +3. Relaunch the installed version from `/Applications` if desired. + +## Validation without launching the app + +For a small UI-only change: + +```bash +pnpm build +``` + +This checks TypeScript and creates the Vite frontend build. It does not create or update a macOS app bundle. + +For Rust/backend changes: + +```bash +cargo test --manifest-path src-tauri/Cargo.toml +pnpm build +``` + +## Versioning + +The user-facing app version is the `version` value in `src-tauri/tauri.conf.json`. The `0.1.0` values in `package.json` and `src-tauri/Cargo.toml` identify the frontend and Rust packages; they are not the PingZilla release version and do not normally need to change with an app release. + +Use semantic versioning for release versions: + +- Increase the patch number for compatible fixes and small improvements, for example `1.3.11` to `1.3.12`. +- Increase the minor number for a meaningful set of new, compatible features, for example `1.3.11` to `1.4.0`. +- Reserve a major-version increase for a release with substantial incompatible behavior or expectations. + +Do not increase the version for ordinary `pnpm tauri dev`, `pnpm build`, or `cargo test` runs. When creating a local production bundle whose identity matters—especially while following the steps below to test packaging, sandboxing, or several release candidates—use a prerelease version based on the next intended release, such as `1.3.12-dev.1`. Increase the final number for another distinguishable build (`dev.2`, `dev.3`, and so on). This prevents an unreleased test bundle from presenting itself as the already-published stable release. + +Dev-style versions are for local testing only and must not be submitted to the Mac App Store. Before making a release candidate or distribution build, replace the prerelease version with the final numeric release version and verify the generated app reports it correctly. + +## Testing a local production bundle + +Create a production-style app when testing native runtime, sandbox, entitlement, packaging, installed-app behavior, or release behavior. This is a separate copy for release-like local testing; it is not needed just to make `pnpm tauri dev` pick up current source changes. If the bundle needs to be distinguishable from the current release or from earlier test bundles, assign it the next `-dev.N` version described above before building. + +```bash +pnpm tauri build --bundles app +``` + +The app is created at: + +```text +src-tauri/target/release/bundle/macos/PingZilla.app +``` + +For local sandbox testing, apply an ad-hoc signature with PingZilla's entitlements: + +```bash +codesign --force --deep --sign - \ + --entitlements src-tauri/Entitlements.plist \ + src-tauri/target/release/bundle/macos/PingZilla.app +``` + +Quit any running copy of PingZilla before testing the bundle so that two processes do not monitor and write history at the same time. To launch the bundle in place: + +```bash +open src-tauri/target/release/bundle/macos/PingZilla.app +``` + +To install the local build in `/Applications`, sign it first using the command above. If an older copy is already installed, remove or replace that entire app bundle rather than merging files into it. Then copy and launch the new bundle: + +```bash +rm -rf /Applications/PingZilla.app +cp -R src-tauri/target/release/bundle/macos/PingZilla.app /Applications/ +open /Applications/PingZilla.app +``` + +Signing before copying preserves the completed bundle's signature; modifying files inside the app afterward invalidates it. + +This ad-hoc signature is for local testing only; it is not a distribution or Mac App Store signature. diff --git a/README.md b/README.md index 2a66e9e..156717c 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ Run `make help` to see all available commands: ## Documentation +- [Contributing and local testing](CONTRIBUTING.md) - Test development changes without removing the installed app - [Marketing Plan](MarketingPlan.md) - Launch strategy and growth plans ## License diff --git a/screenshots/network-history.png b/screenshots/network-history.png new file mode 100644 index 0000000..fb47f2a Binary files /dev/null and b/screenshots/network-history.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4e5dfb2..00a6c8e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,8 +4,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; +use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tauri::{ image::Image, @@ -17,14 +18,50 @@ use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_notification::NotificationExt; use tokio::sync::{Mutex, Notify}; +const NETWORK_QUALITY_PATH: &str = "/usr/bin/networkQuality"; + +fn run_network_quality_process(max_runtime_secs: u32) -> Result { + let output = Command::new(NETWORK_QUALITY_PATH) + .args(["-c", "-M", &max_runtime_secs.to_string()]) + .output() + .map_err(|error| format!("Unable to start networkQuality: {error}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!( + "networkQuality exited with {}{}", + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + )); + } + + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("networkQuality returned invalid JSON: {error}")) +} + +/// Diagnostic entry point used to validate networkQuality from the signed app binary. +pub fn network_quality_smoke_test() -> Result<(), String> { + let result = run_network_quality_process(15)?; + println!( + "{}", + serde_json::to_string_pretty(&result) + .map_err(|error| format!("Unable to format networkQuality output: {error}"))? + ); + Ok(()) +} + /// Method used to measure ping latency #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum PingMethod { - Icmp, // Real ICMP ping via system command + Icmp, // Real ICMP ping via system command // TCP variants kept for backwards compatibility with existing history data - TcpDns, // (deprecated) TCP connect to port 53 (DNS) - TcpHttps, // (deprecated) TCP connect to port 443 - TcpHttp, // (deprecated) TCP connect to port 80 + TcpDns, // (deprecated) TCP connect to port 53 (DNS) + TcpHttps, // (deprecated) TCP connect to port 443 + TcpHttp, // (deprecated) TCP connect to port 80 } /// A single ping measurement @@ -35,6 +72,8 @@ pub struct PingResult { pub target: String, #[serde(default)] pub method: Option, + #[serde(default)] + pub session_id: Option, } /// Statistics for a target @@ -66,6 +105,43 @@ pub struct IpInfo { pub isp: Option, } +/// A continuous period on one public IP + ISP fingerprint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkSession { + pub id: String, + pub fingerprint: String, + pub public_ip: String, + pub isp: Option, + pub label: Option, + pub started_at: DateTime, + pub ended_at: Option>, +} + +/// A user-triggered macOS networkQuality result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpeedTestResult { + pub id: String, + pub timestamp: DateTime, + pub session_id: Option, + pub download_mbps: f64, + pub upload_mbps: f64, + pub loaded_latency_ms: Option, + pub idle_latency_ms: Option, + pub responsiveness_rpm: Option, + pub interface_name: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SessionDetails { + pub session: NetworkSession, + pub median_ms: Option, + pub average_ms: Option, + pub p95_ms: Option, + pub packet_loss_pct: f64, + pub total_pings: usize, + pub latest_speed_test: Option, +} + /// Site monitor configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SiteMonitor { @@ -159,6 +235,17 @@ pub enum TrayIconType { Transparent, } +/// Stable handles for tray rows whose labels change as monitoring data arrives. +/// Keeping these handles alive lets the background service update labels without +/// replacing the native menu while AppKit may be processing a click. +struct TrayMenuItems { + ping: MenuItem, + target: MenuItem, + stats: MenuItem, + ip: MenuItem, + sites: Vec<(String, MenuItem)>, +} + /// Application state shared across the app pub struct AppState { pub ping_history: Mutex>>, @@ -179,6 +266,7 @@ pub struct AppState { pub last_vpn_notification: Mutex>>, // Tray state cache to avoid unnecessary updates pub last_tray_state: Mutex>, + tray_menu_items: StdMutex>, // Battery optimization: sleep/wake and visibility tracking pub is_system_sleeping: AtomicBool, pub is_window_visible: AtomicBool, @@ -186,6 +274,15 @@ pub struct AppState { pub wake_notify: Arc, // User-configurable ping interval (in seconds) pub ping_interval_secs: Mutex, + pub speed_test_duration_secs: Mutex, + // Network-context history + pub network_sessions: Mutex>, + pub current_session_id: Mutex>, + pub network_aliases: Mutex>, + pub speed_tests: Mutex>, + pub speed_test_running: AtomicBool, + pub start_new_session_after_wake: AtomicBool, + pub shutdown_requested: AtomicBool, } impl Default for AppState { @@ -211,6 +308,7 @@ impl Default for AppState { last_vpn_notification: Mutex::new(None), // Tray state cache last_tray_state: Mutex::new(None), + tray_menu_items: StdMutex::new(None), // Battery optimization defaults is_system_sleeping: AtomicBool::new(false), is_window_visible: AtomicBool::new(false), @@ -218,6 +316,14 @@ impl Default for AppState { wake_notify: Arc::new(Notify::new()), // Default ping interval: 10 seconds ping_interval_secs: Mutex::new(10), + speed_test_duration_secs: Mutex::new(7), + network_sessions: Mutex::new(VecDeque::new()), + current_session_id: Mutex::new(None), + network_aliases: Mutex::new(HashMap::new()), + speed_tests: Mutex::new(VecDeque::new()), + speed_test_running: AtomicBool::new(false), + start_new_session_after_wake: AtomicBool::new(false), + shutdown_requested: AtomicBool::new(false), } } } @@ -253,6 +359,304 @@ async fn get_ping_history( .unwrap_or_default()) } +fn network_fingerprint(info: &IpInfo) -> String { + format!( + "{}|{}", + info.ip.trim(), + info.isp.as_deref().unwrap_or("Unknown ISP").trim() + ) +} + +async fn ensure_network_session( + state: &Arc, + info: &IpInfo, + force_new: bool, +) -> Option { + let fingerprint = network_fingerprint(info); + let current_id = state.current_session_id.lock().await.clone(); + + if !force_new { + if let Some(current_id) = ¤t_id { + let sessions = state.network_sessions.lock().await; + if sessions + .iter() + .any(|session| session.id == *current_id && session.fingerprint == fingerprint) + { + return None; + } + } + } + + let now = Utc::now(); + let label = state + .network_aliases + .lock() + .await + .get(&fingerprint) + .cloned(); + let mut sessions = state.network_sessions.lock().await; + + if let Some(current_id) = current_id { + if let Some(current) = sessions.iter_mut().find(|session| session.id == current_id) { + current.ended_at = Some(now); + } + } + + let session = NetworkSession { + id: format!("session-{}", now.timestamp_micros()), + fingerprint, + public_ip: info.ip.clone(), + isp: info.isp.clone(), + label, + started_at: now, + ended_at: None, + }; + sessions.push_back(session.clone()); + + let cutoff = now - chrono::Duration::hours(24); + while sessions + .front() + .is_some_and(|oldest| oldest.ended_at.unwrap_or(oldest.started_at) < cutoff) + { + sessions.pop_front(); + } + drop(sessions); + + *state.current_session_id.lock().await = Some(session.id.clone()); + let startup_cutoff = now - chrono::Duration::minutes(5); + for ping in state + .ping_history + .lock() + .await + .values_mut() + .flat_map(|history| history.iter_mut()) + .filter(|ping| ping.session_id.is_none() && ping.timestamp >= startup_cutoff) + { + ping.session_id = Some(session.id.clone()); + } + Some(session) +} + +#[tauri::command] +async fn get_network_sessions( + state: State<'_, Arc>, +) -> Result, String> { + Ok(state + .network_sessions + .lock() + .await + .iter() + .cloned() + .collect()) +} + +#[tauri::command] +async fn get_speed_tests(state: State<'_, Arc>) -> Result, String> { + Ok(state.speed_tests.lock().await.iter().cloned().collect()) +} + +#[tauri::command] +async fn rename_network_session( + session_id: String, + label: String, + state: State<'_, Arc>, +) -> Result<(), String> { + let label = label.trim().to_string(); + if label.len() > 80 { + return Err("Network name must be 80 characters or fewer".to_string()); + } + + let fingerprint = { + let sessions = state.network_sessions.lock().await; + sessions + .iter() + .find(|session| session.id == session_id) + .map(|session| session.fingerprint.clone()) + .ok_or_else(|| "Network session not found".to_string())? + }; + + if label.is_empty() { + state.network_aliases.lock().await.remove(&fingerprint); + } else { + state + .network_aliases + .lock() + .await + .insert(fingerprint.clone(), label.clone()); + } + + for session in state + .network_sessions + .lock() + .await + .iter_mut() + .filter(|session| session.fingerprint == fingerprint) + { + session.label = (!label.is_empty()).then(|| label.clone()); + } + + save_history_async(state.inner()).await; + Ok(()) +} + +fn percentile(sorted: &[f64], percentile: f64) -> Option { + if sorted.is_empty() { + return None; + } + let index = ((sorted.len() as f64 * percentile).ceil() as usize) + .saturating_sub(1) + .min(sorted.len() - 1); + Some(sorted[index]) +} + +#[tauri::command] +async fn get_session_details( + session_id: String, + target: String, + state: State<'_, Arc>, +) -> Result { + let session = state + .network_sessions + .lock() + .await + .iter() + .find(|session| session.id == session_id) + .cloned() + .ok_or_else(|| "Network session not found".to_string())?; + + let matching: Vec = state + .ping_history + .lock() + .await + .get(&target) + .map(|history| { + history + .iter() + .filter(|ping| ping.session_id.as_deref() == Some(&session_id)) + .cloned() + .collect() + }) + .unwrap_or_default(); + let total_pings = matching.len(); + let failed_pings = matching + .iter() + .filter(|ping| ping.latency_ms.is_none()) + .count(); + let mut successful: Vec = matching.iter().filter_map(|ping| ping.latency_ms).collect(); + successful.sort_by(f64::total_cmp); + + let average_ms = + (!successful.is_empty()).then(|| successful.iter().sum::() / successful.len() as f64); + let median_ms = if successful.is_empty() { + None + } else if successful.len().is_multiple_of(2) { + let middle = successful.len() / 2; + Some((successful[middle - 1] + successful[middle]) / 2.0) + } else { + Some(successful[successful.len() / 2]) + }; + let packet_loss_pct = if total_pings == 0 { + 0.0 + } else { + failed_pings as f64 / total_pings as f64 * 100.0 + }; + let latest_speed_test = state + .speed_tests + .lock() + .await + .iter() + .rev() + .find(|test| test.session_id.as_deref() == Some(&session_id)) + .cloned(); + + Ok(SessionDetails { + session, + median_ms, + average_ms, + p95_ms: percentile(&successful, 0.95), + packet_loss_pct, + total_pings, + latest_speed_test, + }) +} + +fn json_number(value: &serde_json::Value, key: &str) -> Option { + value.get(key).and_then(serde_json::Value::as_f64) +} + +fn median_json_array(value: &serde_json::Value, key: &str) -> Option { + let mut values: Vec = value + .get(key)? + .as_array()? + .iter() + .filter_map(serde_json::Value::as_f64) + .collect(); + values.sort_by(f64::total_cmp); + if values.is_empty() { + None + } else if values.len().is_multiple_of(2) { + let middle = values.len() / 2; + Some((values[middle - 1] + values[middle]) / 2.0) + } else { + Some(values[values.len() / 2]) + } +} + +#[tauri::command] +async fn run_speed_test(state: State<'_, Arc>) -> Result { + if state + .speed_test_running + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Err("A speed test is already running".to_string()); + } + + let session_id = state.current_session_id.lock().await.clone(); + let duration_secs = *state.speed_test_duration_secs.lock().await; + let process_result = + tokio::task::spawn_blocking(move || run_network_quality_process(duration_secs)).await; + state.speed_test_running.store(false, Ordering::SeqCst); + + let raw = process_result.map_err(|error| format!("networkQuality task failed: {error}"))??; + if let Some(domain) = raw.get("error_domain").and_then(serde_json::Value::as_str) { + let code = raw + .get("error_code") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + return Err(format!("networkQuality failed ({domain}, code {code})")); + } + + let now = Utc::now(); + let result = SpeedTestResult { + id: format!("speed-{}", now.timestamp_micros()), + timestamp: now, + session_id, + download_mbps: json_number(&raw, "dl_throughput").unwrap_or_default() / 1_000_000.0, + upload_mbps: json_number(&raw, "ul_throughput").unwrap_or_default() / 1_000_000.0, + loaded_latency_ms: median_json_array(&raw, "lud_self_h2_req_resp"), + idle_latency_ms: json_number(&raw, "base_rtt"), + responsiveness_rpm: json_number(&raw, "responsiveness"), + interface_name: raw + .get("interface_name") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + }; + + let mut speed_tests = state.speed_tests.lock().await; + speed_tests.push_back(result.clone()); + let cutoff = now - chrono::Duration::hours(24); + while speed_tests + .front() + .is_some_and(|oldest| oldest.timestamp < cutoff) + { + speed_tests.pop_front(); + } + drop(speed_tests); + save_history_async(state.inner()).await; + Ok(result) +} + /// Get all targets #[tauri::command] async fn get_targets(state: State<'_, Arc>) -> Result, String> { @@ -466,6 +870,7 @@ async fn get_statistics( #[tauri::command] async fn get_my_ip_info( force_refresh: Option, + app_handle: AppHandle, state: State<'_, Arc>, ) -> Result { let force = force_refresh.unwrap_or(false); @@ -478,6 +883,9 @@ async fn get_my_ip_info( if let (Some(info), Some(checked_at)) = (cached, last_check) { // Cache for 5 minutes if Utc::now().signed_duration_since(checked_at).num_seconds() < 300 { + if let Some(session) = ensure_network_session(state.inner(), &info, false).await { + let _ = app_handle.emit("network-session-update", &session); + } return Ok(info); } } @@ -504,6 +912,9 @@ async fn get_my_ip_info( // Update cache *state.ip_info.lock().await = Some(info.clone()); *state.ip_info_last_check.lock().await = Some(Utc::now()); + if let Some(session) = ensure_network_session(state.inner(), &info, false).await { + let _ = app_handle.emit("network-session-update", &session); + } Ok(info) } @@ -598,7 +1009,9 @@ fn should_send_vpn_notification( /// Get VPN protection settings #[tauri::command] -async fn get_vpn_settings(state: State<'_, Arc>) -> Result { +async fn get_vpn_settings( + state: State<'_, Arc>, +) -> Result { let settings = state.vpn_settings.lock().await; Ok(settings.clone()) } @@ -615,7 +1028,9 @@ async fn set_vpn_settings( /// Get network stability info #[tauri::command] -async fn get_network_stability(state: State<'_, Arc>) -> Result { +async fn get_network_stability( + state: State<'_, Arc>, +) -> Result { let stability = state.network_stability.lock().await; Ok(stability.clone()) } @@ -645,7 +1060,10 @@ async fn get_ping_interval(state: State<'_, Arc>) -> Result>) -> Result<(), String> { +async fn set_ping_interval( + interval_secs: u32, + state: State<'_, Arc>, +) -> Result<(), String> { if interval_secs < 5 { return Err("Ping interval must be at least 5 seconds".to_string()); } @@ -656,6 +1074,24 @@ async fn set_ping_interval(interval_secs: u32, state: State<'_, Arc>) Ok(()) } +#[tauri::command] +async fn get_speed_test_duration(state: State<'_, Arc>) -> Result { + Ok(*state.speed_test_duration_secs.lock().await) +} + +#[tauri::command] +async fn set_speed_test_duration( + duration_secs: u32, + state: State<'_, Arc>, +) -> Result<(), String> { + if !(3..=30).contains(&duration_secs) { + return Err("Speed test duration must be between 3 and 30 seconds".to_string()); + } + *state.speed_test_duration_secs.lock().await = duration_secs; + save_history_async(state.inner()).await; + Ok(()) +} + /// Get all site monitors #[tauri::command] async fn get_site_monitors(state: State<'_, Arc>) -> Result, String> { @@ -668,37 +1104,48 @@ async fn get_site_monitors(state: State<'_, Arc>) -> Result, + app: AppHandle, state: State<'_, Arc>, ) -> Result<(), String> { - let mut monitors = state.site_monitors.lock().await; + { + let mut monitors = state.site_monitors.lock().await; - if monitors.len() >= 10 { - return Err("Maximum of 10 site monitors allowed".to_string()); - } + if monitors.len() >= 10 { + return Err("Maximum of 10 site monitors allowed".to_string()); + } - if monitors.iter().any(|m| m.url == url) { - return Err("Site already being monitored".to_string()); - } + if monitors.iter().any(|m| m.url == url) { + return Err("Site already being monitored".to_string()); + } - monitors.push(SiteMonitor { - url, - name, - enabled: true, - }); + monitors.push(SiteMonitor { + url, + name, + enabled: true, + }); + } - Ok(()) + rebuild_tray_menu(&app, state.inner()).await } /// Remove a site monitor #[tauri::command] -async fn remove_site_monitor(url: String, state: State<'_, Arc>) -> Result<(), String> { - let mut monitors = state.site_monitors.lock().await; - monitors.retain(|m| m.url != url); +async fn remove_site_monitor( + url: String, + app: AppHandle, + state: State<'_, Arc>, +) -> Result<(), String> { + { + let mut monitors = state.site_monitors.lock().await; + monitors.retain(|m| m.url != url); + } - let mut statuses = state.site_statuses.lock().await; - statuses.remove(&url); + { + let mut statuses = state.site_statuses.lock().await; + statuses.remove(&url); + } - Ok(()) + rebuild_tray_menu(&app, state.inner()).await } /// Get current site statuses @@ -775,9 +1222,21 @@ async fn check_site(url: &str) -> SiteStatus { // Parse URL to extract host and port let (host, port) = if url.starts_with("https://") { - (url.trim_start_matches("https://").split('/').next().unwrap_or(url), 443) + ( + url.trim_start_matches("https://") + .split('/') + .next() + .unwrap_or(url), + 443, + ) } else if url.starts_with("http://") { - (url.trim_start_matches("http://").split('/').next().unwrap_or(url), 80) + ( + url.trim_start_matches("http://") + .split('/') + .next() + .unwrap_or(url), + 80, + ) } else { // Assume it's a hostname/IP, try HTTPS first (url.split('/').next().unwrap_or(url), 443) @@ -880,11 +1339,6 @@ async fn check_all_sites(app_handle: &AppHandle, state: &Arc) -> bool async fn check_ip_change(app_handle: &AppHandle, state: &Arc) { let settings = state.vpn_settings.lock().await.clone(); - // Skip if VPN protection is disabled - if !settings.enabled { - return; - } - // Fetch current IP (bypass cache) if let Ok(current) = fetch_ip_info_internal().await { let previous = state.ip_info.lock().await.clone(); @@ -925,7 +1379,8 @@ async fn check_ip_change(app_handle: &AppHandle, state: &Arc) { // Send notification for critical changes let last_notif = *state.last_vpn_notification.lock().await; - if should_send_vpn_notification(&change, &settings, last_notif) { + if settings.enabled && should_send_vpn_notification(&change, &settings, last_notif) + { let (title, body) = match change.change_type { NetworkChangeType::CountryChanged => ( "VPN Alert: Location Changed!", @@ -938,7 +1393,10 @@ async fn check_ip_change(app_handle: &AppHandle, state: &Arc) { "Network: IP Changed", format!("Your IP address changed to {}", current.ip), ), - _ => ("Network Change", "Network configuration changed".to_string()), + _ => ( + "Network Change", + "Network configuration changed".to_string(), + ), }; let _ = app_handle @@ -953,6 +1411,10 @@ async fn check_ip_change(app_handle: &AppHandle, state: &Arc) { } } + if let Some(session) = ensure_network_session(state, ¤t, false).await { + let _ = app_handle.emit("network-session-update", &session); + } + // Update current IP state *state.ip_info.lock().await = Some(current); *state.ip_info_last_check.lock().await = Some(Utc::now()); @@ -1032,21 +1494,42 @@ struct TrayIcons { /// Save history to disk asynchronously (non-blocking) async fn save_history_async(state: &Arc) { - let history = state.ping_history.lock().await.clone(); - let targets = state.targets.lock().await.clone(); - let primary = state.primary_target.lock().await.clone(); - let site_monitors = state.site_monitors.lock().await.clone(); - let vpn_settings = state.vpn_settings.lock().await.clone(); - let ping_interval = *state.ping_interval_secs.lock().await; + let data = SavedData { + history: state.ping_history.lock().await.clone(), + targets: state.targets.lock().await.clone(), + primary_target: state.primary_target.lock().await.clone(), + notification_threshold_ms: *state.notification_threshold_ms.lock().await, + site_monitors: state.site_monitors.lock().await.clone(), + vpn_settings: state.vpn_settings.lock().await.clone(), + ping_interval_secs: *state.ping_interval_secs.lock().await, + speed_test_duration_secs: *state.speed_test_duration_secs.lock().await, + network_sessions: state.network_sessions.lock().await.clone(), + network_aliases: state.network_aliases.lock().await.clone(), + speed_tests: state.speed_tests.lock().await.clone(), + }; // Spawn blocking file I/O in a separate thread to not block async runtime let _ = tokio::task::spawn_blocking(move || { // Ignore error - can't send Box across threads - let _ = save_history(&history, &targets, &primary, &site_monitors, &vpn_settings, ping_interval); + let _ = save_history(&data); }) .await; } +/// Persist current state and exit after the native menu callback has returned. +fn request_app_exit(app: &AppHandle) { + let state = app.state::>().inner().clone(); + if state.shutdown_requested.swap(true, Ordering::SeqCst) { + return; + } + + let app = app.clone(); + tauri::async_runtime::spawn(async move { + save_history_async(&state).await; + app.exit(0); + }); +} + /// Unified background service - consolidates ping, site monitoring, and VPN check into ONE timer /// This dramatically reduces CPU wake-ups (from 3 independent timers to 1) /// Battery optimization: adaptive interval (10s visible, 30s hidden), pauses during system sleep @@ -1074,10 +1557,22 @@ fn start_unified_background_service(app_handle: AppHandle, state: Arc) tick_count += 1; + if state + .start_new_session_after_wake + .swap(false, Ordering::SeqCst) + { + if let Some(info) = state.ip_info.lock().await.clone() { + if let Some(session) = ensure_network_session(&state, &info, true).await { + let _ = app_handle.emit("network-session-update", &session); + } + } + } + // === PING (every tick) === { let targets = state.targets.lock().await.clone(); let primary_target = state.primary_target.lock().await.clone(); + let session_id = state.current_session_id.lock().await.clone(); for target in &targets { let (latency_ms, method) = do_ping(target).await; @@ -1087,6 +1582,7 @@ fn start_unified_background_service(app_handle: AppHandle, state: Arc) latency_ms, target: target.clone(), method, + session_id: session_id.clone(), }; { @@ -1095,8 +1591,11 @@ fn start_unified_background_service(app_handle: AppHandle, state: Arc) .entry(target.clone()) .or_insert_with(|| VecDeque::with_capacity(1000)); target_history.push_back(result.clone()); - // Keep 24 hours worth (varies by interval, use conservative estimate) - while target_history.len() > 8640 { + let cutoff = Utc::now() - chrono::Duration::hours(24); + while target_history + .front() + .is_some_and(|oldest| oldest.timestamp < cutoff) + { target_history.pop_front(); } } @@ -1122,19 +1621,21 @@ fn start_unified_background_service(app_handle: AppHandle, state: Arc) }; let mut last_state = state.last_tray_state.lock().await; - update_tray_if_changed(&tray, &new_state, &mut last_state, &display_mode, &icons); + update_tray_if_changed( + &tray, + &new_state, + &mut last_state, + &display_mode, + &icons, + ); } } let _ = app_handle.emit("ping-update", &result); - // Rebuild the tray menu with current data (for native menu display) + // Update stable native menu items without replacing the menu. if target == &primary_target { - if let Some(tray) = app_handle.tray_by_id("main-tray") { - if let Ok(menu) = build_dynamic_menu(&app_handle, &state).await { - let _ = tray.set_menu(Some(menu)); - } - } + update_tray_menu_items(&state).await; } // Notifications for primary target only @@ -1207,49 +1708,110 @@ struct SavedData { vpn_settings: VpnProtectionSettings, #[serde(default = "default_ping_interval")] ping_interval_secs: u32, + #[serde(default = "default_speed_test_duration")] + speed_test_duration_secs: u32, + #[serde(default)] + network_sessions: VecDeque, + #[serde(default)] + network_aliases: HashMap, + #[serde(default)] + speed_tests: VecDeque, } fn default_ping_interval() -> u32 { 10 } +fn default_speed_test_duration() -> u32 { + 7 +} + /// Save history to disk -fn save_history( - history: &HashMap>, - targets: &[String], - primary_target: &str, - site_monitors: &[SiteMonitor], - vpn_settings: &VpnProtectionSettings, - ping_interval_secs: u32, -) -> Result<(), Box> { +fn save_history(data: &SavedData) -> Result<(), Box> { if let Some(data_dir) = dirs::data_dir() { let app_dir = data_dir.join("pingzilla"); std::fs::create_dir_all(&app_dir)?; let file_path = app_dir.join("history_v2.json"); - let data = SavedData { - history: history.clone(), - targets: targets.to_vec(), - primary_target: primary_target.to_string(), - notification_threshold_ms: 400, - site_monitors: site_monitors.to_vec(), - vpn_settings: vpn_settings.clone(), - ping_interval_secs, - }; let json = serde_json::to_string(&data)?; std::fs::write(file_path, json)?; } Ok(()) } +type LoadedData = ( + HashMap>, + Vec, + String, + Vec, + VpnProtectionSettings, + u32, + VecDeque, + HashMap, + VecDeque, + u32, +); + +fn attach_legacy_session( + history: &mut HashMap>, + sessions: &mut VecDeque, +) { + let legacy_pings: Vec> = history + .values() + .flat_map(|pings| pings.iter()) + .filter(|ping| ping.session_id.is_none()) + .map(|ping| ping.timestamp) + .collect(); + let Some(started_at) = legacy_pings.iter().min().copied() else { + return; + }; + let ended_at = legacy_pings.iter().max().copied(); + let legacy_id = "session-legacy-unknown".to_string(); + + if !sessions.iter().any(|session| session.id == legacy_id) { + sessions.push_front(NetworkSession { + id: legacy_id.clone(), + fingerprint: "legacy-unknown".to_string(), + public_ip: "Unknown".to_string(), + isp: None, + label: Some("Unknown previous connection".to_string()), + started_at, + ended_at, + }); + } + for ping in history.values_mut().flat_map(|pings| pings.iter_mut()) { + if ping.session_id.is_none() { + ping.session_id = Some(legacy_id.clone()); + } + } +} + +fn close_open_sessions_at_last_ping( + history: &HashMap>, + sessions: &mut VecDeque, +) { + for session in sessions + .iter_mut() + .filter(|session| session.ended_at.is_none()) + { + let last_ping = history + .values() + .flat_map(|pings| pings.iter()) + .filter(|ping| ping.session_id.as_deref() == Some(session.id.as_str())) + .map(|ping| ping.timestamp) + .max(); + session.ended_at = Some(last_ping.unwrap_or(session.started_at)); + } +} + /// Load history from disk -fn load_history() -> (HashMap>, Vec, String, Vec, VpnProtectionSettings, u32) { +fn load_history() -> LoadedData { if let Some(data_dir) = dirs::data_dir() { // Try new format first let file_path_v2 = data_dir.join("pingzilla").join("history_v2.json"); if let Ok(json) = std::fs::read_to_string(&file_path_v2) { if let Ok(data) = serde_json::from_str::(&json) { let cutoff = Utc::now() - chrono::Duration::hours(24); - let filtered_history: HashMap> = data + let mut filtered_history: HashMap> = data .history .into_iter() .map(|(target, pings)| { @@ -1258,7 +1820,33 @@ fn load_history() -> (HashMap>, Vec, String (target, filtered) }) .collect(); - return (filtered_history, data.targets, data.primary_target, data.site_monitors, data.vpn_settings, data.ping_interval_secs); + let mut sessions: VecDeque = data + .network_sessions + .into_iter() + .filter(|session| { + session.ended_at.is_none() + || session.ended_at.unwrap_or(session.started_at) > cutoff + }) + .collect(); + attach_legacy_session(&mut filtered_history, &mut sessions); + close_open_sessions_at_last_ping(&filtered_history, &mut sessions); + let speed_tests = data + .speed_tests + .into_iter() + .filter(|test| test.timestamp > cutoff) + .collect(); + return ( + filtered_history, + data.targets, + data.primary_target, + data.site_monitors, + data.vpn_settings, + data.ping_interval_secs, + sessions, + data.network_aliases, + speed_tests, + data.speed_test_duration_secs, + ); } } @@ -1277,14 +1865,38 @@ fn load_history() -> (HashMap>, Vec, String .unwrap_or_else(|| "1.1.1.1".to_string()); let mut map = HashMap::new(); map.insert(target.clone(), filtered); - return (map, vec![target.clone()], target, Vec::new(), VpnProtectionSettings::default(), 10); + let mut sessions = VecDeque::new(); + attach_legacy_session(&mut map, &mut sessions); + return ( + map, + vec![target.clone()], + target, + Vec::new(), + VpnProtectionSettings::default(), + 10, + sessions, + HashMap::new(), + VecDeque::new(), + default_speed_test_duration(), + ); } } } let mut history = HashMap::new(); history.insert("1.1.1.1".to_string(), VecDeque::new()); - (history, vec!["1.1.1.1".to_string()], "1.1.1.1".to_string(), Vec::new(), VpnProtectionSettings::default(), 10) + ( + history, + vec!["1.1.1.1".to_string()], + "1.1.1.1".to_string(), + Vec::new(), + VpnProtectionSettings::default(), + 10, + VecDeque::new(), + HashMap::new(), + VecDeque::new(), + default_speed_test_duration(), + ) } /// Register for macOS sleep/wake notifications to pause background service during sleep @@ -1320,6 +1932,9 @@ fn register_sleep_wake_observer(state: Arc) { state_wake .is_system_sleeping .store(false, std::sync::atomic::Ordering::Relaxed); + state_wake + .start_new_session_after_wake + .store(true, std::sync::atomic::Ordering::Relaxed); // Wake up the background service that's waiting wake_notify.notify_waiters(); }); @@ -1377,29 +1992,79 @@ fn shorten_url(url: &str) -> String { .to_string() } -/// Build initial menu structure (before any ping data is available) -fn build_initial_menu(app: &AppHandle) -> Result, tauri::Error> { +/// Build the tray menu and return stable handles for rows updated in place. +fn build_tray_menu( + app: &AppHandle, + site_monitors: &[SiteMonitor], +) -> Result<(Menu, TrayMenuItems), tauri::Error> { let ping_item = MenuItem::with_id(app, "ping", "⚪ Ping: ---", true, None::<&str>)?; let target_item = MenuItem::with_id(app, "target", " → loading...", true, None::<&str>)?; let stats_item = MenuItem::with_id(app, "stats", " ↓-- · ~-- · ↑--", true, None::<&str>)?; let separator1 = PredefinedMenuItem::separator(app)?; let ip_item = MenuItem::with_id(app, "ip", "📍 IP: Loading...", true, None::<&str>)?; - let separator2 = PredefinedMenuItem::separator(app)?; - let dashboard = MenuItem::with_id(app, "dashboard", "📊 Open Dashboard...", true, None::<&str>)?; - let separator3 = PredefinedMenuItem::separator(app)?; + let mut menu_items: Vec>> = vec![ + Box::new(ping_item.clone()), + Box::new(target_item.clone()), + Box::new(stats_item.clone()), + Box::new(separator1), + Box::new(ip_item.clone()), + ]; + let mut site_items = Vec::new(); + + let visible_monitors: Vec<_> = site_monitors + .iter() + .filter(|monitor| monitor.enabled) + .take(5) + .collect(); + if !visible_monitors.is_empty() { + menu_items.push(Box::new(PredefinedMenuItem::separator(app)?)); + menu_items.push(Box::new(MenuItem::with_id( + app, + "sites_header", + "🌐 Sites:", + true, + None::<&str>, + )?)); + + for monitor in visible_monitors { + let item = MenuItem::with_id( + app, + &format!("site_{}", monitor.url), + &format!(" ⏳ {}", shorten_url(&monitor.url)), + true, + None::<&str>, + )?; + menu_items.push(Box::new(item.clone())); + site_items.push((monitor.url.clone(), item)); + } + } + + menu_items.push(Box::new(PredefinedMenuItem::separator(app)?)); + let dashboard = + MenuItem::with_id(app, "dashboard", "📊 Open Dashboard...", true, None::<&str>)?; + menu_items.push(Box::new(dashboard)); + menu_items.push(Box::new(PredefinedMenuItem::separator(app)?)); let quit = MenuItem::with_id(app, "quit", "Quit PingZilla", true, None::<&str>)?; + menu_items.push(Box::new(quit)); - Menu::with_items(app, &[ - &ping_item, &target_item, &stats_item, &separator1, - &ip_item, &separator2, - &dashboard, &separator3, - &quit, - ]) + let item_refs: Vec<&dyn tauri::menu::IsMenuItem> = + menu_items.iter().map(|item| item.as_ref()).collect(); + let menu = Menu::with_items(app, &item_refs)?; + + Ok(( + menu, + TrayMenuItems { + ping: ping_item, + target: target_item, + stats: stats_item, + ip: ip_item, + sites: site_items, + }, + )) } -/// Build dynamic menu with current ping data -/// Called after each ping to update the menu with latest info -async fn build_dynamic_menu(app: &AppHandle, state: &Arc) -> Result, tauri::Error> { +/// Update dynamic tray labels while preserving the native menu and its items. +async fn update_tray_menu_items(state: &Arc) { // Get current data let primary_target = state.primary_target.lock().await.clone(); let ip_info = state.ip_info.lock().await.clone(); @@ -1439,30 +2104,35 @@ async fn build_dynamic_menu(app: &AppHandle, state: &Arc) -> Result match p.latency_ms { Some(ms) => { - let icon = if ms < 100.0 { "🟢" } else if ms < 150.0 { "🟡" } else { "🔴" }; + let icon = if ms < 100.0 { + "🟢" + } else if ms < 150.0 { + "🟡" + } else { + "🔴" + }; (format!("{:.0}ms", ms), icon) - }, + } None => ("Timeout".to_string(), "⚫"), }, None => ("---".to_string(), "⚪"), }; - let ping_item = MenuItem::with_id(app, "ping", &format!("{} Ping: {}", status_icon, ping_text), true, None::<&str>)?; - - // Target line - let target_item = MenuItem::with_id(app, "target", &format!(" → {}", primary_target), true, None::<&str>)?; + let ping_text = format!("{} Ping: {}", status_icon, ping_text); + let target_text = format!(" → {}", primary_target); // Stats - more compact let stats_text = format!( " ↓{} · ~{} · ↑{}", - min_ms.map(|v| format!("{:.0}ms", v)).unwrap_or_else(|| "--".to_string()), - avg_ms.map(|v| format!("{:.0}ms", v)).unwrap_or_else(|| "--".to_string()), - max_ms.map(|v| format!("{:.0}ms", v)).unwrap_or_else(|| "--".to_string()) + min_ms + .map(|v| format!("{:.0}ms", v)) + .unwrap_or_else(|| "--".to_string()), + avg_ms + .map(|v| format!("{:.0}ms", v)) + .unwrap_or_else(|| "--".to_string()), + max_ms + .map(|v| format!("{:.0}ms", v)) + .unwrap_or_else(|| "--".to_string()) ); - let stats_item = MenuItem::with_id(app, "stats", &stats_text, true, None::<&str>)?; - - let separator1 = PredefinedMenuItem::separator(app)?; - - // IP info let ip_text = match ip_info { Some(info) => { let flag = country_to_flag(&info.country_code); @@ -1470,49 +2140,46 @@ async fn build_dynamic_menu(app: &AppHandle, state: &Arc) -> Result "📍 IP: Loading...".to_string(), }; - let ip_item = MenuItem::with_id(app, "ip", &ip_text, true, None::<&str>)?; - - // Site monitors section - let mut menu_items: Vec>> = vec![ - Box::new(ping_item), - Box::new(target_item), - Box::new(stats_item), - Box::new(separator1), - Box::new(ip_item), - ]; - - // Add site monitors if any - if !site_statuses.is_empty() { - let sites_sep = PredefinedMenuItem::separator(app)?; - menu_items.push(Box::new(sites_sep)); - - let sites_header = MenuItem::with_id(app, "sites_header", "🌐 Sites:", true, None::<&str>)?; - menu_items.push(Box::new(sites_header)); - - for (url, status) in site_statuses.iter().take(5) { - let icon = if status.is_up { "✅" } else { "❌" }; - let latency = status.latency_ms.map(|ms| format!("({}ms)", ms as i32)).unwrap_or_default(); - let site_name = shorten_url(url); - let text = format!(" {} {} {}", icon, site_name, latency); - let site_item = MenuItem::with_id(app, &format!("site_{}", url), &text, true, None::<&str>)?; - menu_items.push(Box::new(site_item)); + if let Ok(items) = state.tray_menu_items.lock() { + if let Some(items) = items.as_ref() { + let _ = items.ping.set_text(ping_text); + let _ = items.target.set_text(target_text); + let _ = items.stats.set_text(stats_text); + let _ = items.ip.set_text(ip_text); + + for (url, item) in &items.sites { + let text = match site_statuses.get(url) { + Some(status) => { + let icon = if status.is_up { "✅" } else { "❌" }; + let latency = status + .latency_ms + .map(|ms| format!("({}ms)", ms as i32)) + .unwrap_or_default(); + format!(" {} {} {}", icon, shorten_url(url), latency) + } + None => format!(" ⏳ {}", shorten_url(url)), + }; + let _ = item.set_text(text); + } } } +} - // Action items - let separator3 = PredefinedMenuItem::separator(app)?; - let dashboard = MenuItem::with_id(app, "dashboard", "📊 Open Dashboard...", true, None::<&str>)?; - let separator4 = PredefinedMenuItem::separator(app)?; - let quit = MenuItem::with_id(app, "quit", "Quit PingZilla", true, None::<&str>)?; - - menu_items.push(Box::new(separator3)); - menu_items.push(Box::new(dashboard)); - menu_items.push(Box::new(separator4)); - menu_items.push(Box::new(quit)); - - // Build the menu - let item_refs: Vec<&dyn tauri::menu::IsMenuItem> = menu_items.iter().map(|b| b.as_ref()).collect(); - Menu::with_items(app, &item_refs) +/// Rebuild only when the configured monitor list changes structurally. +async fn rebuild_tray_menu(app: &AppHandle, state: &Arc) -> Result<(), String> { + let monitors = state.site_monitors.lock().await.clone(); + let (menu, items) = build_tray_menu(app, &monitors).map_err(|error| error.to_string())?; + let tray = app + .tray_by_id("main-tray") + .ok_or_else(|| "Tray icon is unavailable".to_string())?; + tray.set_menu(Some(menu)) + .map_err(|error| error.to_string())?; + *state + .tray_menu_items + .lock() + .map_err(|_| "Tray menu state is unavailable".to_string())? = Some(items); + update_tray_menu_items(state).await; + Ok(()) } /// Open dashboard window with full React UI (graph, stats, etc.) @@ -1529,7 +2196,7 @@ fn open_dashboard_window(app: &AppHandle) { if let Ok(window) = tauri::WebviewWindowBuilder::new( app, "dashboard", - tauri::WebviewUrl::App("index.html".into()) + tauri::WebviewUrl::App("index.html".into()), ) .title("PingZilla") .inner_size(400.0, 600.0) @@ -1551,9 +2218,161 @@ fn open_dashboard_window(app: &AppHandle) { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_pings_are_migrated_to_an_unknown_session() { + let timestamp = Utc::now(); + let mut history = HashMap::from([( + "1.1.1.1".to_string(), + VecDeque::from([PingResult { + timestamp, + latency_ms: Some(42.0), + target: "1.1.1.1".to_string(), + method: Some(PingMethod::Icmp), + session_id: None, + }]), + )]); + let mut sessions = VecDeque::new(); + + attach_legacy_session(&mut history, &mut sessions); + + assert_eq!(sessions.len(), 1); + assert_eq!( + history["1.1.1.1"][0].session_id.as_deref(), + Some("session-legacy-unknown") + ); + assert_eq!( + sessions[0].label.as_deref(), + Some("Unknown previous connection") + ); + } + + #[test] + fn percentile_uses_nearest_rank() { + let values = [10.0, 20.0, 30.0, 40.0, 50.0]; + assert_eq!(percentile(&values, 0.95), Some(50.0)); + assert_eq!(percentile(&values, 0.5), Some(30.0)); + assert_eq!(percentile(&[], 0.95), None); + } + + #[test] + fn speed_test_latency_uses_array_median() { + let raw = serde_json::json!({ + "lud_self_h2_req_resp": [900.0, 100.0, 500.0, 300.0] + }); + assert_eq!(median_json_array(&raw, "lud_self_h2_req_resp"), Some(400.0)); + } + + #[test] + fn public_ip_and_isp_form_the_network_fingerprint() { + let info = IpInfo { + ip: "203.0.113.1".to_string(), + country: "United States".to_string(), + country_code: "US".to_string(), + city: None, + isp: Some("Example ISP".to_string()), + }; + assert_eq!(network_fingerprint(&info), "203.0.113.1|Example ISP"); + } + + #[test] + fn persisted_open_session_closes_at_its_last_ping() { + let timestamp = Utc::now(); + let session_id = "session-current".to_string(); + let history = HashMap::from([( + "1.1.1.1".to_string(), + VecDeque::from([PingResult { + timestamp, + latency_ms: Some(42.0), + target: "1.1.1.1".to_string(), + method: Some(PingMethod::Icmp), + session_id: Some(session_id.clone()), + }]), + )]); + let mut sessions = VecDeque::from([NetworkSession { + id: session_id, + fingerprint: "203.0.113.1|Example ISP".to_string(), + public_ip: "203.0.113.1".to_string(), + isp: Some("Example ISP".to_string()), + label: None, + started_at: timestamp - chrono::Duration::minutes(5), + ended_at: None, + }]); + + close_open_sessions_at_last_ping(&history, &mut sessions); + + assert_eq!(sessions[0].ended_at, Some(timestamp)); + } + + #[test] + fn saved_history_without_speed_duration_defaults_to_seven_seconds() { + let saved: SavedData = serde_json::from_value(serde_json::json!({ + "history": {}, + "targets": ["1.1.1.1"], + "primary_target": "1.1.1.1", + "notification_threshold_ms": 400 + })) + .expect("legacy saved data should deserialize"); + + assert_eq!(saved.speed_test_duration_secs, 7); + } + + #[tokio::test] + async fn initial_session_claims_recent_startup_pings() { + let state = Arc::new(AppState::default()); + state + .ping_history + .lock() + .await + .get_mut("1.1.1.1") + .expect("default target history") + .push_back(PingResult { + timestamp: Utc::now(), + latency_ms: Some(42.0), + target: "1.1.1.1".to_string(), + method: Some(PingMethod::Icmp), + session_id: None, + }); + let info = IpInfo { + ip: "203.0.113.1".to_string(), + country: "United States".to_string(), + country_code: "US".to_string(), + city: None, + isp: Some("Example ISP".to_string()), + }; + + let session = ensure_network_session(&state, &info, false) + .await + .expect("initial session"); + + assert_eq!( + state.ping_history.lock().await["1.1.1.1"][0] + .session_id + .as_deref(), + Some(session.id.as_str()) + ); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let (loaded_history, loaded_targets, loaded_primary, loaded_site_monitors, loaded_vpn_settings, loaded_ping_interval) = load_history(); + let ( + loaded_history, + loaded_targets, + loaded_primary, + loaded_site_monitors, + loaded_vpn_settings, + loaded_ping_interval, + loaded_network_sessions, + loaded_network_aliases, + loaded_speed_tests, + loaded_speed_test_duration, + ) = load_history(); + let loaded_current_session = None; + let initial_site_monitors = loaded_site_monitors.clone(); let app_state = Arc::new(AppState { ping_history: Mutex::new(loaded_history), @@ -1562,6 +2381,11 @@ pub fn run() { site_monitors: Mutex::new(loaded_site_monitors), vpn_settings: Mutex::new(loaded_vpn_settings), ping_interval_secs: Mutex::new(loaded_ping_interval), + network_sessions: Mutex::new(loaded_network_sessions), + current_session_id: Mutex::new(loaded_current_session), + network_aliases: Mutex::new(loaded_network_aliases), + speed_tests: Mutex::new(loaded_speed_tests), + speed_test_duration_secs: Mutex::new(loaded_speed_test_duration), ..Default::default() }); @@ -1576,6 +2400,11 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_current_ping, get_ping_history, + get_network_sessions, + get_speed_tests, + get_session_details, + rename_network_session, + run_speed_test, get_targets, add_target, remove_target, @@ -1596,14 +2425,19 @@ pub fn run() { set_window_visible, get_ping_interval, set_ping_interval, + get_speed_test_duration, + set_speed_test_duration, ]) .setup(move |app| { - // Show in Dock - required for ping to work in sandboxed App Store builds + // Keep this menu-bar app out of the Dock. Activation policy and sandbox + // network permissions are independent; the old Regular-policy requirement + // was likely a mistaken diagnosis or workaround for an earlier issue. #[cfg(target_os = "macos")] - app.set_activation_policy(tauri::ActivationPolicy::Regular); + app.set_activation_policy(tauri::ActivationPolicy::Accessory); - // Build initial menu (will be updated dynamically on each ping) - let initial_menu = build_initial_menu(app.handle())?; + // Build the menu once; monitoring updates mutate stable item labels. + let (initial_menu, initial_menu_items) = + build_tray_menu(app.handle(), &initial_site_monitors)?; // Start with happy Godzilla icon (will update based on ping latency) let icon_bytes = include_bytes!("../icons/pingzilla_happy.png"); @@ -1616,15 +2450,17 @@ pub fn run() { .tooltip("PingZilla - Network Monitor") .menu(&initial_menu) .show_menu_on_left_click(true) // Both left and right click show menu - works in fullscreen! - .on_menu_event(|app, event| { - match event.id.as_ref() { - "dashboard" => open_dashboard_window(app), - "quit" => app.exit(0), - _ => {} - } + .on_menu_event(|app, event| match event.id.as_ref() { + "dashboard" => open_dashboard_window(app), + "quit" => request_app_exit(app), + _ => {} }) .build(app)?; + if let Ok(mut items) = app_state.tray_menu_items.lock() { + *items = Some(initial_menu_items); + } + // Battery optimization: register for sleep/wake notifications // App Nap is allowed - macOS will manage power normally register_sleep_wake_observer(app_state.clone()); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0fcde57..107010e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,13 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if std::env::args().any(|arg| arg == "--network-quality-smoke-test") { + if let Err(error) = pingzilla_lib::network_quality_smoke_test() { + eprintln!("{error}"); + std::process::exit(1); + } + return; + } + pingzilla_lib::run() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 91296e1..0bce7ec 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PingZilla", - "version": "1.3.11", + "version": "1.4.1-dev.1", "identifier": "pingzilla.pixeltowers.io", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/App.css b/src/App.css index 783602b..6e9e9b7 100644 --- a/src/App.css +++ b/src/App.css @@ -22,6 +22,7 @@ body { flex-direction: column; height: 100vh; padding: 12px; + overflow-y: auto; } /* Header */ @@ -391,6 +392,152 @@ body { margin: 0 -8px; } +.history-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: -20px 0 8px; + color: #777; + font-size: 10px; +} + +.speed-test-btn, +.back-to-live-btn, +.network-name-editor button { + border: 1px solid #3f4b5f; + border-radius: 5px; + background: #263449; + color: #dbeafe; + padding: 5px 8px; + font-size: 10px; + cursor: pointer; +} + +.speed-test-btn:disabled { + cursor: progress; + opacity: 0.6; +} + +.back-to-live-btn { + margin-left: auto; +} + +.speed-test-error { + margin-bottom: 8px; + color: #fca5a5; + font-size: 10px; +} + +.point-details { + margin-bottom: 10px; + padding: 12px; + border: 1px solid #343c49; + border-radius: 8px; + background: #22262d; +} + +.point-details-content { + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +@media (min-width: 600px) { + .point-details-content { + transform: translateX(30px); + } +} + +.point-details-time { + color: #aab3c2; + font-size: 10px; +} + +.point-details-network { + font-size: 14px; + font-weight: 650; +} + +.point-details-network-row { + display: flex; + align-items: center; + gap: 5px; + margin-top: 3px; +} + +.network-edit-btn { + border: 0; + background: transparent; + color: #8b95a5; + padding: 1px 4px; + font-size: 14px; + line-height: 1; + cursor: pointer; +} + +.network-edit-btn:hover { + color: #dbeafe; +} + +.network-name-editor { + display: flex; + gap: 6px; + margin: 9px 0; +} + +.network-name-editor input { + min-width: 0; + flex: 1; + border: 1px solid #3d4654; + border-radius: 5px; + background: #171a1f; + color: #fff; + padding: 5px 7px; + font-size: 11px; + user-select: text; + -webkit-user-select: text; +} + +.network-name-editor .network-name-cancel { + border-color: #444b55; + background: #30343a; + color: #c4c8cf; +} + +.point-details-grid { + display: grid; + grid-template-columns: 1fr auto; + gap: 5px 16px; + width: 100%; + max-width: 280px; + margin: 8px 0 0 clamp(0px, calc((100% - 280px) / 2), 30px); + color: #9ca3af; + font-size: 11px; +} + +.point-details-grid strong { + color: #f3f4f6; + font-weight: 600; + text-align: right; +} + +.speed-test-result { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 10px; + padding-top: 9px; + border-top: 1px solid #363e4a; + color: #9ca3af; + font-size: 10px; +} + +.speed-test-result strong { + color: #dbeafe; + font-size: 11px; +} + /* Footer */ .footer { text-align: center; diff --git a/src/App.tsx b/src/App.tsx index bf8fa30..638b7ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,20 @@ // ABOUTME: PingZilla React frontend - displays ping graph and current latency // ABOUTME: Supports multiple targets with tabs and statistics display -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getVersion } from "@tauri-apps/api/app"; import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart"; import { - LineChart, + ComposedChart, Line, XAxis, YAxis, ResponsiveContainer, + ReferenceDot, ReferenceLine, + Tooltip, } from "recharts"; import "./App.css"; @@ -69,6 +71,7 @@ interface PingResult { latency_ms: number | null; target: string; method: PingMethod | null; + session_id: string | null; } interface PingStatistics { @@ -80,9 +83,42 @@ interface PingStatistics { failed_pings: number; } -interface ChartData { - time: string; - latency: number | null; +interface NetworkSession { + id: string; + fingerprint: string; + public_ip: string; + isp: string | null; + label: string | null; + started_at: string; + ended_at: string | null; +} + +interface SpeedTestResult { + id: string; + timestamp: string; + session_id: string | null; + download_mbps: number; + upload_mbps: number; + loaded_latency_ms: number | null; + idle_latency_ms: number | null; + responsiveness_rpm: number | null; + interface_name: string | null; +} + +interface SessionDetails { + session: NetworkSession; + median_ms: number | null; + average_ms: number | null; + p95_ms: number | null; + packet_loss_pct: number; + total_pings: number; + latest_speed_test: SpeedTestResult | null; +} + +interface ChartRow { + timestamp: number; + ping: PingResult; + [key: string]: number | PingResult | null; } interface IpInfo { @@ -150,6 +186,49 @@ const countryCodeToFlag = (code: string): string => { .join(""); }; +const NETWORK_COLORS = [ + "#3b82f6", + "#22c55e", + "#f97316", + "#a855f7", + "#06b6d4", + "#eab308", + "#ec4899", +]; + +const stableColor = (value: string): string => { + let hash = 0; + for (const char of value) hash = (hash * 31 + char.charCodeAt(0)) | 0; + return NETWORK_COLORS[Math.abs(hash) % NETWORK_COLORS.length]; +}; + +const downsampleHistory = (history: PingResult[], maximum = 900): PingResult[] => { + if (history.length <= maximum) return history; + const bucketSize = Math.ceil(history.length / (maximum / 2)); + const sampled: PingResult[] = []; + + for (let start = 0; start < history.length; start += bucketSize) { + const bucket = history.slice(start, start + bucketSize); + const timedOut = bucket.find((ping) => ping.latency_ms === null); + const successful = bucket.filter((ping) => ping.latency_ms !== null); + const lowest = successful.reduce( + (best, ping) => !best || (ping.latency_ms ?? Infinity) < (best.latency_ms ?? Infinity) ? ping : best, + null, + ); + const highest = successful.reduce( + (best, ping) => !best || (ping.latency_ms ?? -Infinity) > (best.latency_ms ?? -Infinity) ? ping : best, + null, + ); + for (const ping of [lowest, highest, timedOut]) { + if (ping && !sampled.includes(ping)) sampled.push(ping); + } + } + + return sampled.sort( + (left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(), + ); +}; + function App() { // Detect view mode from URL params const viewMode = getViewMode(); @@ -158,9 +237,10 @@ function App() { const [activeTarget, setActiveTarget] = useState("1.1.1.1"); const [currentPings, setCurrentPings] = useState>({}); const [currentMethods, setCurrentMethods] = useState>({}); - const [histories, setHistories] = useState>({}); + const [histories, setHistories] = useState>({}); const [statistics, setStatistics] = useState(null); const [statsPeriod, setStatsPeriod] = useState(5); // minutes + const [historyViewportEnd, setHistoryViewportEnd] = useState(null); const [threshold, setThreshold] = useState(400); const [displayMode, setDisplayMode] = useState("icon_and_ping"); const [showSettings, setShowSettings] = useState(false); @@ -184,6 +264,16 @@ function App() { const [showVpnSettings, setShowVpnSettings] = useState(false); // Ping interval setting (in seconds) const [pingInterval, setPingInterval] = useState(10); + const [speedTestDuration, setSpeedTestDuration] = useState(7); + const [networkSessions, setNetworkSessions] = useState([]); + const [speedTests, setSpeedTests] = useState([]); + const [selectedPing, setSelectedPing] = useState(null); + const [sessionDetails, setSessionDetails] = useState(null); + const [networkName, setNetworkName] = useState(""); + const [editingNetworkName, setEditingNetworkName] = useState(false); + const [speedTesting, setSpeedTesting] = useState(false); + const [speedTestSecondsRemaining, setSpeedTestSecondsRemaining] = useState(0); + const [speedTestError, setSpeedTestError] = useState(null); // App version from Tauri const [appVersion, setAppVersion] = useState(""); @@ -210,21 +300,12 @@ function App() { setAppVersion(version); // Load history for each target - const newHistories: Record = {}; + const newHistories: Record = {}; const newCurrentPings: Record = {}; for (const target of loadedTargets) { const pingHistory = await invoke("get_ping_history", { target }); - const chartData = pingHistory.slice(-60).map((p) => ({ - time: new Date(p.timestamp).toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), - latency: p.latency_ms, - })); - newHistories[target] = chartData; + newHistories[target] = pingHistory; if (pingHistory.length > 0) { const last = pingHistory[pingHistory.length - 1]; @@ -235,6 +316,13 @@ function App() { setHistories(newHistories); setCurrentPings(newCurrentPings); + const [loadedSessions, loadedSpeedTests] = await Promise.all([ + invoke("get_network_sessions"), + invoke("get_speed_tests"), + ]); + setNetworkSessions(loadedSessions); + setSpeedTests(loadedSpeedTests); + // Load IP info try { const loadedIpInfo = await invoke("get_my_ip_info", {}); @@ -268,6 +356,13 @@ function App() { } catch (e) { console.error("Failed to load ping interval:", e); } + + try { + const loadedDuration = await invoke("get_speed_test_duration"); + setSpeedTestDuration(loadedDuration); + } catch (e) { + console.error("Failed to load speed test duration:", e); + } } catch (e) { console.error("Failed to load initial data:", e); } @@ -335,18 +430,10 @@ function App() { setHistories((prev) => { const targetHistory = prev[result.target] || []; - const newData = [ - ...targetHistory, - { - time: new Date(result.timestamp).toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), - latency: result.latency_ms, - }, - ].slice(-60); + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + const newData = [...targetHistory, result].filter( + (ping) => new Date(ping.timestamp).getTime() >= cutoff, + ); return { ...prev, [result.target]: newData }; }); }); @@ -356,6 +443,15 @@ function App() { }; }, []); + useEffect(() => { + const unlisten = listen("network-session-update", async () => { + setNetworkSessions(await invoke("get_network_sessions")); + }); + return () => { + unlisten.then((stop) => stop()); + }; + }, []); + // Listen for site status updates useEffect(() => { const unlisten = listen>("site-status-update", (event) => { @@ -451,11 +547,14 @@ function App() { await invoke("set_notification_threshold", { thresholdMs: threshold }); await invoke("set_display_mode", { mode: displayMode }); await invoke("set_ping_interval", { intervalSecs: pingInterval }); + await invoke("set_speed_test_duration", { + durationSecs: speedTestDuration, + }); setShowSettings(false); } catch (e) { console.error("Failed to save settings:", e); } - }, [threshold, displayMode, pingInterval]); + }, [threshold, displayMode, pingInterval, speedTestDuration]); const toggleLaunchAtLogin = useCallback(async () => { try { @@ -489,6 +588,166 @@ function App() { const currentPing = currentPings[activeTarget] ?? null; const currentMethod = currentMethods[activeTarget] ?? null; const history = histories[activeTarget] || []; + const historyWindowMs = statsPeriod * 60 * 1000; + const sessionById = useMemo( + () => new Map(networkSessions.map((session) => [session.id, session])), + [networkSessions], + ); + const chart = useMemo(() => { + const axisEnd = historyViewportEnd ?? Date.now(); + const axisStart = axisEnd - historyWindowMs; + const filtered = history.filter( + (ping) => { + const timestamp = new Date(ping.timestamp).getTime(); + return timestamp >= axisStart && timestamp <= axisEnd; + }, + ); + const sampled = downsampleHistory(filtered); + const sessionIds = Array.from( + new Set(sampled.map((ping) => ping.session_id || "unknown")), + ); + const segments = sessionIds.map((sessionId, index) => { + const session = sessionById.get(sessionId); + return { + key: `segment_${index}`, + sessionId, + color: stableColor(session?.label || session?.fingerprint || sessionId), + }; + }); + const segmentKey = new Map( + segments.map((segment) => [segment.sessionId, segment.key]), + ); + const rows: ChartRow[] = sampled.map((ping) => { + const row: ChartRow = { + timestamp: new Date(ping.timestamp).getTime(), + ping, + }; + row[segmentKey.get(ping.session_id || "unknown") || "segment_0"] = + ping.latency_ms; + return row; + }); + const markers = speedTests + .filter((test) => { + const timestamp = new Date(test.timestamp).getTime(); + return timestamp >= axisStart && sampled.length > 0; + }) + .map((test) => { + const timestamp = new Date(test.timestamp).getTime(); + const nearest = sampled.reduce((best, ping) => + Math.abs(new Date(ping.timestamp).getTime() - timestamp) < + Math.abs(new Date(best.timestamp).getTime() - timestamp) + ? ping + : best, + ); + return { + timestamp, + markerLatency: nearest.latency_ms ?? 0, + ping: nearest, + speedTest: test, + }; + }); + return { rows, segments, markers, axisStart, axisEnd }; + }, [history, historyViewportEnd, historyWindowMs, sessionById, speedTests]); + + const scrollHistory = useCallback( + (event: React.WheelEvent) => { + const horizontalDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.shiftKey + ? event.deltaY + : 0; + if (horizontalDelta === 0 || history.length === 0) return; + + event.preventDefault(); + const earliestTimestamp = new Date(history[0].timestamp).getTime(); + const earliestEnd = Math.min( + Date.now(), + earliestTimestamp + historyWindowMs, + ); + const millisecondsPerPixel = historyWindowMs / 500; + + setHistoryViewportEnd((currentEnd) => { + const nextEnd = Math.max( + earliestEnd, + Math.min(Date.now(), (currentEnd ?? Date.now()) + horizontalDelta * millisecondsPerPixel), + ); + return Date.now() - nextEnd < 1000 ? null : nextEnd; + }); + }, + [history, historyWindowMs], + ); + + useEffect(() => { + setHistoryViewportEnd(null); + }, [activeTarget, statsPeriod]); + + useEffect(() => { + if (!selectedPing?.session_id) { + setSessionDetails(null); + setNetworkName(""); + return; + } + invoke("get_session_details", { + sessionId: selectedPing.session_id, + target: activeTarget, + }) + .then((details) => { + setSessionDetails(details); + setNetworkName(details.session.label || ""); + setEditingNetworkName(false); + }) + .catch((error) => { + console.error("Failed to load session details:", error); + setSessionDetails(null); + }); + }, [activeTarget, selectedPing]); + + const renameSelectedNetwork = useCallback(async () => { + if (!sessionDetails) return; + await invoke("rename_network_session", { + sessionId: sessionDetails.session.id, + label: networkName, + }); + const sessions = await invoke("get_network_sessions"); + setNetworkSessions(sessions); + setSessionDetails(await invoke("get_session_details", { + sessionId: sessionDetails.session.id, + target: activeTarget, + })); + setEditingNetworkName(false); + }, [activeTarget, networkName, sessionDetails]); + + const runSpeedTest = useCallback(async () => { + setSpeedTestSecondsRemaining(speedTestDuration); + setSpeedTesting(true); + setSpeedTestError(null); + try { + const result = await invoke("run_speed_test"); + setSpeedTests((previous) => [...previous, result]); + if (selectedPing?.session_id === result.session_id) { + setSessionDetails(await invoke("get_session_details", { + sessionId: result.session_id, + target: activeTarget, + })); + } + } catch (error) { + setSpeedTestError(String(error)); + } finally { + setSpeedTesting(false); + } + }, [activeTarget, selectedPing, speedTestDuration]); + + useEffect(() => { + if (!speedTesting) { + return; + } + + const countdown = window.setInterval(() => { + setSpeedTestSecondsRemaining((seconds) => Math.max(0, seconds - 1)); + }, 1000); + + return () => window.clearInterval(countdown); + }, [speedTesting]); // Check if using TCP fallback (not real ICMP) const isTcpFallback = currentMethod && currentMethod !== "Icmp"; @@ -670,6 +929,21 @@ function App() { +
+ + + setSpeedTestDuration( + Math.min(30, Math.max(3, Number(event.target.value) || 7)), + ) + } + min={3} + max={30} + /> + sec +
+
- + { + const activeTimestamp = Number(state.activeLabel); + if (!Number.isFinite(activeTimestamp) || chart.rows.length === 0) { + return; + } + + const nearestRow = chart.rows.reduce((nearest, row) => + Math.abs(row.timestamp - activeTimestamp) < + Math.abs(nearest.timestamp - activeTimestamp) + ? row + : nearest, + ); + setSelectedPing(nearestRow.ping); + }} + > + new Date(timestamp).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }) + } /> - - + {selectedPing && ( + + )} + null} cursor={{ stroke: "#64748b" }} /> + {chart.segments.map((segment) => ( + + ))} + {chart.markers.map((marker) => ( + setSelectedPing(marker.ping)} + /> + ))} +
+
+ + {chart.rows.length > 0 + ? `${chart.rows.length} plotted samples` + : "No samples in this range"} + + {historyViewportEnd !== null && ( + + )} + +
+ {speedTestError &&
{speedTestError}
} + + {selectedPing && ( +
+
+
+ {new Date(selectedPing.timestamp).toLocaleString([], { + dateStyle: "medium", + timeStyle: "medium", + })} +
+
+
+ {sessionDetails?.session.label || + sessionDetails?.session.isp || + "Unknown connection"} +
+ {sessionDetails && !editingNetworkName && ( + + )} +
+ {sessionDetails && editingNetworkName && ( +
+ setNetworkName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") renameSelectedNetwork(); + if (event.key === "Escape") { + setNetworkName(sessionDetails.session.label || ""); + setEditingNetworkName(false); + } + }} + placeholder="Name this network" + maxLength={80} + autoFocus + /> + + +
+ )} +
+ Ping + + {selectedPing.latency_ms === null + ? "Timeout" + : `${Math.round(selectedPing.latency_ms)} ms`} + + Session duration + + {sessionDetails + ? `${Math.max( + 0, + Math.round( + (new Date( + sessionDetails.session.ended_at || Date.now(), + ).getTime() - + new Date(sessionDetails.session.started_at).getTime()) / + 60000, + ), + )} min` + : "—"} + + Session median + + {sessionDetails?.median_ms != null + ? `${Math.round(sessionDetails.median_ms)} ms` + : "—"} + + Session average + + {sessionDetails?.average_ms != null + ? `${Math.round(sessionDetails.average_ms)} ms` + : "—"} + + 95th percentile + + {sessionDetails?.p95_ms != null + ? `${Math.round(sessionDetails.p95_ms)} ms` + : "—"} + + Packet loss + + {sessionDetails + ? `${sessionDetails.packet_loss_pct.toFixed(1)}%` + : "—"} + +
+ {sessionDetails?.latest_speed_test && ( +
+
+ Speed test at{" "} + {new Date( + sessionDetails.latest_speed_test.timestamp, + ).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + })} +
+ + ↓ {Math.round(sessionDetails.latest_speed_test.download_mbps)} Mbps + {" "}↑ {Math.round(sessionDetails.latest_speed_test.upload_mbps)} Mbps + +
+ )} +
+
+ )} + {/* Site Monitors Section */}