Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 配下に対象ごとのログファイルを書き出す
Expand All @@ -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 を使用)
Expand Down Expand Up @@ -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` で起動した値やキーで調整した状態を、対象を入れ替えるだけの再読み込みで失わないため)。
端末のリサイズには自動で追随します。

## 列(カラム)の表示
Expand All @@ -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` にコマンドラインフラグはありません。
再読み込みをまたいだ値の保持については「[操作](#操作)」を参照してください。

Expand Down
87 changes: 69 additions & 18 deletions cmd/deadman/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"flag"
"fmt"
"os"
"strconv"

tea "github.com/charmbracelet/bubbletea"

Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
74 changes: 69 additions & 5 deletions cmd/deadman/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
)
}
})
}
Expand Down
Loading
Loading