Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package nbping for openSUSE:Factory checked 
in at 2026-06-29 17:32:29
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/nbping (Old)
 and      /work/SRC/openSUSE:Factory/.nbping.new.11887 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "nbping"

Mon Jun 29 17:32:29 2026 rev:4 rq:1362404 version:0.7.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/nbping/nbping.changes    2026-05-21 
18:33:57.946916622 +0200
+++ /work/SRC/openSUSE:Factory/.nbping.new.11887/nbping.changes 2026-06-29 
17:34:11.745921840 +0200
@@ -0,0 +1,7 @@
+-------------------------------------------------------------------
+Mon Jun 29 09:29:09 UTC 2026 - Martin Hauke <[email protected]>
+
+- Update to version 0.7.1:
+  * chore(release): bump version to 0.7.1
+  * feat(config): add YAML config support (#115)
+

Old:
----
  Nping-0.7.0.obscpio

New:
----
  Nping-0.7.1.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ nbping.spec ++++++
--- /var/tmp/diff_new_pack.Vm0DdM/_old  2026-06-29 17:34:12.785957422 +0200
+++ /var/tmp/diff_new_pack.Vm0DdM/_new  2026-06-29 17:34:12.785957422 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           nbping
-Version:        0.7.0
+Version:        0.7.1
 Release:        0
 Summary:        A ping tool with real-time data and visualizations
 License:        MIT

++++++ Nping-0.7.0.obscpio -> Nping-0.7.1.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/Cargo.lock new/Nping-0.7.1/Cargo.lock
--- old/Nping-0.7.0/Cargo.lock  2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/Cargo.lock  2026-06-28 14:29:41.000000000 +0200
@@ -606,7 +606,7 @@
 
 [[package]]
 name = "nbping"
-version = "0.7.0"
+version = "0.7.1"
 dependencies = [
  "anyhow",
  "clap",
@@ -616,6 +616,8 @@
  "pinger",
  "prometheus",
  "ratatui",
+ "serde",
+ "serde_yaml_ng",
  "tokio",
 ]
 
@@ -832,6 +834,49 @@
 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
 [[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_yaml_ng"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
 name = "signal-hook"
 version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index";
@@ -1071,6 +1116,12 @@
 checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
 
 [[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
+[[package]]
 name = "utf8parse"
 version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index";
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/Cargo.toml new/Nping-0.7.1/Cargo.toml
--- old/Nping-0.7.0/Cargo.toml  2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/Cargo.toml  2026-06-28 14:29:41.000000000 +0200
@@ -1,6 +1,6 @@
 [package]
 name = "nbping"
-version = "0.7.0"
+version = "0.7.1"
 edition = "2021"
 license = "MIT License"
 description = "NBping is a Ping tool developed in Rust. It supports concurrent 
Ping for multiple addresses, visual chart display, real-time data updates, and 
other features."
@@ -12,6 +12,8 @@
 tokio = { version = "1.42.0", features = ["full"] }
 pinger="2.0.0"
 anyhow="1.0.89"
+serde = { version = "1.0", features = ["derive"] }
+serde_yaml_ng = "0.10"
 prometheus = "0.13"
 hyper = { version = "1.0", features = ["full"] }
 hyper-util = { version = "0.1", features = ["tokio", "server", "http1", 
"http2"] }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/README.md new/Nping-0.7.1/README.md
--- old/Nping-0.7.0/README.md   2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/README.md   2026-06-28 14:29:41.000000000 +0200
@@ -80,11 +80,12 @@
   <TARGET>...  target IP address or hostname to ping
 
 Options:
-  -c, --count <COUNT>          Number of pings to send [default: 65535]
+      --config <CONFIG>        Path to a YAML config file (CLI flags override 
its values)
+  -c, --count <COUNT>          Number of pings to send [default: 0 = unlimited]
   -i, --interval <INTERVAL>    Interval in seconds between pings [default: 0]
-  -6, --force_ipv6             Force using IPv6
+  -6, --force_ipv6             Force using IPv6 (config-only field can also 
enable this)
   -m, --multiple <MULTIPLE>    Specify the maximum number of target addresses, 
Only works on one target address [default: 0]
-  -v, --view-type <VIEW_TYPE>  View mode graph/table/point/sparkline [default: 
graph]
+  -v, --view-type <VIEW_TYPE>  Initial view mode: graph/table/point/sparkline 
(switch at runtime with 1-4 / Tab) [default: graph]
   -o, --output <OUTPUT>        Output file to save ping results
   -h, --help                   Print help
   -V, --version                Print version
@@ -99,17 +100,60 @@
 ./nbping exporter --help
 Exporter mode for monitoring
 
-Usage: nbping exporter [OPTIONS] <TARGET>...
+Usage: nbping exporter [OPTIONS] [TARGET]...
 
 Arguments:
-  <TARGET>...  target IP addresses or hostnames to ping
+  [TARGET]...  target IP addresses or hostnames to ping
 
 Options:
+      --config <CONFIG>      Path to a YAML config file (CLI flags override 
its values)
   -i, --interval <INTERVAL>  Interval in seconds between pings [default: 1]
   -p, --port <PORT>          Prometheus metrics HTTP port [default: 9090]
+  -6, --force_ipv6           Force using IPv6 (config-only field can also 
enable this)
   -h, --help                 Print help
 ```
 
+### Configuration file
+
+Instead of passing everything on the command line, you can start NBping from a
+YAML file with `--config`:
+
+```bash
+nbping --config nbping.yaml
+```
+
+The file mirrors the command-line flags. See 
[`nbping.example.yaml`](nbping.example.yaml):
+
+```yaml
+mode: tui              # tui | exporter (default: tui)
+targets:
+  - google.com
+  - github.com
+  - apple.com
+  - baidu.com
+  - 1.1.1.1
+count: 0               # 0 = unlimited
+interval: 1            # seconds
+force_ipv6: false
+multiple: 0            # tui mode only
+view_type: graph       # graph | table | point | sparkline (tui mode only)
+# output: results.log  # tui mode only
+port: 9090             # exporter mode only
+```
+
+Notes:
+
+- **Precedence:** command-line flags override the config file, which overrides
+  built-in defaults (`CLI flag > YAML config > default`). For example,
+  `nbping --config nbping.yaml -i 1` forces a 1-second interval regardless of 
the
+  file.
+- **Mode:** the `mode` field selects TUI or exporter mode when no subcommand is
+  given. Running the explicit `nbping exporter ...` subcommand always uses
+  exporter mode.
+- **`force_ipv6`:** the `-6` flag can only turn IPv6 *on*; to disable IPv6 
while a
+  config enables it, set `force_ipv6: false` in the file.
+- Unknown keys are rejected, so typos surface as errors at startup.
+
 
 ## Acknowledgements
 Thanks to these people for their feedback and suggestions for 🏎NBping!
@@ -123,4 +167,4 @@
 | [X:@geekbb](https://x.com/geekbb/status/1875754541905539510) | 
[公众号:一飞开源](https://mp.weixin.qq.com/s/BZjr54h8dIQgzr8UW3fwOQ) | [公众号: 
开源日记](https://mp.weixin.qq.com/s/uGtkD4x_XOFyKNbIy5pHYA)
 
 ## Star History
-[![Star History 
Chart](https://api.star-history.com/svg?repos=hanshuaikang/Nping&type=Date)](https://star-history.com/#hanshuaikang/Nping&Date)
\ No newline at end of file
+[![Star History 
Chart](https://api.star-history.com/svg?repos=hanshuaikang/Nping&type=Date)](https://star-history.com/#hanshuaikang/Nping&Date)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/README_ZH.md new/Nping-0.7.1/README_ZH.md
--- old/Nping-0.7.0/README_ZH.md        2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/README_ZH.md        2026-06-28 14:29:41.000000000 +0200
@@ -77,11 +77,12 @@
   <TARGET>...  target IP address or hostname to ping
 
 Options:
-  -c, --count <COUNT>          Number of pings to send [default: 65535]
+      --config <CONFIG>        Path to a YAML config file (CLI flags override 
its values)
+  -c, --count <COUNT>          Number of pings to send [default: 0 = unlimited]
   -i, --interval <INTERVAL>    Interval in seconds between pings [default: 0]
-  -6, --force_ipv6             Force using IPv6
+  -6, --force_ipv6             Force using IPv6 (config-only field can also 
enable this)
   -m, --multiple <MULTIPLE>    Specify the maximum number of target addresses, 
Only works on one target address [default: 0]
-  -v, --view-type <VIEW_TYPE>  View mode graph/table/point/sparkline [default: 
graph]
+  -v, --view-type <VIEW_TYPE>  Initial view mode: graph/table/point/sparkline 
(switch at runtime with 1-4 / Tab) [default: graph]
   -o, --output <OUTPUT>        Output file to save ping results
   -h, --help                   Print help
   -V, --version                Print version
@@ -96,17 +97,56 @@
 ./nbping exporter --help
 Exporter mode for monitoring
 
-Usage: nbping exporter [OPTIONS] <TARGET>...
+Usage: nbping exporter [OPTIONS] [TARGET]...
 
 Arguments:
-  <TARGET>...  target IP addresses or hostnames to ping
+  [TARGET]...  target IP addresses or hostnames to ping
 
 Options:
+      --config <CONFIG>      Path to a YAML config file (CLI flags override 
its values)
   -i, --interval <INTERVAL>  Interval in seconds between pings [default: 1]
   -p, --port <PORT>          Prometheus metrics HTTP port [default: 9090]
+  -6, --force_ipv6           Force using IPv6 (config-only field can also 
enable this)
   -h, --help                 Print help
 ```
 
+### 配置文件
+
+除了命令行参数,你也可以通过 `--config` 从 YAML 文件启动 NBping:
+
+```bash
+nbping --config nbping.yaml
+```
+
+配置文件的字段与命令行参数一一对应,完整示例见 [`nbping.example.yaml`](nbping.example.yaml):
+
+```yaml
+mode: tui              # tui | exporter(默认 tui)
+targets:
+  - google.com
+  - github.com
+  - apple.com
+  - baidu.com
+  - 1.1.1.1
+count: 0               # 0 = 不限次数
+interval: 1            # 间隔秒数
+force_ipv6: false
+multiple: 0            # 仅 tui 模式
+view_type: graph       # graph | table | point | sparkline(仅 tui 模式)
+# output: results.log  # 仅 tui 模式
+port: 9090             # 仅 exporter 模式
+```
+
+说明:
+
+- **优先级:** 命令行参数 > YAML 配置 > 内置默认值。例如
+  `nbping --config nbping.yaml -i 1` 会强制使用 1 秒间隔,无视配置文件中的值。
+- **模式:** 未使用子命令时,由 `mode` 字段决定走 TUI 还是 exporter 模式;显式执行
+  `nbping exporter ...` 子命令则始终为 exporter 模式。
+- **`force_ipv6`:** `-6` 命令行 flag 只能开启 IPv6;若配置文件已开启而想关闭,请在文件中设置
+  `force_ipv6: false`。
+- 未知字段会被拒绝,因此拼写错误会在启动时直接报错。
+
 ## 致谢
 感谢这些朋友对 NBping 提出的反馈和建议。
 
@@ -119,4 +159,4 @@
 | [X:@geekbb](https://x.com/geekbb/status/1875754541905539510) | 
[公众号:一飞开源](https://mp.weixin.qq.com/s/BZjr54h8dIQgzr8UW3fwOQ) | [公众号: 
开源日记](https://mp.weixin.qq.com/s/uGtkD4x_XOFyKNbIy5pHYA)
 
 ## Star History
-[![Star History 
Chart](https://api.star-history.com/svg?repos=hanshuaikang/Nping&type=Date)](https://star-history.com/#hanshuaikang/Nping&Date)
\ No newline at end of file
+[![Star History 
Chart](https://api.star-history.com/svg?repos=hanshuaikang/Nping&type=Date)](https://star-history.com/#hanshuaikang/Nping&Date)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/nbping.example.yaml 
new/Nping-0.7.1/nbping.example.yaml
--- old/Nping-0.7.0/nbping.example.yaml 1970-01-01 01:00:00.000000000 +0100
+++ new/Nping-0.7.1/nbping.example.yaml 2026-06-28 14:29:41.000000000 +0200
@@ -0,0 +1,47 @@
+# NBping configuration file example.
+#
+# Usage:  nbping --config nbping.yaml
+#
+# Precedence: command-line flags override values set here, which in turn
+# override the built-in defaults  ->  CLI flag > YAML config > default.
+# Unknown keys are rejected, so a typo'd field name is a hard error.
+
+# Execution mode: "tui" (interactive charts, default) or "exporter" 
(Prometheus).
+mode: tui
+
+# Targets to ping (IP addresses or hostnames). Required (here or on the CLI).
+targets:
+  - google.com
+  - github.com
+  - cloudflare.com
+  - apple.com
+  - x.com
+  - baidu.com
+  - qq.com
+  - 163.com
+
+# Number of pings to send. 0 = unlimited. (default: 0)
+count: 0
+
+# Interval between pings, in seconds.
+#   tui mode:      default 0, where 0 means 500ms.
+#   exporter mode: default 1; a value <= 0 is treated as 1 (no busy-loop).
+interval: 1
+
+# Force using IPv6. Note: the -6 CLI flag can only turn this ON; to keep it off
+# while a config enables it, set this to false here. (default: false)
+force_ipv6: false
+
+# tui mode only: with a single target, resolve up to this many A/AAAA records
+# and ping them all in parallel. (default: 0 = disabled)
+multiple: 0
+
+# tui mode only: initial view. One of graph | table | point | sparkline.
+# Switch at runtime with keys 1-4 / Tab. (default: graph)
+view_type: graph
+
+# tui mode only: file to save ping results to. Must not already exist.
+# output: results.log
+
+# exporter mode only: Prometheus metrics HTTP port. (default: 9090)
+port: 9090
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/config.rs 
new/Nping-0.7.1/src/config.rs
--- old/Nping-0.7.0/src/config.rs       1970-01-01 01:00:00.000000000 +0100
+++ new/Nping-0.7.1/src/config.rs       2026-06-28 14:29:41.000000000 +0200
@@ -0,0 +1,414 @@
+//! YAML configuration file support.
+//!
+//! `nbping` can be started either from command-line flags or from a YAML file
+//! passed via `--config`. The fields here mirror the CLI flags one-to-one
+//! (a flat schema). Every field is optional so that command-line arguments can
+//! selectively override the file: the resolution order is
+//! `CLI explicit flag > YAML config > built-in default`.
+//!
+//! See `nbping.example.yaml` in the repository root for a documented sample.
+
+use anyhow::{anyhow, Context, Result};
+use serde::Deserialize;
+
+use crate::view::View;
+
+/// A configuration file deserialized from YAML.
+///
+/// All fields are `Option` so an absent field falls through to the next layer
+/// (CLI default or built-in default) instead of clobbering it. 
`deny_unknown_fields`
+/// turns typos into hard errors rather than silently ignored keys.
+#[derive(Debug, Default, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub struct FileConfig {
+    /// Execution mode: `tui` (default) or `exporter`.
+    pub mode: Option<String>,
+    /// Target IP addresses or hostnames to ping.
+    pub targets: Option<Vec<String>>,
+    /// Number of pings to send (0 = unlimited).
+    pub count: Option<usize>,
+    /// Interval between pings, in seconds.
+    pub interval: Option<i32>,
+    /// Force using IPv6.
+    pub force_ipv6: Option<bool>,
+    /// Resolve multiple A/AAAA records for a single target (tui mode only).
+    pub multiple: Option<i32>,
+    /// Initial view: graph/table/point/sparkline (tui mode only).
+    pub view_type: Option<String>,
+    /// File to save ping results to (tui mode only).
+    pub output: Option<String>,
+    /// Prometheus metrics HTTP port (exporter mode only).
+    pub port: Option<u16>,
+}
+
+/// Execution mode selected by the config file's `mode` field.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+    Tui,
+    Exporter,
+}
+
+impl FileConfig {
+    /// Read and parse a YAML config file, then validate its contents.
+    ///
+    /// Returns a friendly error (with the file path) when the file is missing,
+    /// malformed, or contains invalid values.
+    pub fn load(path: &str) -> Result<Self> {
+        let contents = std::fs::read_to_string(path)
+            .with_context(|| format!("failed to read config file: {}", path))?;
+        let config: FileConfig = serde_yaml_ng::from_str(&contents)
+            .with_context(|| format!("failed to parse config file: {}", 
path))?;
+        config.validate()?;
+        Ok(config)
+    }
+
+    /// Validate enum-like string fields and numeric ranges up front so errors
+    /// surface at startup rather than deep inside the run path.
+    fn validate(&self) -> Result<()> {
+        if let Some(mode) = &self.mode {
+            self.parsed_mode_inner(mode)?;
+        }
+        if let Some(view) = &self.view_type {
+            if View::from_str(view).is_none() {
+                return Err(anyhow!(
+                    "invalid view_type '{}' in config (expected one of: graph, 
table, point, sparkline)",
+                    view
+                ));
+            }
+        }
+        // Negative durations/counts are nonsensical and would produce negative
+        // millisecond sleeps downstream. (`count` is a `usize`, so serde 
already
+        // rejects negatives for it.)
+        if let Some(i) = self.interval {
+            if i < 0 {
+                return Err(anyhow!("interval must be >= 0, got {}", i));
+            }
+            if i > 86400 {
+                return Err(anyhow!("interval must be <= 86400 (24 h), got {}", 
i));
+            }
+        }
+        if let Some(m) = self.multiple {
+            if m < 0 {
+                return Err(anyhow!("multiple must be >= 0, got {}", m));
+            }
+        }
+        Ok(())
+    }
+
+    /// The execution mode declared by the file, defaulting to `Tui` when 
absent.
+    pub fn mode(&self) -> Result<Mode> {
+        match &self.mode {
+            Some(m) => self.parsed_mode_inner(m),
+            None => Ok(Mode::Tui),
+        }
+    }
+
+    fn parsed_mode_inner(&self, mode: &str) -> Result<Mode> {
+        match mode {
+            "tui" => Ok(Mode::Tui),
+            "exporter" => Ok(Mode::Exporter),
+            other => Err(anyhow!(
+                "invalid mode '{}' in config (expected 'tui' or 'exporter')",
+                other
+            )),
+        }
+    }
+}
+
+/// Fully-resolved settings for the default TUI mode, after merging
+/// `CLI > YAML > default`.
+#[derive(Debug, PartialEq)]
+pub struct ResolvedTui {
+    pub targets: Vec<String>,
+    pub count: usize,
+    pub interval: i32,
+    pub force_ipv6: bool,
+    pub multiple: i32,
+    pub view_type: String,
+    pub output: Option<String>,
+}
+
+/// Fully-resolved settings for exporter mode, after merging `CLI > YAML > 
default`.
+#[derive(Debug, PartialEq)]
+pub struct ResolvedExporter {
+    pub targets: Vec<String>,
+    pub interval: i32,
+    pub port: u16,
+    pub force_ipv6: bool,
+}
+
+/// Resolve the target list: CLI targets win when present, otherwise fall back 
to
+/// the config file's `targets`. The result is de-duplicated while preserving 
the
+/// original order.
+pub fn resolve_targets(cli_targets: Vec<String>, file: &FileConfig) -> 
Vec<String> {
+    let raw = if !cli_targets.is_empty() {
+        cli_targets
+    } else {
+        file.targets.clone().unwrap_or_default()
+    };
+
+    let mut seen = std::collections::HashSet::new();
+    raw.into_iter().filter(|item| seen.insert(item.clone())).collect()
+}
+
+/// Merge CLI flags and config-file values into the final TUI settings.
+/// Precedence per field: CLI explicit value > YAML config > built-in default.
+pub fn resolve_tui(
+    cli_targets: Vec<String>,
+    cli_count: Option<usize>,
+    cli_interval: Option<i32>,
+    cli_force_ipv6: bool,
+    cli_multiple: Option<i32>,
+    cli_view_type: Option<String>,
+    cli_output: Option<String>,
+    file: &FileConfig,
+) -> ResolvedTui {
+    ResolvedTui {
+        targets: resolve_targets(cli_targets, file),
+        count: cli_count.or(file.count).unwrap_or(0),
+        // 0 is meaningful in TUI mode (run_app treats it as 500ms).
+        interval: cli_interval.or(file.interval).unwrap_or(0),
+        // A bool flag can only be turned ON from the CLI; the config can also 
enable it.
+        force_ipv6: cli_force_ipv6 || file.force_ipv6.unwrap_or(false),
+        multiple: cli_multiple.or(file.multiple).unwrap_or(0),
+        view_type: cli_view_type
+            .or_else(|| file.view_type.clone())
+            .unwrap_or_else(|| "graph".to_string()),
+        output: cli_output.or_else(|| file.output.clone()),
+    }
+}
+
+/// Merge CLI flags and config-file values into the final exporter settings.
+/// Exporter mode is reachable two ways, so values from the `exporter` 
subcommand
+/// take precedence over top-level flags, then the config file, then defaults.
+pub fn resolve_exporter(
+    sub_targets: Vec<String>,
+    sub_interval: Option<i32>,
+    sub_port: Option<u16>,
+    top_targets: Vec<String>,
+    top_interval: Option<i32>,
+    cli_force_ipv6: bool,
+    file: &FileConfig,
+) -> ResolvedExporter {
+    let cli_targets = if !sub_targets.is_empty() {
+        sub_targets
+    } else {
+        top_targets
+    };
+    let interval = 
sub_interval.or(top_interval).or(file.interval).unwrap_or(1);
+    // Unlike TUI mode, exporter mode has no "0 == 500ms" semantics; a zero or
+    // negative interval would busy-loop, so fall back to 1 second.
+    let interval = if interval <= 0 { 1 } else { interval };
+    ResolvedExporter {
+        targets: resolve_targets(cli_targets, file),
+        interval,
+        port: sub_port.or(file.port).unwrap_or(9090),
+        force_ipv6: cli_force_ipv6 || file.force_ipv6.unwrap_or(false),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_full_config() {
+        let yaml = r#"
+mode: exporter
+targets:
+  - google.com
+  - 1.1.1.1
+count: 5
+interval: 2
+force_ipv6: true
+multiple: 3
+view_type: table
+output: out.log
+port: 9100
+"#;
+        let cfg: FileConfig = serde_yaml_ng::from_str(yaml).unwrap();
+        assert_eq!(cfg.mode.as_deref(), Some("exporter"));
+        assert_eq!(
+            cfg.targets,
+            Some(vec!["google.com".to_string(), "1.1.1.1".to_string()])
+        );
+        assert_eq!(cfg.count, Some(5));
+        assert_eq!(cfg.interval, Some(2));
+        assert_eq!(cfg.force_ipv6, Some(true));
+        assert_eq!(cfg.multiple, Some(3));
+        assert_eq!(cfg.view_type.as_deref(), Some("table"));
+        assert_eq!(cfg.output.as_deref(), Some("out.log"));
+        assert_eq!(cfg.port, Some(9100));
+        assert_eq!(cfg.mode().unwrap(), Mode::Exporter);
+    }
+
+    #[test]
+    fn empty_config_defaults_to_tui() {
+        let cfg: FileConfig = serde_yaml_ng::from_str("targets: 
[a.com]").unwrap();
+        assert_eq!(cfg.mode().unwrap(), Mode::Tui);
+        assert!(cfg.validate().is_ok());
+    }
+
+    #[test]
+    fn rejects_unknown_fields() {
+        // A typo'd key must be a hard error, not silently dropped.
+        let err = serde_yaml_ng::from_str::<FileConfig>("intervall: 
5").unwrap_err();
+        assert!(err.to_string().contains("unknown field"), "{}", err);
+    }
+
+    #[test]
+    fn rejects_invalid_mode() {
+        let cfg: FileConfig = serde_yaml_ng::from_str("mode: bogus").unwrap();
+        assert!(cfg.validate().is_err());
+        assert!(cfg.mode().is_err());
+    }
+
+    #[test]
+    fn rejects_invalid_view_type() {
+        let cfg: FileConfig = serde_yaml_ng::from_str("view_type: 
pie").unwrap();
+        assert!(cfg.validate().is_err());
+    }
+
+    #[test]
+    fn valid_view_types_accepted() {
+        for v in ["graph", "table", "point", "sparkline"] {
+            let cfg: FileConfig =
+                serde_yaml_ng::from_str(&format!("view_type: {}", v)).unwrap();
+            assert!(cfg.validate().is_ok(), "{} should be valid", v);
+        }
+    }
+
+    #[test]
+    fn rejects_negative_numbers() {
+        let cfg: FileConfig = serde_yaml_ng::from_str("interval: -5").unwrap();
+        assert!(cfg.validate().is_err());
+        let cfg: FileConfig = serde_yaml_ng::from_str("multiple: -1").unwrap();
+        assert!(cfg.validate().is_err());
+        // serde rejects a negative `count` (usize) before validate() even 
runs.
+        assert!(serde_yaml_ng::from_str::<FileConfig>("count: -1").is_err());
+    }
+
+    #[test]
+    fn rejects_interval_over_86400() {
+        // Values this large would overflow i32 after *1000 and produce an
+        // enormous sleep duration in the ping worker.
+        let cfg: FileConfig = serde_yaml_ng::from_str("interval: 
86401").unwrap();
+        assert!(cfg.validate().is_err());
+        // Edge: exactly 86400 is the allowed maximum.
+        let cfg: FileConfig = serde_yaml_ng::from_str("interval: 
86400").unwrap();
+        assert!(cfg.validate().is_ok());
+    }
+
+    fn file(yaml: &str) -> FileConfig {
+        serde_yaml_ng::from_str(yaml).unwrap()
+    }
+
+    // ---- TUI merge precedence ----
+
+    #[test]
+    fn tui_cli_overrides_file() {
+        let f = file("interval: 9\ncount: 100\nview_type: table");
+        let r = resolve_tui(
+            vec!["a.com".into()],
+            Some(5),           // cli count
+            Some(2),           // cli interval
+            false,
+            None,
+            Some("point".into()), // cli view_type
+            None,
+            &f,
+        );
+        assert_eq!(r.count, 5);
+        assert_eq!(r.interval, 2);
+        assert_eq!(r.view_type, "point");
+        assert_eq!(r.targets, vec!["a.com".to_string()]);
+    }
+
+    #[test]
+    fn tui_falls_back_to_file_then_default() {
+        let f = file("interval: 9\nview_type: sparkline");
+        let r = resolve_tui(vec!["a.com".into()], None, None, false, None, 
None, None, &f);
+        assert_eq!(r.interval, 9); // from file
+        assert_eq!(r.view_type, "sparkline"); // from file
+        assert_eq!(r.count, 0); // default
+
+        let empty = FileConfig::default();
+        let r = resolve_tui(vec!["a.com".into()], None, None, false, None, 
None, None, &empty);
+        assert_eq!(r.interval, 0); // tui default
+        assert_eq!(r.view_type, "graph"); // default
+        assert_eq!(r.multiple, 0);
+    }
+
+    #[test]
+    fn tui_force_ipv6_cli_or_file() {
+        let on = file("force_ipv6: true");
+        let off = FileConfig::default();
+        // CLI flag enables it.
+        assert!(resolve_tui(vec!["a".into()], None, None, true, None, None, 
None, &off).force_ipv6);
+        // File enables it even without the CLI flag.
+        assert!(resolve_tui(vec!["a".into()], None, None, false, None, None, 
None, &on).force_ipv6);
+        // Neither set -> off.
+        assert!(!resolve_tui(vec!["a".into()], None, None, false, None, None, 
None, &off).force_ipv6);
+    }
+
+    // ---- targets resolution ----
+
+    #[test]
+    fn cli_targets_win_over_file() {
+        let f = file("targets: [x.com, y.com]");
+        let r = resolve_targets(vec!["a.com".into(), "a.com".into(), 
"b.com".into()], &f);
+        // CLI wins, and is de-duplicated while preserving order.
+        assert_eq!(r, vec!["a.com".to_string(), "b.com".to_string()]);
+    }
+
+    #[test]
+    fn empty_cli_targets_fall_back_to_file() {
+        let f = file("targets: [x.com, y.com, x.com]");
+        let r = resolve_targets(vec![], &f);
+        assert_eq!(r, vec!["x.com".to_string(), "y.com".to_string()]);
+    }
+
+    // ---- exporter merge precedence (two entry paths) ----
+
+    #[test]
+    fn exporter_defaults_match_legacy() {
+        // No CLI, no file -> exporter interval defaults to 1, port to 9090.
+        let r = resolve_exporter(vec!["a".into()], None, None, vec![], None, 
false, &FileConfig::default());
+        assert_eq!(r.interval, 1);
+        assert_eq!(r.port, 9090);
+    }
+
+    #[test]
+    fn exporter_subcommand_beats_toplevel_and_file() {
+        let f = file("interval: 9\nport: 8000");
+        // subcommand -i 2, top-level -i 3, file 9 -> subcommand wins.
+        let r = resolve_exporter(vec!["a".into()], Some(2), Some(9100), 
vec![], Some(3), false, &f);
+        assert_eq!(r.interval, 2);
+        assert_eq!(r.port, 9100);
+    }
+
+    #[test]
+    fn exporter_toplevel_interval_used_when_no_subcommand() {
+        // mode:exporter via config, user passes top-level -i 3; subcommand 
absent.
+        let f = file("interval: 9");
+        let r = resolve_exporter(vec![], None, None, vec!["a".into()], 
Some(3), false, &f);
+        assert_eq!(r.interval, 3);
+        assert_eq!(r.targets, vec!["a".to_string()]);
+    }
+
+    #[test]
+    fn exporter_zero_interval_is_guarded() {
+        // A 0 interval (no "500ms" meaning in exporter mode) must not 
busy-loop.
+        let f = file("interval: 0");
+        let r = resolve_exporter(vec!["a".into()], None, None, vec![], None, 
false, &f);
+        assert_eq!(r.interval, 1);
+    }
+
+    #[test]
+    fn exporter_force_ipv6_honored() {
+        let f = file("force_ipv6: true");
+        // Now actually plumbed through (regression guard for the B1 fix).
+        assert!(resolve_exporter(vec!["a".into()], None, None, vec![], None, 
false, &f).force_ipv6);
+        assert!(resolve_exporter(vec!["a".into()], None, None, vec![], None, 
true, &FileConfig::default()).force_ipv6);
+    }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/draw.rs new/Nping-0.7.1/src/draw.rs
--- old/Nping-0.7.0/src/draw.rs 2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/src/draw.rs 2026-06-28 14:29:41.000000000 +0200
@@ -90,8 +90,10 @@
     output_file: Option<String>,
 ) -> Result<(), Box<dyn Error>> {
     let mut output_file_handle = if let Some(ref output_path) = output_file {
+        // create_new atomically fails if the file already exists, eliminating
+        // the TOCTOU window between the exists() check in main() and the open.
         match std::fs::OpenOptions::new()
-            .create(true)
+            .create_new(true)
             .write(true)
             .open(output_path)
         {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/main.rs new/Nping-0.7.1/src/main.rs
--- old/Nping-0.7.0/src/main.rs 2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/src/main.rs 2026-06-28 14:29:41.000000000 +0200
@@ -7,9 +7,10 @@
 mod data_processor;
 mod exporter;
 mod view;
+mod config;
 
 use clap::{Parser, Subcommand};
-use std::collections::{HashSet, VecDeque};
+use std::collections::VecDeque;
 use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
 use std::sync::{Arc, Mutex};
 use std::time::Duration;
@@ -23,6 +24,7 @@
 use crate::network::send_ping;
 use crate::exporter::{PrometheusMetrics, http_server, spawn_ping_workers};
 use crate::view::View;
+use crate::config::{FileConfig, Mode};
 
 struct RawModeGuard;
 
@@ -41,7 +43,7 @@
 
 #[derive(Parser, Debug)]
 #[command(
-    version = "v0.7.0",
+    version = "v0.7.1",
     author = "hanshuaikang<https://github.com/hanshuaikang>",
     about = "🏎  NBping mean NB Ping, A Ping Tool in Rust with Real-Time Data 
and Visualizations"
 )]
@@ -50,27 +52,30 @@
     #[arg(help = "target IP address or hostname to ping", required = false)]
     target: Vec<String>,
 
+    /// Path to a YAML config file (CLI flags override values from this file)
+    #[arg(long, global = true, help = "Path to a YAML config file (CLI flags 
override its values)")]
+    config: Option<String>,
+
     /// Number of pings to send, when count is 0, the maximum number of pings 
per address is calculated
-    #[arg(short, long, default_value_t = 0, help = "Number of pings to send")]
-    count: usize,
+    #[arg(short, long, help = "Number of pings to send [default: 0 = 
unlimited]")]
+    count: Option<usize>,
 
     /// Interval in seconds between pings
-    #[arg(short, long, default_value_t = 0, help = "Interval in seconds 
between pings")]
-    interval: i32,
+    #[arg(short, long, help = "Interval in seconds between pings [default: 
0]")]
+    interval: Option<i32>,
 
-    #[clap(long = "force_ipv6", default_value_t = false, short = '6', help = 
"Force using IPv6")]
+    #[clap(long = "force_ipv6", default_value_t = false, short = '6', global = 
true, help = "Force using IPv6 (config-only field can also enable this)")]
     pub force_ipv6: bool,
 
     #[arg(
         short = 'm',
         long,
-        default_value_t = 0,
-        help = "Specify the maximum number of target addresses, Only works on 
one target address"
+        help = "Specify the maximum number of target addresses, Only works on 
one target address [default: 0]"
     )]
-    multiple: i32,
+    multiple: Option<i32>,
 
-    #[arg(short, long, default_value = "graph", help = "Initial view mode: 
graph/table/point/sparkline (switch at runtime with 1-4 / Tab)")]
-    view_type: String,
+    #[arg(short, long, help = "Initial view mode: graph/table/point/sparkline 
(switch at runtime with 1-4 / Tab) [default: graph]")]
+    view_type: Option<String>,
 
     #[arg(short = 'o', long = "output", help = "Output file to save ping 
results")]
     output: Option<String>,
@@ -84,16 +89,16 @@
     /// Exporter mode for monitoring
     Exporter {
         /// Target IP addresses or hostnames to ping
-        #[arg(help = "target IP addresses or hostnames to ping", required = 
true)]
+        #[arg(help = "target IP addresses or hostnames to ping", required = 
false)]
         target: Vec<String>,
 
         /// Interval in seconds between pings
-        #[arg(short, long, default_value_t = 1, help = "Interval in seconds 
between pings")]
-        interval: i32,
+        #[arg(short, long, help = "Interval in seconds between pings [default: 
1]")]
+        interval: Option<i32>,
 
         /// Prometheus metrics HTTP port
-        #[arg(short, long, default_value_t = 9090, help = "Prometheus metrics 
HTTP port")]
-        port: u16,
+        #[arg(short, long, help = "Prometheus metrics HTTP port [default: 
9090]")]
+        port: Option<u16>,
     },
 }
 
@@ -102,69 +107,117 @@
     // parse command line arguments
     let args = Args::parse();
 
-    match args.command {
-        Some(Commands::Exporter { target, interval, port }) => {
-            let worker_threads = (target.len() + 1).max(1);
-            // Create tokio runtime for Exporter mode
-            let rt = Builder::new_multi_thread()
-                .worker_threads(worker_threads)
-                .enable_all()
-                .build()?;
-
-            let res = rt.block_on(run_exporter_mode(target, interval, port));
-
-            // if error print error message and exit
-            if let Err(err) = res {
-                eprintln!("{}", err);
+    // Load the YAML config file when one was provided. Values from it act as a
+    // baseline that command-line flags override (CLI > YAML > built-in 
default).
+    let file_cfg = match &args.config {
+        Some(path) => match FileConfig::load(path) {
+            Ok(cfg) => cfg,
+            Err(err) => {
+                eprintln!("Error: {:#}", err);
                 std::process::exit(1);
             }
         },
-        None => {
-            // Default ping mode
-            if args.target.is_empty() {
-                eprintln!("Error: target IP address or hostname is required");
+        None => FileConfig::default(),
+    };
+
+    // Decide which mode to run:
+    //  - an explicit `exporter` subcommand always wins
+    //  - otherwise the config file's `mode` field decides (defaulting to tui)
+    let run_exporter = match &args.command {
+        Some(Commands::Exporter { .. }) => true,
+        None => match file_cfg.mode() {
+            Ok(mode) => mode == Mode::Exporter,
+            Err(err) => {
+                eprintln!("Error: {:#}", err);
                 std::process::exit(1);
             }
+        },
+    };
 
-            // set Ctrl+C and q and esc to exit
-            let running = Arc::new(Mutex::new(true));
+    if run_exporter {
+        // Exporter mode is reachable two ways: the `exporter` subcommand 
(with its
+        // own target/interval/port) or top-level flags alongside `--config 
mode: exporter`.
+        let (sub_target, sub_interval, sub_port) = match args.command {
+            Some(Commands::Exporter { target, interval, port }) => (target, 
interval, port),
+            None => (Vec::new(), None, None),
+        };
+        let cfg = config::resolve_exporter(
+            sub_target,
+            sub_interval,
+            sub_port,
+            args.target,
+            args.interval,
+            args.force_ipv6,
+            &file_cfg,
+        );
+        if cfg.targets.is_empty() {
+            eprintln!("Error: at least one target is required (via CLI or 
config 'targets')");
+            std::process::exit(1);
+        }
 
-            // check output file
-            if let Some(ref output_path) = args.output {
-                if std::path::Path::new(output_path).exists() {
-                    eprintln!("Output file already exists: {}", output_path);
-                    std::process::exit(1);
-                }
-            }
+        let worker_threads = (cfg.targets.len() + 1).max(1);
+        let rt = Builder::new_multi_thread()
+            .worker_threads(worker_threads)
+            .enable_all()
+            .build()?;
+
+        let res = rt.block_on(run_exporter_mode(cfg.targets, cfg.interval, 
cfg.port, cfg.force_ipv6));
+        if let Err(err) = res {
+            eprintln!("{}", err);
+            std::process::exit(1);
+        }
+    } else {
+        // Default TUI ping mode. Resolve every value as CLI > YAML > default.
+        let cfg = config::resolve_tui(
+            args.target,
+            args.count,
+            args.interval,
+            args.force_ipv6,
+            args.multiple,
+            args.view_type,
+            args.output,
+            &file_cfg,
+        );
+        if cfg.targets.is_empty() {
+            eprintln!("Error: at least one target is required (via CLI or 
config 'targets')");
+            std::process::exit(1);
+        }
+        // YAML interval is validated in config.rs; guard the CLI path here.
+        if cfg.interval < 0 {
+            eprintln!("Error: interval must be >= 0, got {}", cfg.interval);
+            std::process::exit(1);
+        }
 
-            // after de-duplication, the original order is still preserved
-            let mut seen = HashSet::new();
-            let targets: Vec<String> = args.target.into_iter()
-                .filter(|item| seen.insert(item.clone()))
-                .collect();
-
-            // Calculate worker threads based on IP count
-            let ip_count = if targets.len() == 1 && args.multiple > 0 {
-                args.multiple as usize
-            } else {
-                targets.len()
-            };
-            let worker_threads = (ip_count +  1).max(1);
-
-            // Create tokio runtime with specific worker thread count
-            let rt = Builder::new_multi_thread()
-                .worker_threads(worker_threads)
-                .enable_all()
-                .build()?;
-
-            let res = rt.block_on(run_app(targets, args.count, args.interval, 
running.clone(), args.force_ipv6, args.multiple, args.view_type, args.output));
-
-            // if error print error message and exit
-            if let Err(err) = res {
-                eprintln!("{}", err);
+        // set Ctrl+C and q and esc to exit
+        let running = Arc::new(Mutex::new(true));
+
+        // check output file
+        if let Some(ref output_path) = cfg.output {
+            if std::path::Path::new(output_path).exists() {
+                eprintln!("Output file already exists: {}", output_path);
                 std::process::exit(1);
             }
         }
+
+        // Calculate worker threads based on IP count
+        let ip_count = if cfg.targets.len() == 1 && cfg.multiple > 0 {
+            cfg.multiple as usize
+        } else {
+            cfg.targets.len()
+        };
+        let worker_threads = (ip_count + 1).max(1);
+
+        // Create tokio runtime with specific worker thread count
+        let rt = Builder::new_multi_thread()
+            .worker_threads(worker_threads)
+            .enable_all()
+            .build()?;
+
+        let res = rt.block_on(run_app(cfg.targets, cfg.count, cfg.interval, 
running.clone(), cfg.force_ipv6, cfg.multiple, cfg.view_type, cfg.output));
+        if let Err(err) = res {
+            eprintln!("{}", err);
+            std::process::exit(1);
+        }
     }
     Ok(())
 }
@@ -239,7 +292,8 @@
 
     let errs = Arc::new(Mutex::new(Vec::new()));
 
-    let interval = if interval == 0 { 500 } else { interval * 1000 };
+    // saturating_mul prevents i32 overflow for very large interval values.
+    let interval = if interval == 0 { 500 } else { 
interval.saturating_mul(1000) };
     let mut tasks = Vec::new();
 
     for (i, ip) in ips.iter().enumerate() {
@@ -304,6 +358,7 @@
     targets: Vec<String>,
     interval: i32,
     port: u16,
+    force_ipv6: bool,
 ) -> Result<(), Box<dyn std::error::Error>> {
     // 创建 Prometheus metrics 收集器
     let prometheus_metrics = Arc::new(PrometheusMetrics::new()?);
@@ -333,12 +388,8 @@
         }
     });
 
-    // 去重目标地址,同时保留原始顺序
-    let mut seen = std::collections::HashSet::new();
-    let targets: Vec<String> = targets.into_iter()
-        .filter(|item| seen.insert(item.clone()))
-        .collect();
-
+    // Targets are already de-duplicated and non-empty by the time they reach
+    // here (see config::resolve_exporter), but guard defensively.
     if targets.is_empty() {
         return Err("No valid targets provided".into());
     }
@@ -346,7 +397,7 @@
     // 解析目标地址为 IP 地址
     let mut target_pairs = Vec::new();
     for target in &targets {
-        let ip = network::get_host_ipaddr(target, false)?;
+        let ip = network::get_host_ipaddr(target, force_ipv6)?;
         target_pairs.push((target.clone(), ip));
     }
 
@@ -378,7 +429,7 @@
         ).await
     });
 
-    let interval_ms = interval * 1000;
+    let interval_ms = interval.saturating_mul(1000);
     let ping_threads = spawn_ping_workers(
         target_pairs,
         Duration::from_millis(interval_ms as u64),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/ui/layout.rs 
new/Nping-0.7.1/src/ui/layout.rs
--- old/Nping-0.7.0/src/ui/layout.rs    2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/src/ui/layout.rs    2026-06-28 14:29:41.000000000 +0200
@@ -61,11 +61,10 @@
             let alive = if d.received > 0 { a + 1 } else { a };
             (r + d.received, t + d.timeout, s + avg, alive)
         });
-    let global_avg = if total_targets > 0 {
-        sum_avg / total_targets as f64
-    } else {
-        0.0
-    };
+    // Divide by alive (targets with at least one successful ping), not 
total_targets.
+    // Dead/unreachable targets contribute 0.0 to sum_avg, so including them 
in the
+    // denominator makes the header average misleadingly low.
+    let global_avg = if alive > 0 { sum_avg / alive as f64 } else { 0.0 };
     let global_loss = calculate_loss_pkg(total_timeout, total_recv);
     let beat = HEARTBEAT[(ctx.tick as usize) % HEARTBEAT.len()];
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/ui/point.rs 
new/Nping-0.7.1/src/ui/point.rs
--- old/Nping-0.7.0/src/ui/point.rs     2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/src/ui/point.rs     2026-06-28 14:29:41.000000000 +0200
@@ -16,7 +16,9 @@
     theme: &Theme,
 ) {
     let ip_height: u16 = 5;
-    let total_height = (ip_data.len() as u16) * ip_height + 2;
+    // Use u32 arithmetic to avoid u16 overflow when ip_data.len() > 13106,
+    // then clamp to u16::MAX before converting.
+    let total_height = ((ip_data.len() as u32) * ip_height as u32 + 
2).min(u16::MAX as u32) as u16;
 
     let chunks = Layout::default()
         .direction(Direction::Vertical)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/ui/sparkline.rs 
new/Nping-0.7.1/src/ui/sparkline.rs
--- old/Nping-0.7.0/src/ui/sparkline.rs 2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/src/ui/sparkline.rs 2026-06-28 14:29:41.000000000 +0200
@@ -6,7 +6,7 @@
 
 use crate::ip_data::IpData;
 use crate::ui::theme::Theme;
-use crate::ui::utils::{calculate_avg_rtt, calculate_jitter, 
calculate_loss_pkg, calculate_p95, draw_errors_section};
+use crate::ui::utils::{calculate_avg_rtt, calculate_jitter, 
calculate_loss_pkg, calculate_p95, draw_errors_section, rtt_to_spark_unit};
 
 pub fn draw_sparkline_view(
     f: &mut Frame,
@@ -16,12 +16,15 @@
     theme: &Theme,
 ) {
     let n = ip_data.len().max(1);
+    // 8 rows per cell: 2 for borders + 1 for the info line + 5 for the 
sparkline.
+    // 5 sparkline rows = 40 sub-levels, enough to make sub-ms bars (▂▃▄▅) 
visible.
+    const CELL_HEIGHT: u16 = 8;
 
     let chunks = Layout::default()
         .direction(Direction::Vertical)
         .constraints(
             std::iter::once(Constraint::Length(1))
-                .chain(std::iter::repeat_n(Constraint::Length(5), n))
+                .chain(std::iter::repeat_n(Constraint::Length(CELL_HEIGHT), n))
                 .chain([Constraint::Min(6)])
                 .collect::<Vec<_>>(),
         )
@@ -113,17 +116,31 @@
         let width = spark_rect.width as usize;
         let rtts_len = ip.rtts.len();
         let skip = rtts_len.saturating_sub(width);
-        let spark_data: Vec<u64> = ip
+        // Scale RTT to 1/100 ms units so sub-millisecond RTTs (e.g. 
LAN/localhost
+        // at 0.3ms) are not truncated to 0 by integer cast. Without this, 
every
+        // healthy bar with rtt < 1ms maps to 0 and the sparkline looks empty
+        // (indistinguishable from 100% packet loss). Timeouts stay at 0 
(blank gap).
+        let raw: Vec<u64> = ip
             .rtts
             .iter()
             .skip(skip)
-            .map(|&rtt| if rtt < 0.0 { 0 } else { rtt as u64 })
+            .map(|&rtt| rtt_to_spark_unit(rtt))
             .collect();
+        // Right-align: pad the front with zeros so the most recent bar is 
always
+        // at the right edge. The blank leading region is visually distinct 
from
+        // timeout gaps (timeouts also show as 0/blank) but only appears while 
the
+        // history is shorter than the widget width — i.e. early in a session.
+        let spark_data: Vec<u64> = if raw.len() < width {
+            let mut padded = vec![0u64; width - raw.len()];
+            padded.extend_from_slice(&raw);
+            padded
+        } else {
+            raw
+        };
 
-        // Cap auto-scale at P95 so a single outlier (e.g. a one-off
-        // 1200ms spike) doesn't pull every typical RTT down to level 0.
-        // Values above the cap clip to a full bar, which highlights spikes.
-        let spark_max = (p95 as u64).max(1);
+        // Cap auto-scale at P95 (same unit) so a single outlier doesn't crush
+        // the typical bars. Values above the cap clip to full bar height.
+        let spark_max = ((p95 * 100.0).round() as u64).max(1);
 
         let spark = Sparkline::default()
             .data(&spark_data)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/ui/utils.rs 
new/Nping-0.7.1/src/ui/utils.rs
--- old/Nping-0.7.0/src/ui/utils.rs     2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/src/ui/utils.rs     2026-06-28 14:29:41.000000000 +0200
@@ -21,10 +21,13 @@
 }
 
 pub fn calculate_jitter(rtt: &VecDeque<f64>) -> f64 {
-    if rtt.len() > 1 {
-        let diffs: Vec<f64> = rtt.iter().zip(rtt.iter().skip(1)).map(|(y1, 
y2)| (y2 - y1).abs()).collect();
-        let sum: f64 = diffs.iter().sum();
-        sum / diffs.len() as f64
+    // Filter out -1.0 timeout sentinels before computing adjacent differences.
+    // Without this, a timeout between two 1ms pings inflates jitter by ~2ms 
per
+    // sentinel (|1.0 - (-1.0)| = 2.0, |-1.0 - 1.0| = 2.0).
+    let valid: Vec<f64> = rtt.iter().copied().filter(|&r| r >= 0.0).collect();
+    if valid.len() > 1 {
+        let sum: f64 = valid.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
+        sum / (valid.len() - 1) as f64
     } else {
         0.0
     }
@@ -42,6 +45,13 @@
     valid[idx]
 }
 
+/// Convert an RTT float (ms) to a scaled u64 for sparkline display.
+/// Multiplies by 100 to preserve 0.01 ms precision — without this, sub-ms
+/// RTTs (e.g. 0.3 ms on LAN) would be truncated to 0 and appear as blank bars.
+pub fn rtt_to_spark_unit(rtt: f64) -> u64 {
+    if rtt < 0.0 { 0 } else { (rtt * 100.0).round() as u64 }
+}
+
 pub fn calculate_loss_pkg(timeout: usize, received: usize) -> f64 {
     if timeout > 0 {
         (timeout as f64 / (received as f64 + timeout as f64)) * 100.0
@@ -83,3 +93,70 @@
         .wrap(Wrap { trim: true });
     f.render_widget(errors_paragraph, area);
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn deque(v: &[f64]) -> VecDeque<f64> {
+        v.iter().copied().collect()
+    }
+
+    // ---- calculate_jitter ----
+
+    #[test]
+    fn jitter_ignores_timeout_sentinels() {
+        // [1.0, -1.0, 2.0]: real jitter between 1.0→2.0 = 1.0 ms.
+        // Before the fix, the sentinel inflated this to (2.0 + 3.0) / 2 = 2.5 
ms.
+        let j = calculate_jitter(&deque(&[1.0, -1.0, 2.0]));
+        assert!((j - 1.0).abs() < 1e-9, "jitter={}", j);
+    }
+
+    #[test]
+    fn jitter_all_sentinels_returns_zero() {
+        assert_eq!(calculate_jitter(&deque(&[-1.0, -1.0, -1.0])), 0.0);
+    }
+
+    #[test]
+    fn jitter_single_valid_returns_zero() {
+        assert_eq!(calculate_jitter(&deque(&[-1.0, 5.0, -1.0])), 0.0);
+    }
+
+    #[test]
+    fn jitter_no_sentinels_unchanged() {
+        // [1.0, 3.0, 2.0] → diffs [2.0, 1.0] → jitter 1.5
+        let j = calculate_jitter(&deque(&[1.0, 3.0, 2.0]));
+        assert!((j - 1.5).abs() < 1e-9, "jitter={}", j);
+    }
+
+    // ---- rtt_to_spark_unit ----
+
+    #[test]
+    fn spark_unit_preserves_submillisecond() {
+        assert_eq!(rtt_to_spark_unit(0.3), 30);  // 0.3 ms → 30 units
+        assert_eq!(rtt_to_spark_unit(0.01), 1);  // 0.01 ms → 1 unit (not 0)
+        assert_eq!(rtt_to_spark_unit(1.0), 100); // 1.0 ms → 100 units
+        assert_eq!(rtt_to_spark_unit(10.5), 1050);
+    }
+
+    #[test]
+    fn spark_unit_timeout_sentinel_is_blank() {
+        assert_eq!(rtt_to_spark_unit(-1.0), 0);
+        assert_eq!(rtt_to_spark_unit(-0.001), 0);
+    }
+
+    // ---- calculate_p95 ----
+
+    #[test]
+    fn p95_filters_sentinels() {
+        // Only the three valid values should be considered.
+        let p = calculate_p95(&deque(&[-1.0, 1.0, 2.0, 3.0, -1.0]));
+        // 95th percentile of [1.0, 2.0, 3.0] = 3.0
+        assert!((p - 3.0).abs() < 1e-9, "p95={}", p);
+    }
+
+    #[test]
+    fn p95_all_sentinels_returns_zero() {
+        assert_eq!(calculate_p95(&deque(&[-1.0, -1.0])), 0.0);
+    }
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/Nping-0.7.0/src/view.rs new/Nping-0.7.1/src/view.rs
--- old/Nping-0.7.0/src/view.rs 2026-05-20 17:10:30.000000000 +0200
+++ new/Nping-0.7.1/src/view.rs 2026-06-28 14:29:41.000000000 +0200
@@ -1,5 +1,7 @@
 use std::sync::atomic::{AtomicU8, Ordering};
 
+const VIEW_COUNT: u8 = 4;
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 #[repr(u8)]
 pub enum View {
@@ -40,7 +42,7 @@
     }
 
     pub fn next(self) -> Self {
-        Self::from_u8((self as u8 + 1) % 4)
+        Self::from_u8((self as u8 + 1) % VIEW_COUNT)
     }
 }
 

++++++ Nping.obsinfo ++++++
--- /var/tmp/diff_new_pack.Vm0DdM/_old  2026-06-29 17:34:13.177970834 +0200
+++ /var/tmp/diff_new_pack.Vm0DdM/_new  2026-06-29 17:34:13.185971108 +0200
@@ -1,5 +1,5 @@
 name: Nping
-version: 0.7.0
-mtime: 1779289830
-commit: 1c88a7e89e8727aa83ec76c33e4e59d7d059ece7
+version: 0.7.1
+mtime: 1782649781
+commit: a0371cc7210f9a6e15e1a314b80419acb01d3e12
 

++++++ _service ++++++
--- /var/tmp/diff_new_pack.Vm0DdM/_old  2026-06-29 17:34:13.221972340 +0200
+++ /var/tmp/diff_new_pack.Vm0DdM/_new  2026-06-29 17:34:13.225972477 +0200
@@ -3,7 +3,7 @@
     <param name="url">https://github.com/hanshuaikang/Nping</param>
     <param name="versionformat">@PARENT_TAG@</param>
     <param name="scm">git</param>
-    <param name="revision">v0.7.0</param>
+    <param name="revision">v0.7.1</param>
     <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param>
     <param name="changesgenerate">enable</param>
   </service>

++++++ _servicedata ++++++
--- /var/tmp/diff_new_pack.Vm0DdM/_old  2026-06-29 17:34:13.261973709 +0200
+++ /var/tmp/diff_new_pack.Vm0DdM/_new  2026-06-29 17:34:13.265973846 +0200
@@ -1,6 +1,6 @@
 <servicedata>
 <service name="tar_scm">
                 <param name="url">https://github.com/hanshuaikang/Nping</param>
-              <param 
name="changesrevision">1c88a7e89e8727aa83ec76c33e4e59d7d059ece7</param></service></servicedata>
+              <param 
name="changesrevision">a0371cc7210f9a6e15e1a314b80419acb01d3e12</param></service></servicedata>
 (No newline at EOF)
 

++++++ vendor.tar.zst ++++++
/work/SRC/openSUSE:Factory/nbping/vendor.tar.zst 
/work/SRC/openSUSE:Factory/.nbping.new.11887/vendor.tar.zst differ: char 7, 
line 1

Reply via email to