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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,22 @@ mping batch --count 5 192.168.1.0/29

# Use with external host lists
mping batch -f hosts.txt --count 5

# Machine-readable output (json / csv) for scripts and dashboards
mping batch -o json --count 5 10.0.0.0/29 | jq '.[] | select(.loss_percent > 0) | .host'
mping batch -o csv --count 10 -f hosts.txt > result.csv

# Health check: exit with status 2 if any target's loss exceeds 20%
mping batch --max-loss 20 -f hosts.txt || alert "some targets are unhealthy"
```

Batch mode details:
- `--count N` sends exactly N probes per target.
- `-o, --output` selects `table` (default), `json`, or `csv`. RTT fields are in milliseconds and include `jitter_ms` (standard deviation of RTT, same as `mdev` in iputils ping).
- `--max-loss PCT` turns batch into a health check. Exit codes: `0` = OK, `1` = usage/config error, `2` = at least one target exceeded the threshold (violating targets are listed on stderr).
- Progress dots are written to stderr only when it is a terminal, so stdout stays clean for pipes. The failure beep is disabled in batch mode.
- `-I, --interface` selects the source interface, same as the interactive mode.

## Target Expansion

mping supports powerful target expansion syntax for monitoring large numbers of hosts efficiently:
Expand Down
5 changes: 5 additions & 0 deletions cmd/mping/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"errors"
"fmt"
"os"
"runtime"
Expand Down Expand Up @@ -31,6 +32,10 @@ func Execute() {
cmd.SetOut(os.Stderr)
cmd.SetErr(os.Stderr)
cmd.Println(err)
var exitErr *command.ExitError
if errors.As(err, &exitErr) {
os.Exit(exitErr.Code)
}
os.Exit(1)
}
}
Expand Down
109 changes: 92 additions & 17 deletions internal/command/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"

"github.com/servak/mping/internal/config"
Expand All @@ -23,13 +25,18 @@ func NewPingBatchCmd() *cobra.Command {
Example: `mping batch 1.1.1.1 8.8.8.8
mping batch icmpv6:google.com
mping batch http://google.com
mping batch dns://8.8.8.8/google.com`,
mping batch dns://8.8.8.8/google.com
mping batch -o json 10.0.0.0/29 | jq '.[] | select(.loss_percent > 0)'
mping batch --max-loss 20 -f hosts.txt || echo "some targets are unhealthy"`,
RunE: func(cmd *cobra.Command, args []string) error {
flags := cmd.Flags()
counter, err := flags.GetInt("count")
if err != nil {
return err
}
if counter <= 0 {
return errors.New("count must be greater than zero")
}
interval, err := flags.GetInt("interval")
if err != nil {
return err
Expand All @@ -53,6 +60,26 @@ mping batch dns://8.8.8.8/google.com`,
if err != nil {
return err
}
sourceInterface, err := flags.GetString("interface")
if err != nil {
return err
}
output, err := flags.GetString("output")
if err != nil {
return err
}
if err := shared.ValidateOutputFormat(output); err != nil {
return err
}
maxLoss := -1.0 // disabled unless explicitly set
if flags.Changed("max-loss") {
if maxLoss, err = flags.GetFloat64("max-loss"); err != nil {
return err
}
if maxLoss < 0 || maxLoss > 100 {
return fmt.Errorf("max-loss must be between 0 and 100, got %v", maxLoss)
}
}

hosts := ExpandTargets(args, filename)
if len(hosts) == 0 {
Expand All @@ -65,12 +92,16 @@ mping batch dns://8.8.8.8/google.com`,
if cfg == nil {
return fmt.Errorf("failed to load config %q: %w", path, err)
}
cfg.SetSourceInterface(sourceInterface)
_interval := time.Duration(interval) * time.Millisecond
_timeout := time.Duration(timeout) * time.Millisecond

// Create ProbeManager and MetricsManager
probeManager := prober.NewProbeManager(cfg.Prober, cfg.Default)
metricsManager := stats.NewMetricsManager()
// The terminal bell would corrupt machine-readable output and is
// pointless in non-interactive runs.
metricsManager.SetBeepEnabled(false)

// Add targets
err = probeManager.AddTargets(hosts...)
Expand All @@ -81,43 +112,87 @@ mping batch dns://8.8.8.8/google.com`,
// Subscribe to events for metrics collection
done := metricsManager.Subscribe(probeManager.Events())

// Start probing with timeout context
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(counter)*_interval)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cmd.Print("probe")
// Progress goes to stderr (only when it is a terminal) so that
// stdout stays clean for piping into other tools.
progress := progressWriter()
fmt.Fprint(progress, "probe")
go func() {
if err := probeManager.Run(ctx, _interval, _timeout); err != nil {
fmt.Printf("ProbeManager error: %v\n", err)
fmt.Fprintf(os.Stderr, "ProbeManager error: %v\n", err)
}
}()

// Wait for specified duration
for counter > 0 {
counter--
cmd.Print(".")
time.Sleep(_interval)
// Probers send the first round immediately and then one round per
// interval, so round N goes out at (N-1)*interval. Stopping half an
// interval after the last round yields exactly `count` probes without
// racing the next tick. Stop waits for in-flight probes to finish.
// Deadlines are measured from a fixed start so sleep overshoot does
// not accumulate across rounds (the prober's ticker does not drift).
start := time.Now()
fmt.Fprint(progress, ".")
for i := 1; i < counter; i++ {
time.Sleep(time.Until(start.Add(time.Duration(i) * _interval)))
fmt.Fprint(progress, ".")
}
time.Sleep(time.Until(start.Add(time.Duration(counter-1)*_interval + _interval/2)))

// Stop probing
probeManager.Stop()
<-done // wait for in-flight metric updates to settle
cmd.Print("\r")
fmt.Fprint(progress, "\r\033[K")

metrics := metricsManager.SortBy(stats.Success, true)
tableData := shared.NewTableData(metrics, stats.Success, true)
t := tableData.ToGoPrettyTable()
t.SetStyle(table.StyleLight)
cmd.Println(t.Render())
return nil
if err := shared.WriteReport(cmd.OutOrStdout(), output, metrics, stats.Success, true); err != nil {
return err
}
return checkMaxLoss(metrics, maxLoss)
},
}

flags := cmd.Flags()
flags.StringP("filename", "f", "", "use contents of file")
flags.StringP("config", "c", "~/.mping.yml", "config path")
flags.StringP("interface", "I", "", "source interface (name or IP address)")
flags.IntP("interval", "i", 1000, "interval(ms)")
flags.IntP("timeout", "t", 1000, "timeout(ms)")
flags.IntP("count", "", 10, "repeat count")
flags.StringP("output", "o", shared.FormatTable, "output format ("+strings.Join(shared.OutputFormats, ", ")+")")
flags.Float64("max-loss", 0, fmt.Sprintf("exit with status %d if any target's loss(%%) exceeds this value", ExitCodeThresholdExceeded))

return cmd
}

// checkMaxLoss returns an ExitError listing targets whose loss exceeds maxLoss.
// A negative maxLoss disables the check.
func checkMaxLoss(metrics []stats.Metrics, maxLoss float64) error {
if maxLoss < 0 {
return nil
}
var violated []string
for _, m := range metrics {
if m.GetLoss() > maxLoss {
violated = append(violated, fmt.Sprintf("%s(%.1f%%)", m.GetName(), m.GetLoss()))
}
}
if len(violated) == 0 {
return nil
}
return &ExitError{
Code: ExitCodeThresholdExceeded,
Err: fmt.Errorf("%d target(s) exceeded max loss %.1f%%: %s",
len(violated), maxLoss, strings.Join(violated, ", ")),
}
}

// progressWriter returns stderr when it is attached to a terminal,
// otherwise a writer that discards progress output.
func progressWriter() io.Writer {
fi, err := os.Stderr.Stat()
if err != nil || fi.Mode()&os.ModeCharDevice == 0 {
return io.Discard
}
return os.Stderr
}
82 changes: 82 additions & 0 deletions internal/command/batch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package command

import (
"errors"
"strings"
"testing"
"time"

"github.com/servak/mping/internal/stats"
)

func TestCheckMaxLoss(t *testing.T) {
now := time.Now()
metrics := []stats.Metrics{
stats.NewMetricsForTest("ok", 1, 10, 10, 0, 0, 0, 0, 0, 0, 0, now, time.Time{}, ""),
stats.NewMetricsForTest("lossy", 1, 10, 7, 3, 30, 0, 0, 0, 0, 0, now, now, "timeout"),
stats.NewMetricsForTest("down", 1, 10, 0, 10, 100, 0, 0, 0, 0, 0, time.Time{}, now, "timeout"),
}

tests := []struct {
name string
maxLoss float64
wantErr bool
wantHosts []string
}{
{"disabled", -1, false, nil},
{"zero tolerance", 0, true, []string{"lossy", "down"}},
{"boundary is inclusive", 30, true, []string{"down"}},
{"all within limit", 100, false, nil},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkMaxLoss(metrics, tt.maxLoss)
if !tt.wantErr {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
var exitErr *ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *ExitError, got %v", err)
}
if exitErr.Code != ExitCodeThresholdExceeded {
t.Errorf("exit code = %d, want %d", exitErr.Code, ExitCodeThresholdExceeded)
}
for _, h := range tt.wantHosts {
if !strings.Contains(err.Error(), h+"(") {
t.Errorf("error %q should mention %s", err, h)
}
}
if strings.Contains(err.Error(), "ok(") {
t.Errorf("error %q should not mention healthy host", err)
}
})
}
}

func TestBatchCmdRejectsInvalidFlags(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{"bad output", []string{"-o", "xml", "127.0.0.1"}, "unsupported output format"},
{"bad max-loss", []string{"--max-loss", "150", "127.0.0.1"}, "max-loss must be between"},
{"bad count", []string{"--count", "0", "127.0.0.1"}, "count must be greater than zero"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewPingBatchCmd()
cmd.SetArgs(tt.args)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("Execute() error = %v, want containing %q", err, tt.want)
}
})
}
}
20 changes: 20 additions & 0 deletions internal/command/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package command

// ExitError carries a specific process exit code alongside an error.
// It lets automation distinguish "probing worked but targets were unhealthy"
// from usage or configuration errors (which exit with status 1).
type ExitError struct {
Code int
Err error
}

func (e *ExitError) Error() string {
return e.Err.Error()
}

func (e *ExitError) Unwrap() error {
return e.Err
}

// ExitCodeThresholdExceeded is returned when targets violate a health threshold
const ExitCodeThresholdExceeded = 2
7 changes: 1 addition & 6 deletions internal/command/mping.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"time"

"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"

"github.com/servak/mping/internal/config"
Expand Down Expand Up @@ -109,11 +108,7 @@ mping dns://8.8.8.8/google.com`,

// Final results
metrics := metricsManager.SortBy(stats.Fail, false)
tableData := shared.NewTableData(metrics, stats.Fail, false)
t := tableData.ToGoPrettyTable()
t.SetStyle(table.StyleLight)
cmd.Println(t.Render())
return nil
return shared.WriteReport(cmd.OutOrStderr(), shared.FormatTable, metrics, stats.Fail, false)
},
}

Expand Down
2 changes: 2 additions & 0 deletions internal/stats/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Metrics interface {
GetAverageRTT() time.Duration
GetMinimumRTT() time.Duration
GetMaximumRTT() time.Duration
GetJitter() time.Duration
GetLastSuccTime() time.Time
GetLastFailTime() time.Time
GetLastFailDetail() string
Expand All @@ -36,6 +37,7 @@ type MetricsProvider interface {
type MetricsSystemManager interface {
ResetAllMetrics()
ToggleBeep()
SetBeepEnabled(enabled bool)
IsBeepEnabled() bool
}

Expand Down
12 changes: 12 additions & 0 deletions internal/stats/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ func (mm *metricsManager) ToggleBeep() {
}
}

// SetBeepEnabled turns beep sound on or off
func (mm *metricsManager) SetBeepEnabled(enabled bool) {
if mm.beeper == nil {
return
}
if enabled {
mm.beeper.Enable()
} else {
mm.beeper.Disable()
}
}

// IsBeepEnabled returns current beep sound state
func (mm *metricsManager) IsBeepEnabled() bool {
if mm.beeper != nil {
Expand Down
Loading
Loading