diff --git a/README.md b/README.md index 98d05c4..1bdf48d 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ ssh 中継の例 `google-via-ssh 173.194.117.176 relay=X.X.X.X os=Linux` は、 ## オプション ```text --s, --scale N RTT バーグラフのスケール(ms 単位、既定 10。実行中は ↑/↓ でも変更可) +-s, --scale N RTT バーグラフのスケール(ms 単位、既定 10、小数可。実行中は ↑/↓ でも変更可) -a, --async-mode 全対象へ並列に ping を送る -b, --blink-arrow async モードで矢印を点滅させる -l, --logging DIR DIR 配下に対象ごとのログファイルを書き出す @@ -155,7 +155,8 @@ ssh 中継の例 `google-via-ssh 173.194.117.176 relay=X.X.X.X os=Linux` は、 ## 操作 ```text -↑ / ↓ RTT バーのスケールを段階的に変える(1→2→5→10→20→50→100 ms。既存のバーも再描画) +↑ / ↓ RTT バーのスケールを段階的に変える(0.01…0.1…1…10…100 ms。既存のバーも再描画) +l RTT スケールの対数表示を切り替える(linear → 底 e → 底 e²。↑/↓ の値が対数窓の左端=floor になる) p 統計値の表示精度を切り替える(ms → ms.1 → ms.2 → ms.3。常に ms 単位、小数桁数を増減) r 全対象の統計をリセットする(プログラムは動作したまま) R 設定ファイルを再読み込みする(Windows。Unix では SIGHUP を使用) @@ -191,7 +192,7 @@ split 2 一覧を 2 列で表示する(既定 1。明示した -c 列をより多く詰めたいときは、`m` / `v` / `p` で統計列を隠すと 1 列あたりの幅が縮み、より多くの列が入ります。 設定ファイルの**再読み込み**(Unix では SIGHUP、Windows では `R` キー)では、既存のエントリは統計・履歴を引き継ぎます(名前・アドレスおよび中継属性で同一性を判定)。 -`columns` の表示は設定ファイルの内容に戻りますが、`scale`・`precision`・`split`(段組み列数)は**実行中の値が保持されます**(`-s`/`-c` で起動した値やキーで調整した状態を、対象を入れ替えるだけの再読み込みで失わないため)。 +`columns` の表示は設定ファイルの内容に戻りますが、`scale`(log モードの状態を含む)・`precision`・`split`(段組み列数)は**実行中の値が保持されます**(`-s`/`-c` で起動した値やキーで調整した状態を、対象を入れ替えるだけの再読み込みで失わないため)。 端末のリサイズには自動で追随します。 ## 列(カラム)の表示 @@ -217,19 +218,24 @@ columns ADDRESS=off MIN=off MAX=off VIA=on RTT バーグラフの**スケール**と統計値の**表示精度**は、実行中にキーで切り替えられるほか、設定ファイルのディレクティブで起動時の既定を指定できます。 ```text -scale 5 RTT バー 1 段あたりの ms(↑/↓ キーと同じ。既定 10) +scale 5 RTT バー 1 段あたりの ms(↑/↓ キーと同じ。既定 10、小数可 例 scale 0.5) precision ms.1 統計値の表示精度(ms / ms.1 / ms.2 / ms.3 のいずれか。既定 ms) ``` - `scale` … RTT バーのグリフ(`▁▂▃▄▅▆▇█`)1 段が表す ms 幅です。 高速な LAN ではバーが潰れて差が見えないため、小さくすると見分けやすくなります。 - 実行中は `↑`(粗く)/ `↓`(細かく)で `1→2→5→10→20→50→100 ms` の段階を移動でき、**画面上の既存のバーも即座に再描画されます**。 + 実行中は `↑`(粗く)/ `↓`(細かく)で `0.01〜100 ms`(サブミリ秒を含む)の段階を移動でき、**画面上の既存のバーも即座に再描画されます**。 +- `log` モード … `l` キーで RTT スケールの**対数表示**を切り替えます(linear → 底 e → 底 e²)。 + 高速 LAN の低 RTT が最下段に潰れるのを避けつつ、桁をまたぐ広いレンジを 1 本のバーに収めたいときに使います。 + 対数モードでは `↑`/`↓` で設定するスケール値が**対数窓の左端(floor)**として働き、係数(底)で窓の広さが決まります(底 e で 7 段が約 3.0 桁、底 e² で約 6.1 桁)。 + 対数モード中はヘッダ表示が `RTT floor 10ms xe` のように floor と係数の表記に変わります(係数ラベルは底 e が `xe`、底 e² が `xe2`)。 + 起動時は linear(オフ)で、CLI / 設定ファイルからの初期指定はありません(実行中に `l` で切り替え)。 - `precision` … 統計列(RTT / AVG / MIN / MAX / JIT)の数値表記です。 表示単位は常に ms で、`ms`(整数)/ `ms.1`(小数 1 桁)/ `ms.2`(小数 2 桁)/ `ms.3`(小数 3 桁 = マイクロ秒相当)から選べます。 `.N` の桁数を増やすほどサブミリ秒の差が見えますが列幅も広がります。 実行中は `p` キーで循環します。 -`scale` はコマンドラインの `-s` / `--scale` と同じ設定で、**明示した `-s` が `scale` ディレクティブより優先**されます(どちらも無ければ既定 10)。 +`scale` はコマンドラインの `-s` / `--scale` と同じ設定で、**明示した `-s` が `scale` ディレクティブより優先**されます(どちらも無ければ既定 10。小数値も指定でき、サブミリ秒のスケールに対応します)。実用範囲(0.0001〜1000000 ms)を外れた極端な値・不正値(0・負数・`inf`・`nan`)は無視され、もう一方の有効な値(不正な `-s` に対しては `scale` ディレクティブ)、それも無ければ既定 10 が使われます。無視された `-s` は起動時に警告として表示されます。 `precision` にコマンドラインフラグはありません。 再読み込みをまたいだ値の保持については「[操作](#操作)」を参照してください。 diff --git a/cmd/deadman/main.go b/cmd/deadman/main.go index 28687ed..5a062dd 100644 --- a/cmd/deadman/main.go +++ b/cmd/deadman/main.go @@ -8,6 +8,7 @@ import ( "flag" "fmt" "os" + "strconv" tea "github.com/charmbracelet/bubbletea" @@ -16,29 +17,48 @@ import ( "github.com/yuu61/deadman/internal/tui" ) -// defaultScale is the default RTT bar gap in milliseconds. -const defaultScale = 10 - // version is the build version shown in the TUI title bar. It is overridden at build // time via -ldflags "-X main.version=..." (see the Makefile, which derives it from git // describe); a plain `go install`/`go build` leaves it at "dev". var version = "dev" -// resolveScale picks the effective RTT-bar scale: an explicit CLI -s wins, else a -// config "scale" directive, else defaultScale. 0 means "unset" for both inputs (the -// -s flag defaults to 0, and a missing or invalid "scale" directive leaves cfg.Scale -// at 0), so passing -s is never indistinguishable from its own default and a config -// scale can take effect. -func resolveScale(cli, cfg int) int { - if cli > 0 { +// resolveScale picks the effective RTT-bar scale: an explicit, usable CLI -s wins, else a +// config "scale" directive, else config.DefaultScale. 0 means "unset" for both inputs (the +// -s flag defaults to 0, and a missing or invalid "scale" directive leaves cfg.Scale at 0), +// so a config scale can take effect when -s is absent. +// +// Both inputs go through the same config.ValidScale predicate the "scale" directive uses, +// so a non-finite or out-of-range value (e.g. -s Inf, which flag.Float64 accepts) is +// dropped rather than flattening every bar. The rejection is not fatal and is surfaced as +// a startup warning by parseArgs (which alone can tell an explicit -s 0 from an unset one). +func resolveScale(cli, cfg float64) float64 { + if config.ValidScale(cli) { return cli } - if cfg > 0 { - return cfg - } + return config.ScaleOrDefault(cfg) +} - return defaultScale +// scaleWarning builds the operator-facing warning for an explicitly-passed but unusable -s +// value (0, negative, inf, nan, or out of range). It names the rejected value and the +// usable window — formatted from config's bounds so the prose can't drift from the +// predicate — but deliberately NOT the effective scale: the warning is rendered +// persistently in the header while the live scale can still change (↑/↓), so embedding +// "using Nms" would go stale. The footer's "RTT Scale" line always shows the value in +// effect, so the operator can read the real scale there. +// +// The rejected value uses %g while the bounds use FormatFloat 'f' on purpose, not by +// oversight: the value is arbitrary operator input that may be extreme, and %g keeps +// -s 1e-300 a compact "1e-300" rather than the ~300-digit decimal 'f' would emit (the +// very label ballooning MinScale exists to reject); the bounds are known round numbers +// that read cleanest in plain 'f' (0.0001..1000000, no "1e+06"). +func scaleWarning(cli float64) string { + return fmt.Sprintf( + "-s %g ignored: not a usable RTT-bar scale (%s..%s ms); using the configured or default scale", + cli, + strconv.FormatFloat(config.MinScale, 'f', -1, 64), + strconv.FormatFloat(config.MaxScale, 'f', -1, 64), + ) } // parseArgs parses the command line into TUI options. Flags may appear before or @@ -47,8 +67,12 @@ func resolveScale(cli, cfg int) int { // configfile intermix. func parseArgs(args []string) (tui.Options, error) { fs := flag.NewFlagSet("deadman", flag.ContinueOnError) - scale := fs.Int("s", 0, "scale of ping RTT bar gap (ms, default 10)") - fs.IntVar(scale, "scale", 0, "scale of ping RTT bar gap (ms, default 10)") + scaleUsage := fmt.Sprintf( + "scale of ping RTT bar gap (ms, default %g, decimals allowed)", + config.DefaultScale, + ) + scale := fs.Float64("s", 0, scaleUsage) + fs.Float64Var(scale, "scale", 0, scaleUsage) async := fs.Bool("a", false, "send ping asynchronously") fs.BoolVar(async, "async-mode", false, "send ping asynchronously") blink := fs.Bool("b", false, "blink arrow in async mode") @@ -89,14 +113,39 @@ func parseArgs(args []string) (tui.Options, error) { ) } - return tui.Options{ + opts := tui.Options{ Async: *async, Blink: *blink, Scale: *scale, LogDir: *logdir, Cols: *cols, ConfigPath: positional[0], - }, nil + } + + // Surface an explicitly-passed but unusable -s as a startup warning. The value is still + // dropped and the configured/default scale is used (see resolveScale); this only makes + // the silently-ignored input visible. + if scaleFlagSet(fs) && !config.ValidScale(*scale) { + opts.Warnings = append(opts.Warnings, scaleWarning(*scale)) + } + + return opts, nil +} + +// scaleFlagSet reports whether -s or --scale was actually given on the command line. It +// distinguishes an explicit `-s 0` (a rejected value worth warning about) from an unset -s +// — the *scale == 0 sentinel cannot, since both leave it at the flag's 0 default — by +// asking the flag set which flags were set rather than inspecting the parsed value. +func scaleFlagSet(fs *flag.FlagSet) bool { + set := false + + fs.Visit(func(f *flag.Flag) { + if f.Name == "s" || f.Name == "scale" { + set = true + } + }) + + return set } // resolveCols picks the newspaper-column count: an explicit CLI -c/--split wins, @@ -139,6 +188,8 @@ func main() { os.Exit(1) } + // The invalid-scale warning (if any) was already queued by parseArgs, which alone can + // tell an explicit -s from its default; here we only resolve the effective value. opts.Columns = cfg.Columns opts.Scale = resolveScale(opts.Scale, cfg.Scale) opts.Precision = cfg.Precision diff --git a/cmd/deadman/main_test.go b/cmd/deadman/main_test.go index 19283a8..d566e98 100644 --- a/cmd/deadman/main_test.go +++ b/cmd/deadman/main_test.go @@ -3,8 +3,11 @@ package main import ( "errors" "flag" + "math" "os" "testing" + + "github.com/yuu61/deadman/internal/config" ) // -h/--help must surface flag.ErrHelp so main can exit 0 (success) rather than the @@ -73,7 +76,7 @@ func TestParseArgsScaleBlinkLog(t *testing.T) { } if opts.Scale != 20 { - t.Errorf("Scale = %d, want 20", opts.Scale) + t.Errorf("Scale = %g, want 20", opts.Scale) } if !opts.Blink { @@ -85,6 +88,18 @@ func TestParseArgsScaleBlinkLog(t *testing.T) { } } +func TestParseArgsScaleFractional(t *testing.T) { + // A fractional -s is accepted (sub-ms scale); the flag parses as float64. + opts, err := parseArgs([]string{"deadman.conf", "-s", "0.5"}) + if err != nil { + t.Fatal(err) + } + + if opts.Scale != 0.5 { + t.Errorf("Scale = %g, want 0.5", opts.Scale) + } +} + func TestParseArgsMissingConfig(t *testing.T) { _, err := parseArgs([]string{"-a"}) if err == nil { @@ -107,21 +122,70 @@ func TestParseArgsRejectsMultipleConfigs(t *testing.T) { } } +// TestResolveScale pins the effective scale: an explicit, usable CLI -s wins, else the +// config "scale", else the default; any unusable value (here passed as a bare float, not +// via the flag) is dropped rather than flattening every bar. The companion warning is the +// flag layer's job — it needs to tell an explicit -s from its default — so it is covered +// by TestParseArgsScaleWarning, not here. func TestResolveScale(t *testing.T) { cases := []struct { name string - cli, cfg int - want int + cli, cfg float64 + want float64 }{ {"cli explicit wins over config", 20, 5, 20}, {"config used when cli unset", 0, 5, 5}, {"cli used when config unset", 7, 0, 7}, - {"default when both unset", 0, 0, defaultScale}, + {"sub-ms cli is honored", 0.5, 0, 0.5}, + {"non-finite cli falls back to config", math.Inf(1), 5, 5}, + {"nan cli falls back to config", math.NaN(), 5, 5}, + {"out-of-range cli falls back to default", 1e-300, 0, config.DefaultScale}, + {"non-finite cfg falls back to default", 0, math.Inf(1), config.DefaultScale}, + {"nan cfg falls back to default", 0, math.NaN(), config.DefaultScale}, + {"default when both unset", 0, 0, config.DefaultScale}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := resolveScale(c.cli, c.cfg); got != c.want { - t.Errorf("resolveScale(%d, %d) = %d, want %d", c.cli, c.cfg, got, c.want) + t.Errorf("resolveScale(%g, %g) = %g, want %g", c.cli, c.cfg, got, c.want) + } + }) + } +} + +// TestParseArgsScaleWarning checks that an explicitly-passed, unusable -s surfaces a +// startup warning while an unset or usable -s stays silent. The explicit `-s 0` case is +// the one a value sentinel cannot catch (an unset -s also parses to 0): fs.Visit +// distinguishes them, so the rejected zero is not silently dropped. The warning omits the +// effective scale on purpose (it is rendered persistently while ↑/↓ can change the live +// scale), so this only asserts presence/absence, not an embedded "using Nms". +func TestParseArgsScaleWarning(t *testing.T) { + cases := []struct { + name string + args []string + wantWarn bool + }{ + {"unset -s is silent", []string{"deadman.conf"}, false}, + {"usable -s is silent", []string{"-s", "5", "deadman.conf"}, false}, + {"explicit zero -s warns", []string{"-s", "0", "deadman.conf"}, true}, + {"explicit zero --scale warns", []string{"--scale=0", "deadman.conf"}, true}, + {"negative -s warns", []string{"-s", "-5", "deadman.conf"}, true}, + {"inf -s warns", []string{"-s", "inf", "deadman.conf"}, true}, + {"nan -s warns", []string{"-s", "nan", "deadman.conf"}, true}, + {"out-of-range -s warns", []string{"-s", "1e-300", "deadman.conf"}, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + opts, err := parseArgs(c.args) + if err != nil { + t.Fatal(err) + } + + if gotWarn := len(opts.Warnings) > 0; gotWarn != c.wantWarn { + t.Errorf( + "parseArgs(%v) warnings = %v, wantWarn=%v", + c.args, opts.Warnings, c.wantWarn, + ) } }) } diff --git a/go.sum b/go.sum index 6013b40..37690ec 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= @@ -12,12 +10,10 @@ github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dA github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -26,8 +22,6 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= @@ -48,12 +42,10 @@ github.com/prometheus-community/pro-bing v0.9.0 h1:M/zt1lL7cmbK+wm40RPS4waaiAN2E github.com/prometheus-community/pro-bing v0.9.0/go.mod h1:IBeW2ScY7sAWv4mYjH0xDDigqfe7kXeVxNdbNKdBHWY= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -64,7 +56,6 @@ golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= @@ -72,10 +63,7 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 83e0b88..c8f76d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,7 +42,7 @@ type TargetSpec struct { type Config struct { Targets []TargetSpec Columns map[string]bool // column key (upper-case) -> shown. - Scale int // RTT-bar ms-per-step from a "scale" directive; 0 = unset. + Scale float64 // RTT-bar ms-per-step (or log-mode floor) from a "scale" directive; 0 = unset. Precision string // stat-precision label from a "precision" directive; "" = unset. Cols int // newspaper-column count from a "split" directive; 0 = unset. } @@ -73,8 +73,8 @@ var directives = map[string]func(cfg *Config, args []string){ return } - n, err := strconv.Atoi(args[0]) - if err == nil && n > 0 { + n, err := strconv.ParseFloat(args[0], 64) + if err == nil && ValidScale(n) { cfg.Scale = n } }, @@ -95,6 +95,44 @@ var directives = map[string]func(cfg *Config, args []string){ }, } +// Scale bounds. A scale outside [MinScale, MaxScale] is unusable, not merely degenerate: +// below MinScale every real RTT overflows the top band (the bar is uniformly "█") and the +// shortest-decimal footer label balloons toward hundreds of characters (FormatFloat 'f' +// of 1e-300 is ~300 digits), shoving the key legend off-screen; above MaxScale every bar +// collapses to the floor. MinScale (0.1 µs) is far finer and MaxScale (1000 s) far coarser +// than any real network bar, so the usable window stays generous while nonsense is rejected. +// Exported so the CLI's rejection warning (cmd/deadman) formats the bounds from these +// constants instead of carrying a prose copy that drifts when the window changes. +const ( + MinScale = 1e-4 + MaxScale = 1e6 +) + +// ValidScale reports whether v is a usable RTT-bar scale: within [MinScale, MaxScale], +// which also excludes NaN, ±Inf, zero and negatives. It is the single predicate shared by +// the "scale" directive, the CLI -s resolver (cmd/deadman) and the TUI's startup +// normalization, so the accept/reject boundary cannot drift between them. +func ValidScale(v float64) bool { + return v >= MinScale && v <= MaxScale +} + +// DefaultScale is the RTT-bar scale used when neither the CLI -s flag nor a "scale" +// directive supplies a valid value. It sits beside ValidScale so the CLI resolver +// (cmd/deadman) and the TUI's startup normalization share one default rather than each +// hardcoding it — the same single-source rationale as ValidScale. +const DefaultScale = 10.0 + +// ScaleOrDefault returns v when it is a usable scale and DefaultScale otherwise, so the +// "invalid → default" normalization lives in one place rather than being repeated by the +// CLI resolver (resolveScale) and the TUI's New. +func ScaleOrDefault(v float64) float64 { + if ValidScale(v) { + return v + } + + return DefaultScale +} + var reSeparator = regexp.MustCompile(`^-+$`) // relayKeys is the set of attribute keys routed into a target's relay map. "netns" is diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f5d9df2..beeee5d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,10 +1,59 @@ package config import ( + "math" "strings" "testing" ) +// TestValidScale pins the usable-scale window: the [MinScale, MaxScale] bounds (and the +// implicit rejection of zero, negatives, NaN and ±Inf). Extreme finite values are +// rejected too — below MinScale the footer label balloons and every bar overflows. +func TestValidScale(t *testing.T) { + cases := []struct { + v float64 + ok bool + }{ + {10, true}, + {0.01, true}, // sub-ms ladder bottom. + {MinScale, true}, // inclusive lower bound. + {MaxScale, true}, // inclusive upper bound. + {0, false}, + {-5, false}, + {1e-300, false}, // far below MinScale: ~300-char footer label, bar all █. + {1e300, false}, // far above MaxScale. + {math.Inf(1), false}, + {math.Inf(-1), false}, + {math.NaN(), false}, + } + for _, c := range cases { + if got := ValidScale(c.v); got != c.ok { + t.Errorf("ValidScale(%g) = %v, want %v", c.v, got, c.ok) + } + } +} + +// TestScaleOrDefault confirms the shared "invalid → default" normalization: a usable +// value passes through, anything ValidScale rejects becomes DefaultScale. +func TestScaleOrDefault(t *testing.T) { + cases := []struct { + v, want float64 + }{ + {5, 5}, + {0.5, 0.5}, + {0, DefaultScale}, + {-1, DefaultScale}, + {1e-300, DefaultScale}, + {math.Inf(1), DefaultScale}, + {math.NaN(), DefaultScale}, + } + for _, c := range cases { + if got := ScaleOrDefault(c.v); got != c.want { + t.Errorf("ScaleOrDefault(%g) = %g, want %g", c.v, got, c.want) + } + } +} + const sample = `# # deadman config # @@ -218,7 +267,7 @@ func TestParseConfigDirectiveCaseInsensitive(t *testing.T) { } if cfg.Scale != 5 { - t.Errorf("Scale = %d, want 5", cfg.Scale) + t.Errorf("Scale = %g, want 5", cfg.Scale) } if cfg.Cols != 2 { @@ -403,7 +452,7 @@ func TestParseConfigScaleAndPrecision(t *testing.T) { } if cfg.Scale != 5 { - t.Errorf("Scale = %d, want 5", cfg.Scale) + t.Errorf("Scale = %g, want 5", cfg.Scale) } // The precision label is stored verbatim; the TUI validates it against its mode @@ -449,20 +498,43 @@ func TestParseConfigSplitLenient(t *testing.T) { } func TestParseConfigScaleLenient(t *testing.T) { - // A non-numeric or non-positive scale is ignored, leaving Scale unset (0) so the - // caller falls back to the CLI/default rather than aborting the parse. - for _, in := range []string{"scale abc\n", "scale -3\n", "scale 0\n", "scale\n"} { + // A non-numeric, non-positive, non-finite, or out-of-range finite scale is ignored, + // leaving Scale unset (0) so the caller falls back to the CLI/default rather than + // aborting the parse. All of these fall to the single ValidScale range predicate: + // ParseFloat accepts "inf"/"nan", but +Inf fails v <= MaxScale and NaN fails both + // range comparisons. The "1e-300"/"1e300" cases pin the directive's finite range + // bounds (MinScale/MaxScale) specifically: they are finite and positive, so a + // regression that rejected only the non-finite inputs but kept a bare n>0 (e.g. an + // explicit IsInf/IsNaN guard plus n>0) would accept them and flatten every bar — the + // nan case alone can't catch that, and inf only catches it by sign, not by magnitude. + for _, in := range []string{ + "scale abc\n", "scale -3\n", "scale 0\n", "scale\n", "scale inf\n", "scale nan\n", + "scale 1e-300\n", "scale 1e300\n", + } { cfg, err := ParseConfig(strings.NewReader(in)) if err != nil { t.Fatalf("ParseConfig(%q) error: %v", in, err) } if cfg.Scale != 0 { - t.Errorf("ParseConfig(%q): Scale = %d, want 0 (unset)", in, cfg.Scale) + t.Errorf("ParseConfig(%q): Scale = %g, want 0 (unset)", in, cfg.Scale) } } } +func TestParseConfigScaleFractional(t *testing.T) { + // A fractional scale is accepted (sub-ms resolution); the value is stored verbatim + // as a float so the result bar can bucket sub-millisecond RTTs. + cfg, err := ParseConfig(strings.NewReader("scale 0.5\nhost 1.2.3.4\n")) + if err != nil { + t.Fatal(err) + } + + if cfg.Scale != 0.5 { + t.Errorf("Scale = %g, want 0.5", cfg.Scale) + } +} + func TestParseConfigEmpty(t *testing.T) { cfg, err := ParseConfig(strings.NewReader("\n \n#only comments\n")) if err != nil { diff --git a/internal/config/prerelease_test.go b/internal/config/prerelease_test.go index 5d697a9..0bb9ddb 100644 --- a/internal/config/prerelease_test.go +++ b/internal/config/prerelease_test.go @@ -15,7 +15,7 @@ func TestParseConfigStripsBOM(t *testing.T) { } if cfg.Scale != 5 { - t.Errorf("Scale = %d, want 5 (BOM must not block the first-line directive)", cfg.Scale) + t.Errorf("Scale = %g, want 5 (BOM must not block the first-line directive)", cfg.Scale) } if len(cfg.Targets) != 1 { diff --git a/internal/monitor/target.go b/internal/monitor/target.go index ee4a41c..62669e1 100644 --- a/internal/monitor/target.go +++ b/internal/monitor/target.go @@ -4,6 +4,7 @@ package monitor import ( + "math" "slices" "strconv" "strings" @@ -226,14 +227,26 @@ func (t *Target) foldSuccessRTT(rtt float64) { t.prevRTT = rtt } -// rttBars are the block elements for ascending RTT buckets; bar i is used when -// RTT < scale*(i+1), and the full block "█" for anything above the last bucket. +// rttBars are the block elements for ascending RTT buckets. In linear mode bar i +// covers [scale*i, scale*(i+1)); in log mode it covers the geometric band +// [floor*aⁱ, floor*a^(i+1)) with a = e^lnBase and floor = scale. The full block "█" +// renders for anything at or above the last band in either mode. var rttBars = []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇"} +// boundaryEpsilon nudges the bucket index up before truncation so an RTT exactly on a +// band boundary — where the true step value is a whole number that float rounding can +// render a hair below it (rtt/scale for rtt=0.3, scale=0.1 is 2.9999999999999996, not +// 3.0) — lands in the band it opens rather than the one below. The trade-off is +// deliberate: an RTT within ~1e-9 in step space just under a boundary rounds up too, a +// sliver far below display resolution. barForStep applies it, so every caller shares +// the nudge-then-truncate protocol without re-adding it. +const boundaryEpsilon = 1e-9 + // Glyph maps a result to its result-bar character. Failures map to X/t/s; a -// success maps to a block element scaled by the RTT scale (ms per step). The TUI -// calls this at render time, so the bar re-buckets when the scale changes. -func Glyph(res ping.Result, scale int) string { +// success maps to a block element bucketed by rttGlyph (logarithmic when lnBase is +// positive, linear otherwise). The TUI calls this at render time, so the bar re-buckets +// when the scale or log factor changes. +func Glyph(res ping.Result, scale, lnBase float64) string { switch res.Code { case ping.SSHTimeout: return "t" @@ -244,23 +257,48 @@ func Glyph(res ping.Result, scale int) string { return "X" } - return rttGlyph(res.RTT, scale) + return rttGlyph(res.RTT, scale, lnBase) default: // unknown code: treat as a plain failure. return "X" } } -// rttGlyph picks the block element for a successful probe's RTT. -func rttGlyph(rtt float64, scale int) string { - step := float64(scale) - for i, bar := range rttBars { - if rtt < step*float64(i+1) { - return bar - } +// rttGlyph picks the block element for a successful probe's RTT. The bucket index is +// computed in "step space" — rtt/scale for the linear scale (lnBase <= 0) or +// ln(rtt/scale)/lnBase for the logarithmic scale (a base-e^lnBase log) — then clamped by +// barForStep. Both regimes share the half-open, left-closed band inclusion: band i +// covers [scale*i, scale*(i+1)) linearly, [scale*aⁱ, scale*a^(i+1)) logarithmically +// (a = e^lnBase). A degenerate scale or a non-positive RTT (where the ratio/log is +// undefined) falls back to the lowest bar. +func rttGlyph(rtt, scale, lnBase float64) string { + if scale <= 0 || rtt <= 0 { + return rttBars[0] + } + + step := rtt / scale + if lnBase > 0 { + step = math.Log(step) / lnBase } - return "█" + return barForStep(step) +} + +// barForStep maps a bucket index to a bar glyph, clamping to [0, len(rttBars)]: a +// negative index (RTT below the first band) or NaN yields the lowest bar, and an index +// at or above len(rttBars) overflows to "█". It applies boundaryEpsilon itself so the +// nudge-then-truncate protocol lives in one place rather than across the call boundary. +func barForStep(step float64) string { + step += boundaryEpsilon + switch { + case math.IsNaN(step) || step < 0: + return rttBars[0] + case step >= float64(len(rttBars)): + return "█" + default: + // step is in [0, len(rttBars)) here, so the truncation is a valid bar index. + return rttBars[int(step)] + } } // IsFailGlyph reports whether a glyph represents a failure (rendered in red). diff --git a/internal/monitor/target_test.go b/internal/monitor/target_test.go index 4ed66b0..cf85df3 100644 --- a/internal/monitor/target_test.go +++ b/internal/monitor/target_test.go @@ -26,7 +26,7 @@ func TestGlyph(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := Glyph(c.res, scale); got != c.want { + if got := Glyph(c.res, scale, 0); got != c.want { t.Errorf("Glyph(%+v) = %q, want %q", c.res, got, c.want) } }) @@ -69,7 +69,7 @@ func TestConsume(t *testing.T) { t.Errorf("Jit = %v, want 1.25", tg.Jit) } // History is newest-first: the last result was RTT 30, which renders ▄ at scale 10. - if got := tg.Results(1); len(got) != 1 || Glyph(got[0], 10) != "▄" { + if got := tg.Results(1); len(got) != 1 || Glyph(got[0], 10, 0) != "▄" { t.Errorf("Results(1) rendered at scale 10 = %v, want [▄]", got) } @@ -179,11 +179,122 @@ func TestResultsRescale(t *testing.T) { res := tg.Results(1)[0] // RTT 15: at scale 10 it lands in the 2nd bucket (10..20 -> ▂); at scale 5 it is // in the 4th (15 == 5*3, < 5*4 -> ▄). - if got := Glyph(res, 10); got != "▂" { + if got := Glyph(res, 10, 0); got != "▂" { t.Errorf("Glyph(RTT 15, scale 10) = %q, want ▂", got) } - if got := Glyph(res, 5); got != "▄" { + if got := Glyph(res, 5, 0); got != "▄" { t.Errorf("Glyph(RTT 15, scale 5) = %q, want ▄", got) } } + +// TestRttGlyphLinearBoundary is the regression guard for boundaryEpsilon: with a +// fractional linear scale, an RTT exactly on a band boundary like rtt=0.3, scale=0.1 +// (where 0.1*3 rounds to 0.30000000000000004 and rtt/scale to 2.9999999999999996) +// must land in the band it opens (▄), not the one below (▃). Removing boundaryEpsilon +// fails these — unlike the log boundary tests, whose floors hit exact integer steps. +func TestRttGlyphLinearBoundary(t *testing.T) { + cases := []struct { + rtt, scale float64 + want string + }{ + {0.3, 0.1, "▄"}, // 0.1*3 = 0.30000000000000004; 0.3 opens band 3. + {0.7, 0.1, "█"}, // 0.1*7 = 0.7000000000000001; 0.7 overflows the last band. + {0.2, 0.1, "▃"}, // band 2, no boundary artifact. + {0.05, 0.05, "▂"}, + } + for _, c := range cases { + res := ping.Result{Success: true, Code: ping.Success, RTT: c.rtt} + if got := Glyph(res, c.scale, 0); got != c.want { + t.Errorf("Glyph(RTT %v, scale %v, linear) = %q, want %q", c.rtt, c.scale, got, c.want) + } + } +} + +// TestRttGlyphLog covers the log-mode cases that TestRttGlyphLogBoundaryStability's +// exhaustive boundary sweep does not: an RTT below the floor, a mid-band value (not on a +// boundary), a far overflow, the zero/negative guards, and the lnBase==0 linear +// passthrough. The exact band boundaries (floor*e^(lnBase*i) → rttBars[i]) are pinned by +// the sweep across several floors and both factors, so they are not duplicated here. +func TestRttGlyphLog(t *testing.T) { + exp := math.Exp + + cases := []struct { + name string + rtt float64 + scale float64 + lnBase float64 + want string + }{ + // Below the floor (step < 0 in log space) clamps to the lowest bar. + {"below_floor", 0.5, 1.0, 1, "▁"}, + // Mid-band (not on a boundary) lands in the lower band: floor(2.5) = band 2. + {"mid_band_2_3", exp(2.5), 1.0, 1, "▃"}, + // Far past the last boundary still overflows to the full block. + {"far_overflow", 1e9, 1.0, 1, "█"}, + + // Guards: zero/negative RTT and a degenerate floor fall back to ▁. + {"zero_rtt", 0.0, 1.0, 1, "▁"}, + {"neg_floor", exp(3), -1.0, 1, "▁"}, + + // lnBase=0 stays linear even through this table. + {"linear_9_scale10", 9.0, 10.0, 0, "▁"}, + {"linear_70_scale10", 70.0, 10.0, 0, "█"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res := ping.Result{Success: true, Code: ping.Success, RTT: c.rtt} + if got := Glyph(res, c.scale, c.lnBase); got != c.want { + t.Errorf("Glyph(RTT %v, scale %v, lnBase %g) = %q, want %q", + c.rtt, c.scale, c.lnBase, got, c.want) + } + }) + } +} + +// TestRttGlyphLogBoundaryStability checks the log boundary contract across non-unit +// floors and both factors: an RTT exactly on band boundary i (floor*e^(lnBase*i)) lands +// in band i (rttBars[i]) for i in 0..len(rttBars)-1 and overflows to "█" at +// i == len(rttBars). These particular floors land on exact integer steps (so the +// boundaryEpsilon regression itself is guarded by TestRttGlyphLinearBoundary, where +// float rounding actually bites), but they pin the band semantics. +func TestRttGlyphLogBoundaryStability(t *testing.T) { + floors := []float64{0.3, 1.0, 2.5, 7.0, 0.001} + for _, floor := range floors { + for _, lnBase := range []float64{1, 2} { + for i := 0; i <= len(rttBars); i++ { + rtt := floor * math.Exp(lnBase*float64(i)) + + want := "█" + if i < len(rttBars) { + want = rttBars[i] + } + + res := ping.Result{Success: true, Code: ping.Success, RTT: rtt} + if got := Glyph(res, floor, lnBase); got != want { + t.Errorf("floor=%v lnBase=%g band=%d (rtt=%v): Glyph = %q, want %q", + floor, lnBase, i, rtt, got, want) + } + } + } + } +} + +// TestGlyphFailureCodesLogMode confirms log mode does not disturb the failure-code +// mapping: a failed probe is still X (and SSH timeout/failure t/s) whatever the log +// factor, since the switch routes before any RTT bucketing. +func TestGlyphFailureCodesLogMode(t *testing.T) { + cases := []struct { + res ping.Result + want string + }{ + {ping.Result{Code: ping.Failed}, "X"}, + {ping.Result{Code: ping.SSHTimeout}, "t"}, + {ping.Result{Code: ping.SSHFailed}, "s"}, + } + for _, c := range cases { + if got := Glyph(c.res, 1.0, 1); got != c.want { + t.Errorf("Glyph(%+v, lnBase=1) = %q, want %q", c.res, got, c.want) + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 037eef0..8741080 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -30,14 +30,15 @@ type Row struct { type Options struct { Async bool Blink bool - Scale int - Precision string // initial stat-precision label (config "precision"); "" = ms. + Scale float64 // RTT-bar ms-per-step (the window floor in log mode); 0 lets New fall back. + Precision string // initial stat-precision label (config "precision"); "" = ms. LogDir string LogWriter *monitor.LogWriter // serializes -l log writes off the Update loop; nil = no logging. ConfigPath string Version string // build version label shown in the title bar (Makefile -ldflags); "" = "dev". Columns map[string]bool // per-column visibility overrides (config file). Cols int // requested newspaper-column count (CLI -c/--split, config "split"); <=1 = single. + Warnings []string // CLI-level startup warnings (e.g. an invalid -s value), shown above the rows. } // Model is the Bubble Tea model. @@ -58,8 +59,9 @@ type Model struct { visible map[string]bool // per-column visibility (config defaults + 'm' toggle). - scale int // RTT-bar ms-per-step; adjusted live with up/down, applied when rendering glyphs. - precIdx int // index into precisionModes for the stat columns; cycled with 'p'. + scale float64 // RTT-bar ms-per-step (the window floor in log mode); adjusted live with up/down. + logIdx int // index into logFactors (0 = linear); cycled with 'l'. The selected LnBase drives Glyph. + precIdx int // index into precisionModes for the stat columns; cycled with 'p'. scrollTop int // first visible row when the list exceeds the viewport; moved with j/k/g/G/PgUp/PgDn. @@ -84,18 +86,35 @@ type Model struct { func New(specs []config.TargetSpec, opts Options) (Model, error) { rows, buildWarns := buildRows(specs, nil) + // Normalize the scale like Cols: a caller bypassing resolveScale (a test or an + // embedding) may pass a zero/degenerate Options.Scale, which would otherwise make + // rttGlyph's scale<=0 guard flatten every bar to ▁. config.ScaleOrDefault is the same + // "invalid → default" helper the CLI resolver's fallback uses, so behavior is uniform. + scale := config.ScaleOrDefault(opts.Scale) + + warnings := composeWarnings(opts.Warnings, append(startupWarnings(specs), buildWarns...)) + return Model{ rows: rows, opts: opts, - scale: opts.Scale, + scale: scale, precIdx: precisionIndex(opts.Precision), hostInfo: hostInfo(), visible: buildVisible(opts.Columns), - warnings: append(startupWarnings(specs), buildWarns...), + warnings: warnings, cols: max(opts.Cols, 1), }, nil } +// composeWarnings builds the header warning list: CLI-level diagnostics (e.g. an invalid +// -s) lead, then the per-target startup/build warnings. New and handleReload both go +// through it, so a reload that regenerates the target warnings cannot silently drop the +// CLI ones — the rejected flag they describe is still in force-by-fallback after a +// reload. Clones cli so appending never mutates the caller's slice. +func composeWarnings(cli, target []string) []string { + return append(slices.Clone(cli), target...) +} + // buildRows turns specs into rows, returning any per-target construction warnings. // Targets present in existing (matched by Key) are reused so their history/stats // survive a reload. A target whose config cannot be built (e.g. a missing required @@ -268,9 +287,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleViewKey handles the display-only keys: the MIN/MAX ('m') and structural -// HOSTNAME/ADDRESS/VIA ('h'/'a'/'v') column toggles, the RTT-bar scale (up/down), -// the stat precision ('p'), and the newspaper-column count ('['/']'). An unknown -// key leaves the model unchanged. +// HOSTNAME/ADDRESS/VIA ('h'/'a'/'v') column toggles, the RTT-bar scale (up/down) and +// its log factor ('l'), the stat precision ('p'), and the newspaper-column count +// ('['/']'). An unknown key leaves the model unchanged. // // The layout-changing keys clampScroll after recalcWidths: a stat-column toggle // shifts minColumnWidth, which can change effectiveCols and thus the vertical @@ -291,13 +310,11 @@ func (m Model) handleViewKey(msg tea.KeyMsg) Model { // ('v'). Each one's width feeds the result-bar column and the multi-column // fit, so recompute the layout and re-pin the scroll, like the MIN/MAX toggle. return m.toggleStructuralCol(msg.String()).recalcWidths().clampScroll() - case "up": - // Coarser RTT-bar scale (more ms per step). Glyphs are bucketed at render - // time, so the existing bar re-buckets with no width change. - m.scale = scaleUp(m.scale) - case "down": - // Finer RTT-bar scale. - m.scale = scaleDown(m.scale) + case "up", "down", "l": + // RTT-bar scale (up/down step the ladder) and its log factor ('l' cycles + // 0->e->e²->0). Glyphs are bucketed at render time, so the bar re-buckets + // with no width change. + return m.adjustRTTScale(msg.String()) case "p": // Cycle the stat precision (ms -> ms.1 -> ms.2 -> ms.3); the column width // changes, so recompute the result-bar layout. @@ -320,6 +337,24 @@ func (m Model) handleViewKey(msg tea.KeyMsg) Model { return m } +// adjustRTTScale handles the RTT-bar scale and log-factor keys: up/down step the +// scale ladder and 'l' cycles the log factor (0 linear -> e -> e²). None of these +// changes a column width, so the caller needs no width recalc. +func (m Model) adjustRTTScale(key string) Model { + switch key { + case "up": + m.scale = scaleUp(m.scale) + case "down": + m.scale = scaleDown(m.scale) + case "l": + m.logIdx = (m.logIdx + 1) % len(logFactors) + default: + // Unreachable: handleViewKey routes only up/down/l here. + } + + return m +} + // toggleStructuralCol flips the visibility of the structural string column bound to // the key: 'h' -> HOSTNAME, 'a' -> ADDRESS, 'v' -> VIA. RESULT has no key and is // never hidden. The caller recomputes the layout afterwards, since each column's @@ -356,15 +391,42 @@ func (m Model) adjustCols(key string) Model { return m } -// scaleSteps is the ladder the up/down keys move the RTT-bar scale through (ms). -var scaleSteps = []int{1, 2, 5, 10, 20, 50, 100} +// logFactor is one entry in the 'l'-key cycle: a legend label paired with LnBase, the +// log base carried as a value rather than implied by the slice index. Mirrors +// precisionMode (label + behavior in one row). +type logFactor struct { + Label string + // LnBase is the ln of the per-step bucket base, i.e. the divisor in + // ln(rtt/scale)/LnBase handed to monitor.Glyph: 0 = linear (no log), 1 = base e, + // 2 = base e². Because it is data, not the index, a new factor is just a row — base + // 10 would be {"x10", math.Log(10)} — with no rttGlyph change. Labels stay ASCII + // (narrow): "×"/"²" are East-Asian-ambiguous and render 2 cells on CJK terminals, + // desyncing the keys-line width from the renderer's measure. + LnBase float64 +} + +// logFactors is the single source of the 'l'-key cycle order, the log base each entry +// selects, and the floor-mode legend labels for the log factors: linear (no log), then +// base e and base e². Index 0's "lin" label is a cycle-slot placeholder, not rendered: +// keysLine labels linear mode with its own "RTT Scale" wording (vs "RTT floor … xe"), +// since linear shows ms-per-step while a log factor shows the window floor. So the table +// is the single source of the log-factor labels (index > 0); the linear row is self-named. +var logFactors = []logFactor{ + {"lin", 0}, + {"xe", 1}, + {"xe2", 2}, +} + +// scaleSteps is the ladder the up/down keys move the RTT-bar scale through (ms); it +// extends below 1ms so sub-millisecond LAN RTTs can be resolved. +var scaleSteps = []float64{0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100} // scaleUp returns the next coarser (larger-ms) rung above cur. An off-ladder cur // (e.g. from -s 7) snaps up to the nearest rung, so the live scale stays a free-form -// int while the keys move through sensible presets. At or above the top rung cur is +// value while the keys move through sensible presets. At or above the top rung cur is // preserved: Up means coarser, so it must never decrease the scale (a free-form // -s 1000 stays 1000 rather than snapping down to the ladder top). -func scaleUp(cur int) int { +func scaleUp(cur float64) float64 { for _, s := range scaleSteps { if s > cur { return s @@ -374,27 +436,30 @@ func scaleUp(cur int) int { return cur } -// scaleDown returns the next finer (smaller-ms) rung below cur, clamped at the bottom. -func scaleDown(cur int) int { +// scaleDown returns the next finer (smaller-ms) rung below cur. At or below the bottom +// rung cur is preserved: Down means finer, so it must never increase the scale — the +// mirror of scaleUp's top guard, so a free-form -s 0.005 stays 0.005 rather than being +// snapped up to the ladder bottom. +func scaleDown(cur float64) float64 { for _, s := range slices.Backward(scaleSteps) { if s < cur { return s } } - return scaleSteps[0] + return cur } // handleReload reparses the config and starts a fresh generation, so stale -// timers/results from the previous target set are ignored. The live scale/precision -// are intentionally preserved (not reset to config), unlike column visibility: -// scale has a CLI flag (-s) whose value a reload must not silently drop, and -// precision is a live, key-driven view setting. +// timers/results from the previous target set are ignored. The live +// scale/log-factor/precision are intentionally preserved (not reset to config), +// unlike column visibility: scale has a CLI flag (-s) whose value a reload must not +// silently drop, and the log factor and precision are live, key-driven view settings. func (m Model) handleReload() (tea.Model, tea.Cmd) { if r, ok := loadRows(m.opts.ConfigPath, m.rows); ok { m.rows = r.rows m.visible = buildVisible(r.columns) - m.warnings = r.warnings + m.warnings = composeWarnings(m.opts.Warnings, r.warnings) m = m.recalcWidths() m = m.clampScroll() // a shrunk target set may leave scrollTop past the end. } diff --git a/internal/tui/view.go b/internal/tui/view.go index 7ca2b49..d92f206 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strconv" "strings" "github.com/charmbracelet/lipgloss" @@ -81,7 +82,18 @@ func (m Model) View() string { // keysLine is the scale + key-legend line (line 2), factored into a method to sit // alongside the other fixed-line builders (centerTitle/titleLine/headerLine). func (m Model) keysLine() string { - s := rear + fmt.Sprintf("RTT Scale %dms.", m.scale) + // In log mode the scale value is the window floor (not ms-per-step) and the factor + // sets the width, so relabel and surface the factor up front (clipped last, like the + // cols count below). The mode is read from the selected factor's LnBase, not the + // index, so the linear/log split stays tied to the table. + f := m.logFactor() + + var s string + if f.LnBase > 0 { + s = rear + fmt.Sprintf("RTT floor %sms %s.", scaleLabel(m.scale), f.Label) + } else { + s = rear + fmt.Sprintf("RTT Scale %sms.", scaleLabel(m.scale)) + } // The effective-vs-requested column count sits near the front, before the long // key legend, so the renderer's width truncation (it clips lines, not wraps) @@ -92,13 +104,31 @@ func (m Model) keysLine() string { } s += fmt.Sprintf( - " Keys: (q)uit (r)efresh (R)eload (m)in/max (v)ia (↑/↓)scale (p)recision[%s] ([/])cols", + " Keys: (q)uit (r)efresh (R)eload (m)in/max (v)ia (↑/↓)scale (l)og (p)recision[%s] ([/])cols", m.precMode().Label, ) return s } +// logFactor returns the active RTT-scale log factor for the model, clamping an out-of- +// range logIdx to the linear entry so a Model not built through New can never panic the +// render (mirrors precMode). Callers read both its Label (legend) and LnBase (mode/base). +func (m Model) logFactor() logFactor { + if m.logIdx < 0 || m.logIdx >= len(logFactors) { + return logFactors[0] + } + + return logFactors[m.logIdx] +} + +// scaleLabel formats an RTT-bar scale for the keys line. FormatFloat with 'f' avoids +// %g's exponent switch (an explicit -s 1000000 would otherwise read "1e+06ms") while +// the shortest-precision (-1) keeps ladder values byte-identical: "0.01", "10", "100". +func scaleLabel(scale float64) string { + return strconv.FormatFloat(scale, 'f', -1, 64) +} + // scrollStatus is the one-line position indicator shown below the row window when // the list is scrolled. It is truncated to the terminal width so it always // occupies exactly one physical line, which the row-window math assumes. @@ -214,8 +244,11 @@ func (m Model) targetLine(idx int, t *monitor.Target) string { var g strings.Builder + // logIdx is loop-invariant, so resolve the log base once rather than per glyph + // (this loop runs resultWidth times per row, every frame). + lnBase := m.logFactor().LnBase for _, res := range t.Results(m.resW) { - ch := monitor.Glyph(res, m.scale) + ch := monitor.Glyph(res, m.scale, lnBase) if monitor.IsFailGlyph(ch) { g.WriteString(styleDown.Render(ch)) } else { diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index edef6f0..b6725fc 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -593,13 +593,25 @@ func TestScaleStepKeys(t *testing.T) { t.Errorf("after up,up: want scale 20\n---\n%s", out) } - // down past the bottom rung clamps at 1ms. - _, out = drive(t, m, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, - tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}) - if !strings.Contains(out, "RTT Scale 1ms") { - t.Errorf("down past the bottom should clamp at 1ms\n---\n%s", out) + // down past the bottom clamps at the sub-ms floor (scaleSteps[0]). Pressing Down + // len(scaleSteps) times reaches the floor from any rung, so the count and the + // expected floor label both track the ladder instead of hardcoded literals. + floor := scaleLabel(scaleSteps[0]) + + downs := make([]tea.Msg, len(scaleSteps)) + for i := range downs { + downs[i] = tea.KeyMsg{Type: tea.KeyDown} + } + + m, out = drive(t, m, downs...) + if !strings.Contains(out, "RTT Scale "+floor+"ms") { + t.Errorf("down past the bottom should clamp at %sms\n---\n%s", floor, out) + } + + // further down past the floor stays clamped at the floor. + _, out = drive(t, m, tea.KeyMsg{Type: tea.KeyDown}) + if !strings.Contains(out, "RTT Scale "+floor+"ms") { + t.Errorf("further down past the floor must stay at %sms\n---\n%s", floor, out) } } @@ -610,25 +622,27 @@ func TestScaleStepKeys(t *testing.T) { func TestScaleLadderBounds(t *testing.T) { cases := []struct { name string - cur int - wantUp, wantDn int + cur float64 + wantUp, wantDn float64 }{ {"within ladder", 10, 20, 5}, {"off-ladder below top", 7, 10, 5}, {"at top rung", 100, 100, 50}, {"above top rung stays put on up", 1000, 1000, 100}, // the Bug-1 regression guard. - {"at bottom rung", 1, 2, 1}, + {"at bottom rung", 0.01, 0.02, 0.01}, {"off-ladder near bottom", 3, 5, 2}, + {"sub-ms mid", 0.1, 0.2, 0.05}, + {"below bottom holds", 0.005, 0.01, 0.005}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := scaleUp(c.cur); got != c.wantUp { - t.Errorf("scaleUp(%d) = %d, want %d", c.cur, got, c.wantUp) + t.Errorf("scaleUp(%g) = %g, want %g", c.cur, got, c.wantUp) } if got := scaleDown(c.cur); got != c.wantDn { - t.Errorf("scaleDown(%d) = %d, want %d", c.cur, got, c.wantDn) + t.Errorf("scaleDown(%g) = %g, want %g", c.cur, got, c.wantDn) } }) } @@ -664,6 +678,71 @@ func TestScaleRebucketsExistingBar(t *testing.T) { } } +// TestLogModeKey cycles the 'l' key (linear -> base e -> base e² -> linear), asserting +// the footer relabels to floor mode and surfaces the factor up front (xe / xe2) and +// wraps back. This is the regression guard for the 'l' wiring and the logFactors lookup. +func TestLogModeKey(t *testing.T) { + specs := []config.TargetSpec{{Name: "h", Addr: "1.2.3.4", Relay: map[string]string{}}} + + m, err := New(specs, Options{Scale: 10}) + if err != nil { + t.Fatal(err) + } + + m, out := drive(t, m, tea.WindowSizeMsg{Width: 200, Height: 40}) + if !strings.Contains(out, "RTT Scale 10ms") { + t.Errorf("linear start should show 'RTT Scale 10ms'\n---\n%s", out) + } + + // 'l' -> base e: the footer switches to floor wording and the xe factor. + m, out = drive(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if !strings.Contains(out, "RTT floor 10ms xe.") { + t.Errorf("after l: want 'RTT floor 10ms xe'\n---\n%s", out) + } + + // 'l' -> base e²: the xe2 factor (the trailing '.' keeps this from matching xe). + m, out = drive(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if !strings.Contains(out, "RTT floor 10ms xe2.") { + t.Errorf("after l,l: want xe2\n---\n%s", out) + } + + // 'l' wraps back to linear. + _, out = drive(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if !strings.Contains(out, "RTT Scale 10ms") { + t.Errorf("after l×3: should wrap back to linear\n---\n%s", out) + } +} + +// TestLogModeRebucketsBar confirms 'l' re-buckets the on-screen bar through the View +// path (targetLine passing the selected LnBase to monitor.Glyph): the same stored RTT 50 renders ▆ on +// the linear scale 10 but ▂ in log ×e (ln(50/10)≈1.6, band 1). +func TestLogModeRebucketsBar(t *testing.T) { + specs := []config.TargetSpec{{Name: "h", Addr: "1.2.3.4", Relay: map[string]string{}}} + + m, err := New(specs, Options{Scale: 10}) + if err != nil { + t.Fatal(err) + } + + m, out := drive(t, m, + tea.WindowSizeMsg{Width: 200, Height: 40}, + pingResultMsg{ + idx: 0, + target: m.rows[0].Target, + res: ping.Result{Success: true, Code: ping.Success, RTT: 50}, + }, + ) + if !strings.Contains(out, "▆") { + t.Errorf("RTT 50 at linear scale 10 should render ▆\n---\n%s", out) + } + + // switch to log ×e: 50ms re-buckets from ▆ to ▂. + _, out = drive(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if !strings.Contains(out, "▂") || strings.Contains(out, "▆") { + t.Errorf("in log ×e, RTT 50 should re-bucket to ▂ not ▆\n---\n%s", out) + } +} + func TestPrecisionFromConfigAtStartup(t *testing.T) { specs := []config.TargetSpec{{Name: "h", Addr: "1.2.3.4", Relay: map[string]string{}}} @@ -705,19 +784,21 @@ func TestReloadPreservesScaleAndPrecision(t *testing.T) { t.Fatal(err) } - // Live: step the scale to 5 and cycle precision to ms.1. + // Live: step the scale to 5, cycle precision to ms.1, and switch to log mode. m, _ = drive(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}, + tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, ) // Reload reparses the file: columns reset to it (MIN hidden), but the live - // scale/precision are preserved (the documented, intentional asymmetry). + // scale/precision/log-factor are preserved (the documented, intentional asymmetry). _, out := drive(t, m, reloadMsg{}) - if !strings.Contains(out, "RTT Scale 5ms") || !strings.Contains(out, "(p)recision[ms.1]") { - t.Errorf("reload should preserve the live scale/precision\n---\n%s", out) + // The trailing '.' makes this fail if a reload bug advances logIdx 1->2 (xe -> xe2). + if !strings.Contains(out, "RTT floor 5ms xe.") || !strings.Contains(out, "(p)recision[ms.1]") { + t.Errorf("reload should preserve the live scale/precision/log-factor\n---\n%s", out) } if strings.Contains(out, "MIN") { @@ -1124,3 +1205,34 @@ func TestViewFitsTerminalHeight(t *testing.T) { _, sample := drive(t, m, tea.WindowSizeMsg{Width: 120, Height: 20}) t.Logf("sample render (120x20, 50 targets):\n%s", sample) } + +// TestOptionsWarningsSurface confirms a CLI-level warning passed via Options.Warnings is +// rendered (with the "! " prefix) ahead of the rows, like the per-target startup warnings, +// and that it survives a reload: the rejected flag it describes is still in force, so a +// reload that regenerates the per-target warnings must re-prepend the CLI ones. +func TestOptionsWarningsSurface(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "deadman.conf") + + err := os.WriteFile(path, []byte("h 1.2.3.4\n"), 0o600) + if err != nil { + t.Fatal(err) + } + + specs := []config.TargetSpec{{Name: "h", Addr: "1.2.3.4", Relay: map[string]string{}}} + + m, err := New(specs, Options{Scale: 10, ConfigPath: path, Warnings: []string{"cli warn xyz"}}) + if err != nil { + t.Fatal(err) + } + + m, out := drive(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + if !strings.Contains(out, "! cli warn xyz") { + t.Errorf("Options.Warnings should render with a '! ' prefix\n---\n%s", out) + } + + _, out = drive(t, m, reloadMsg{}) + if !strings.Contains(out, "! cli warn xyz") { + t.Errorf("Options.Warnings should survive a reload\n---\n%s", out) + } +}