diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7441120..d17e27a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,18 +8,51 @@ on: jobs: build: - runs-on: ubuntu-latest - + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + go: ["1.20"] + os: [ubuntu-latest, macOS-latest, windows-latest] + name: ${{ matrix.os }} Go ${{ matrix.go }} Tests steps: - uses: actions/checkout@v3 - name: Setup Go uses: actions/setup-go@v3 with: - go-version: '1.20.x' + go-version: ${{ matrix.go }} cache: true - name: Install dependencies run: go get . - - name: Build - run: go build -v ./... - name: Run tests - run: go test ./... -v \ No newline at end of file + run: go test ./... -v + - name: Build + run: go build -o bin/ + - name: Test windows cmd + if: matrix.os == 'windows-latest' + shell: cmd + run: | + bin\globalping-cli.exe ping cdn.jsdelivr.net + bin\globalping-cli.exe ping cdn.jsdelivr.net from @-1 + - name: Test windows powershell + if: matrix.os == 'windows-latest' + shell: powershell + run: | + bin\globalping-cli.exe ping cdn.jsdelivr.net + bin\globalping-cli.exe ping cdn.jsdelivr.net from '@-1' + - name: Test windows bash + if: matrix.os == 'windows-latest' + shell: bash + run: | + ./bin/globalping-cli.exe ping cdn.jsdelivr.net + ./bin/globalping-cli.exe ping cdn.jsdelivr.net from @-1 + - name: Test macOS + if: matrix.os == 'macOS-latest' + run: | + ./bin/globalping-cli ping cdn.jsdelivr.net + ./bin/globalping-cli ping cdn.jsdelivr.net from @-1 + - name: Test ubuntu + if: matrix.os == 'ubuntu-latest' + run: | + ./bin/globalping-cli ping cdn.jsdelivr.net + ./bin/globalping-cli ping cdn.jsdelivr.net from @-1 diff --git a/README.md b/README.md index 3b84d73..a3fde80 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ Additional Commands: Flags: -C, --ci Disable realtime terminal updates and color suitable for CI and scripting (default false) - -F, --from string Comma-separated list of location values to match against or measurement ID. For example the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network (default "world"). (default "world") + -F, --from string Comma-separated list of location values to match against or a measurement ID + For example, the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network + Or use [@1 | first, @2 ... @-2, @-1 | last | previous] to run with the probes from previous measurements. (default "world") -h, --help help for globalping -J, --json Output results in JSON format (default false) --latency Output only the stats of a measurement (default false). Only applies to the dns, http and ping commands @@ -201,7 +203,7 @@ google.com. 300 IN A 142.250.183.206 #### Reselect probes -You can select the same probes used in a previous measurement. +You can select the same probes used in a previous measurement by passing the measurement ID to the `--from` flag. ```bash globalping dns google.com from rvasVvKnj48cxNjC @@ -226,6 +228,54 @@ google.com. 300 IN A 142.250.199.174 ;; MSG SIZE rcvd: 55 ``` +#### Reselect probes from measurements in the current session + +Use `[@1 | first, @2 ... @-2, @-1 | last | previous]` to select the probes from previous measurements in the current session. + +```bash +globalping ping google.com from USA --latency +> NA, US, (VA), Ashburn, ASN:213230, Hetzner Online GmbH +Min: 7.314 ms +Max: 7.413 ms +Avg: 7.359 ms + +globalping ping google.com from Germany --latency +> EU, DE, Falkenstein, ASN:24940, Hetzner Online GmbH +Min: 4.87 ms +Max: 4.936 ms +Avg: 4.911 ms + +globalping ping google.com from previous --latency +> EU, DE, Falkenstein, ASN:24940, Hetzner Online GmbH +Min: 4.87 ms +Max: 4.936 ms +Avg: 4.911 ms + +globalping ping google.com from @-1 --latency +> EU, DE, Falkenstein, ASN:24940, Hetzner Online GmbH +Min: 4.87 ms +Max: 4.936 ms +Avg: 4.911 ms + +globalping ping google.com from @-2 --latency +> NA, US, (VA), Ashburn, ASN:213230, Hetzner Online GmbH +Min: 7.314 ms +Max: 7.413 ms +Avg: 7.359 ms + +globalping ping google.com from first --latency +> NA, US, (VA), Ashburn, ASN:213230, Hetzner Online GmbH +Min: 7.314 ms +Max: 7.413 ms +Avg: 7.359 ms + +globalping ping google.com from @1 --latency +> NA, US, (VA), Ashburn, ASN:213230, Hetzner Online GmbH +Min: 7.314 ms +Max: 7.413 ms +Avg: 7.359 ms +``` + #### Learn about available flags Most commands have shared and unique flags. We recommend that you familiarize yourself with these so that you can run and automate your network testsĀ in powerful ways. diff --git a/cmd/common.go b/cmd/common.go index 4649f9c..4730471 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -1,22 +1,188 @@ package cmd import ( + "bufio" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime" + "strconv" "strings" + "github.com/icza/backscanner" "github.com/jsdelivr/globalping-cli/model" + "github.com/shirou/gopsutil/process" ) -func createLocations(from string) []model.Locations { +var ( + ErrorNoPreviousMeasurements = errors.New("no previous measurements found") + ErrInvalidIndex = errors.New("invalid index") + ErrIndexOutOfRange = errors.New("index out of range") +) + +var SESSION_PATH string + +func inProgressUpdates(ci bool) bool { + return !(ci) +} + +func createLocations(from string) ([]model.Locations, bool, error) { fromArr := strings.Split(from, ",") + if len(fromArr) == 1 { + mId, err := mapToMeasurementID(fromArr[0]) + if err != nil { + return nil, false, err + } + isPreviousMeasurementId := false + if mId == "" { + mId = strings.TrimSpace(fromArr[0]) + } else { + isPreviousMeasurementId = true + } + return []model.Locations{ + { + Magic: mId, + }, + }, isPreviousMeasurementId, nil + } locations := make([]model.Locations, len(fromArr)) for i, v := range fromArr { locations[i] = model.Locations{ Magic: strings.TrimSpace(v), } } - return locations + return locations, false, nil } -func inProgressUpdates(ci bool) bool { - return !(ci) +// Maps a location to a measurement ID if possible +func mapToMeasurementID(location string) (string, error) { + if location == "" { + return "", nil + } + if location[0] == '@' { + index, err := strconv.Atoi(location[1:]) + if err != nil { + return "", ErrInvalidIndex + } + return getMeasurementID(index) + } + if location == "first" { + return getMeasurementID(1) + } + if location == "last" || location == "previous" { + return getMeasurementID(-1) + } + return "", nil +} + +// Returns the measurement ID at the given index from the session history +func getMeasurementID(index int) (string, error) { + if index == 0 { + return "", ErrInvalidIndex + } + f, err := os.Open(getMeasurementsPath()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", ErrorNoPreviousMeasurements + } + return "", fmt.Errorf("failed to open previous measurements file: %s", err) + } + defer f.Close() + // Read ids from the end of the file + if index < 0 { + fStats, err := f.Stat() + if err != nil { + return "", fmt.Errorf("failed to read previous measurements: %s", err) + } + if fStats.Size() == 0 { + return "", ErrorNoPreviousMeasurements + } + scanner := backscanner.New(f, int(fStats.Size()-1)) // -1 to skip last newline + for { + index++ + b, _, err := scanner.LineBytes() + if err != nil { + if err == io.EOF { + return "", ErrIndexOutOfRange + } + return "", fmt.Errorf("failed to read previous measurements: %s", err) + } + if index == 0 { + return string(b), nil + } + } + } + // Read ids from the beginning of the file + scanner := bufio.NewScanner(f) + for scanner.Scan() { + index-- + if index == 0 { + return scanner.Text(), nil + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("failed to read previous measurements: %s", err) + } + return "", ErrIndexOutOfRange +} + +// Saves the measurement ID to the session history +func saveMeasurementID(id string) error { + _, err := os.Stat(getSessionPath()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + err := os.Mkdir(getSessionPath(), 0755) + if err != nil { + return fmt.Errorf("failed to save measurement ID: %s", err) + } + } else { + return fmt.Errorf("failed to save measurement ID: %s", err) + } + } + f, err := os.OpenFile(getMeasurementsPath(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to save measurement ID: %s", err) + } + defer f.Close() + _, err = f.WriteString(id + "\n") + if err != nil { + return fmt.Errorf("failed to save measurement ID: %s", err) + } + return nil +} + +func getSessionPath() string { + if SESSION_PATH != "" { + return SESSION_PATH + } + SESSION_PATH = filepath.Join(os.TempDir(), getSessionId()) + return SESSION_PATH +} + +func getSessionId() string { + p, err := process.NewProcess(int32(os.Getppid())) + if err != nil { + return "globalping" + } + // Workaround for bash.exe on Windows + // PPID is different on each run. + // https://cygwin.com/git/gitweb.cgi?p=newlib-cygwin.git;a=commit;h=448cf5aa4b429d5a9cebf92a0da4ab4b5b6d23fe + if runtime.GOOS == "windows" { + name, _ := p.Name() + if name == "bash.exe" { + p, err = p.Parent() + if err != nil { + return "globalping" + } + } + } + createTime, _ := p.CreateTime() + return fmt.Sprintf("globalping_%d_%d", p.Pid, createTime) +} + +func getMeasurementsPath() string { + return filepath.Join(getSessionPath(), "measurements") } diff --git a/cmd/common_test.go b/cmd/common_test.go index a493439..c5579e8 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -1,11 +1,22 @@ package cmd import ( + "errors" + "io/fs" + "os" "testing" + "github.com/jsdelivr/globalping-cli/model" "github.com/stretchr/testify/assert" ) +var ( + measurementID1 = "WOOxHNyhdsBQYEjU" + measurementID2 = "hhUicONd75250Z1b" + measurementID3 = "YPDXL29YeGctf6iJ" + measurementID4 = "hH3tBVPZEj5k6AcW" +) + func TestInProgressUpdates_CI(t *testing.T) { ci := true assert.Equal(t, false, inProgressUpdates(ci)) @@ -15,3 +26,180 @@ func TestInProgressUpdates_NotCI(t *testing.T) { ci := false assert.Equal(t, true, inProgressUpdates(ci)) } + +func TestCreateLocations(t *testing.T) { + for scenario, fn := range map[string]func(t *testing.T){ + "valid_single": testLocationsSingle, + "valid_multiple": testLocationsMultiple, + "valid_multiple_whitespace": testLocationsMultipleWhitespace, + "valid_session_last_measurement": testCreateLocationsSessionLastMeasurement, + "valid_session_first_measurement": testCreateLocationsSessionFirstMeasurement, + "valid_session_measurement_at_index": testCreateLocationsSessionMeasurementAtIndex, + "valid_session_no_session": testCreateLocationsSessionNoSession, + "invalid_session_index": testCreateLocationsSessionInvalidIndex, + } { + t.Run(scenario, func(t *testing.T) { + fn(t) + t.Cleanup(func() { + sessionPath := getSessionPath() + err := os.RemoveAll(sessionPath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Failed to remove session path: %s", err) + } + }) + }) + } +} + +func testLocationsSingle(t *testing.T) { + locations, isPreviousMeasurementId, err := createLocations("New York") + assert.Equal(t, []model.Locations{{Magic: "New York"}}, locations) + assert.False(t, isPreviousMeasurementId) + assert.Nil(t, err) +} + +func testLocationsMultiple(t *testing.T) { + locations, isPreviousMeasurementId, err := createLocations("New York,Los Angeles") + assert.Equal(t, []model.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) + assert.False(t, isPreviousMeasurementId) + assert.Nil(t, err) +} + +// Check if multiple locations with whitespace are parsed correctly +func testLocationsMultipleWhitespace(t *testing.T) { + locations, isPreviousMeasurementId, err := createLocations("New York, Los Angeles ") + assert.Equal(t, []model.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) + assert.False(t, isPreviousMeasurementId) + assert.Nil(t, err) +} + +func testCreateLocationsSessionLastMeasurement(t *testing.T) { + saveMeasurementID(measurementID1) + locations, isPreviousMeasurementId, err := createLocations("@1") + assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) + + locations, isPreviousMeasurementId, err = createLocations("last") + assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) + + locations, isPreviousMeasurementId, err = createLocations("previous") + assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) +} + +func testCreateLocationsSessionFirstMeasurement(t *testing.T) { + saveMeasurementID(measurementID1) + saveMeasurementID(measurementID2) + locations, isPreviousMeasurementId, err := createLocations("@-1") + assert.Equal(t, []model.Locations{{Magic: measurementID2}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) + + locations, isPreviousMeasurementId, err = createLocations("last") + assert.Equal(t, []model.Locations{{Magic: measurementID2}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) +} + +func testCreateLocationsSessionMeasurementAtIndex(t *testing.T) { + saveMeasurementID(measurementID1) + saveMeasurementID(measurementID2) + saveMeasurementID(measurementID3) + saveMeasurementID(measurementID4) + locations, isPreviousMeasurementId, err := createLocations("@2") + assert.Equal(t, []model.Locations{{Magic: measurementID2}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) + + locations, isPreviousMeasurementId, err = createLocations("@-2") + assert.Equal(t, []model.Locations{{Magic: measurementID3}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) + + locations, isPreviousMeasurementId, err = createLocations("@-4") + assert.Equal(t, []model.Locations{{Magic: measurementID1}}, locations) + assert.True(t, isPreviousMeasurementId) + assert.Nil(t, err) +} + +func testCreateLocationsSessionNoSession(t *testing.T) { + locations, isPreviousMeasurementId, err := createLocations("@1") + assert.Nil(t, locations) + assert.False(t, isPreviousMeasurementId) + assert.Equal(t, ErrorNoPreviousMeasurements, err) +} + +func testCreateLocationsSessionInvalidIndex(t *testing.T) { + locations, isPreviousMeasurementId, err := createLocations("@0") + assert.Nil(t, locations) + assert.False(t, isPreviousMeasurementId) + assert.Equal(t, ErrInvalidIndex, err) + + locations, isPreviousMeasurementId, err = createLocations("@") + assert.Nil(t, locations) + assert.False(t, isPreviousMeasurementId) + assert.Equal(t, ErrInvalidIndex, err) + + locations, isPreviousMeasurementId, err = createLocations("@x") + assert.Nil(t, locations) + assert.False(t, isPreviousMeasurementId) + assert.Equal(t, ErrInvalidIndex, err) + + saveMeasurementID(measurementID1) + locations, isPreviousMeasurementId, err = createLocations("@2") + assert.Nil(t, locations) + assert.False(t, isPreviousMeasurementId) + assert.Equal(t, ErrIndexOutOfRange, err) + + locations, isPreviousMeasurementId, err = createLocations("@-2") + assert.Nil(t, locations) + assert.False(t, isPreviousMeasurementId) + assert.Equal(t, ErrIndexOutOfRange, err) +} + +func TestSaveMeasurementID(t *testing.T) { + for scenario, fn := range map[string]func(t *testing.T){ + "valid_new_session": testSaveMeasurementIDNewSession, + "valid_existing_session": testSaveMeasurementIDExistingSession, + } { + t.Run(scenario, func(t *testing.T) { + fn(t) + t.Cleanup(func() { + sessionPath := getSessionPath() + err := os.RemoveAll(sessionPath) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("Failed to remove session path: %s", err) + } + }) + }) + } +} + +func testSaveMeasurementIDNewSession(t *testing.T) { + saveMeasurementID(measurementID1) + assert.FileExists(t, getMeasurementsPath()) + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expected := []byte(measurementID1 + "\n") + assert.Equal(t, expected, b) +} + +func testSaveMeasurementIDExistingSession(t *testing.T) { + err := os.Mkdir(getSessionPath(), 0755) + if err != nil { + t.Fatalf("Failed to create session path: %s", err) + } + err = os.WriteFile(getMeasurementsPath(), []byte(measurementID1+"\n"), 0644) + if err != nil { + t.Fatalf("Failed to create measurements file: %s", err) + } + saveMeasurementID(measurementID2) + b, err := os.ReadFile(getMeasurementsPath()) + assert.NoError(t, err) + expected := []byte(measurementID1 + "\n" + measurementID2 + "\n") + assert.Equal(t, expected, b) +} diff --git a/cmd/dns.go b/cmd/dns.go index 5e99f1b..2a3ac21 100644 --- a/cmd/dns.go +++ b/cmd/dns.go @@ -11,7 +11,7 @@ import ( // dnsCmd represents the dns command var dnsCmd = &cobra.Command{ - Use: "dns [target] from [location | measurement ID]", + Use: "dns [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", GroupID: "Measurements", Short: "Resolve a DNS record similarly to dig", Long: `Performs DNS lookups and displays the answers that are returned from the name server(s) that were queried. @@ -29,6 +29,15 @@ Using the dig format @resolver. For example: # Resolve google.com using probes from previous measurement dns google.com from rvasVvKnj48cxNjC + # Resolve google.com using probes from first measurement in session + dns google.com from @1 + + # Resolve google.com using probes from last measurement in session + dns google.com from last + + # Resolve google.com using probes from second to last measurement in session + dns google.com from @-2 + # Resolve google.com from 2 probes from London or Belgium with trace enabled dns google.com from London,Belgium --limit 2 --trace @@ -55,7 +64,6 @@ Using the dig format @resolver. For example: opts = model.PostMeasurement{ Type: "dns", Target: ctx.Target, - Locations: createLocations(ctx.From), Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), Options: &model.MeasurementOptions{ @@ -68,6 +76,12 @@ Using the dig format @resolver. For example: Trace: trace, }, } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) + if err != nil { + cmd.SilenceUsage = true + return err + } res, showHelp, err := client.PostAPI(opts) if err != nil { @@ -78,6 +92,14 @@ Using the dig format @resolver. For example: return nil } + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveMeasurementID(res.ID) + if err != nil { + fmt.Printf("warning: %s\n", err) + } + } + view.OutputResults(res.ID, ctx, opts) return nil }, diff --git a/cmd/http.go b/cmd/http.go index f2b64bc..e991d7a 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -81,7 +81,7 @@ func overrideOptInt(orig, new int) int { // httpCmd represents the http command var httpCmd = &cobra.Command{ - Use: "http [target] from [location | measurement ID]", + Use: "http [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", GroupID: "Measurements", Short: "Perform a HEAD or GET request to a host", Long: `The http command sends an HTTP request to a host and can perform HEAD or GET operations. GET is limited to 10KB responses, everything above will be cut by the API. Detailed performance stats as available for every request. @@ -107,6 +107,15 @@ Examples: # HTTP GET request google.com using probes from previous measurement http google.com from rvasVvKnj48cxNjC --method get + # HTTP GET request google.com using probes from first measurement in session + http google.com from @1 --method get + + # HTTP GET request google.com using probes from last measurement in session + http google.com from last --method get + + # HTTP GET request google.com using probes from second to last measurement in session + http google.com from @-2 --method get + # HTTP GET request to google.com from a probe in London. Returns the full output http google.com from London --method get --full @@ -133,13 +142,18 @@ func httpCmdRun(cmd *cobra.Command, args []string) error { } // build http measurement - m, err := buildHttpMeasurementRequest() + opts, err := buildHttpMeasurementRequest() + if err != nil { + return err + } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) if err != nil { + cmd.SilenceUsage = true return err } - opts = m - res, showHelp, err := client.PostAPI(opts) + res, showHelp, err := client.PostAPI(*opts) if err != nil { if showHelp { return err @@ -148,39 +162,42 @@ func httpCmdRun(cmd *cobra.Command, args []string) error { return nil } - view.OutputResults(res.ID, ctx, m) + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveMeasurementID(res.ID) + if err != nil { + fmt.Printf("warning: %s\n", err) + } + } + + view.OutputResults(res.ID, ctx, *opts) return nil } const PostMeasurementTypeHttp = "http" // buildHttpMeasurementRequest builds the measurement request for the http type -func buildHttpMeasurementRequest() (model.PostMeasurement, error) { - m := model.PostMeasurement{ - Type: PostMeasurementTypeHttp, +func buildHttpMeasurementRequest() (*model.PostMeasurement, error) { + opts := &model.PostMeasurement{ + Type: PostMeasurementTypeHttp, + Limit: ctx.Limit, + InProgressUpdates: inProgressUpdates(ctx.CI), } - urlData, err := parseUrlData(ctx.Target) if err != nil { - return m, err + return nil, err } - headers, err := parseHttpHeaders(httpCmdOpts.Headers) if err != nil { - return m, err + return nil, err } - method := strings.ToUpper(httpCmdOpts.Method) if ctx.Full { // override method to GET method = "GET" } - - m.Target = urlData.Host - m.Locations = createLocations(ctx.From) - m.Limit = ctx.Limit - m.InProgressUpdates = inProgressUpdates(ctx.CI) - m.Options = &model.MeasurementOptions{ + opts.Target = urlData.Host + opts.Options = &model.MeasurementOptions{ Protocol: overrideOpt(urlData.Protocol, httpCmdOpts.Protocol), Port: overrideOptInt(urlData.Port, httpCmdOpts.Port), Packets: packets, @@ -193,8 +210,7 @@ func buildHttpMeasurementRequest() (model.PostMeasurement, error) { }, Resolver: overrideOpt(ctx.Resolver, httpCmdOpts.Resolver), } - - return m, nil + return opts, nil } func parseHttpHeaders(headerStrings []string) (map[string]string, error) { diff --git a/cmd/http_test.go b/cmd/http_test.go index 8149cea..550e248 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -92,9 +92,8 @@ func TestBuildHttpMeasurementRequest_FULL(t *testing.T) { m, err := buildHttpMeasurementRequest() assert.NoError(t, err) - expectedM := model.PostMeasurement{Limit: 0, - Locations: []model.Locations{ - {Magic: "london"}}, + expectedM := &model.PostMeasurement{ + Limit: 0, Type: "http", Target: "example.com", InProgressUpdates: true, @@ -130,9 +129,8 @@ func TestBuildHttpMeasurementRequest_HEAD(t *testing.T) { m, err := buildHttpMeasurementRequest() assert.NoError(t, err) - expectedM := model.PostMeasurement{Limit: 0, - Locations: []model.Locations{ - {Magic: "london"}}, + expectedM := &model.PostMeasurement{ + Limit: 0, Type: "http", Target: "example.com", InProgressUpdates: true, diff --git a/cmd/mtr.go b/cmd/mtr.go index 4bb2b0b..478a20d 100644 --- a/cmd/mtr.go +++ b/cmd/mtr.go @@ -11,7 +11,7 @@ import ( // mtrCmd represents the mtr command var mtrCmd = &cobra.Command{ - Use: "mtr [target] from [location | measurement ID]", + Use: "mtr [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", GroupID: "Measurements", Short: "Run an MTR test, similar to traceroute", Long: `mtr combines the functionality of the traceroute and ping programs in a single network diagnostic tool. @@ -23,6 +23,15 @@ Examples: # MTR google.com using probes from previous measurement mtr google.com from rvasVvKnj48cxNjC + # MTR google.com using probes from first measurement in session + mtr google.com from @1 + + # MTR google.com using probes from last measurement in session + mtr google.com from last + + # MTR google.com using probes from second to last measurement in session + mtr google.com from @-2 + # MTR 1.1.1.1 from 2 probes from USA or Belgium with 10 packets in CI mode mtr 1.1.1.1 from USA,Belgium --limit 2 --packets 10 --ci @@ -46,7 +55,6 @@ Examples: opts = model.PostMeasurement{ Type: "mtr", Target: ctx.Target, - Locations: createLocations(ctx.From), Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), Options: &model.MeasurementOptions{ @@ -55,6 +63,12 @@ Examples: Packets: packets, }, } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) + if err != nil { + cmd.SilenceUsage = true + return err + } res, showHelp, err := client.PostAPI(opts) if err != nil { @@ -65,6 +79,14 @@ Examples: return nil } + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveMeasurementID(res.ID) + if err != nil { + fmt.Printf("warning: %s\n", err) + } + } + view.OutputResults(res.ID, ctx, opts) return nil }, diff --git a/cmd/ping.go b/cmd/ping.go index bc9cf37..ac634a8 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -11,7 +11,7 @@ import ( // pingCmd represents the ping command var pingCmd = &cobra.Command{ - Use: "ping [target] from [location | measurement ID]", + Use: "ping [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", GroupID: "Measurements", Short: "Run a ping test", Long: `The ping command allows sending ping requests to a target. Often used to test the network latency and stability. @@ -23,6 +23,15 @@ Examples: # Ping google.com using probes from previous measurement ping google.com from rvasVvKnj48cxNjC + # Ping google.com using probes from first measurement in session + ping google.com from @1 + + # Ping google.com using probes from last measurement in session + ping google.com from last + + # Ping google.com using probes from second to last measurement in session + ping google.com from @-2 + # Ping 1.1.1.1 from 2 probes from USA or Belgium with 10 packets in CI mode ping 1.1.1.1 from USA,Belgium --limit 2 --packets 10 --ci @@ -42,13 +51,18 @@ Examples: opts = model.PostMeasurement{ Type: "ping", Target: ctx.Target, - Locations: createLocations(ctx.From), Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), Options: &model.MeasurementOptions{ Packets: packets, }, } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) + if err != nil { + cmd.SilenceUsage = true + return err + } res, showHelp, err := client.PostAPI(opts) if err != nil { @@ -59,6 +73,14 @@ Examples: return nil } + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveMeasurementID(res.ID) + if err != nil { + fmt.Printf("warning: %s\n", err) + } + } + view.OutputResults(res.ID, ctx, opts) return nil }, diff --git a/cmd/root.go b/cmd/root.go index d896d46..d37f38e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,7 +47,9 @@ func Execute() { func init() { // Global flags - rootCmd.PersistentFlags().StringVarP(&ctx.From, "from", "F", "world", `Comma-separated list of location values to match against or measurement ID. For example the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network (default "world").`) + rootCmd.PersistentFlags().StringVarP(&ctx.From, "from", "F", "world", `Comma-separated list of location values to match against or a measurement ID +For example, the partial or full name of a continent, region (e.g eastern europe), country, US state, city or network +Or use [@1 | first, @2 ... @-2, @-1 | last | previous] to run with the probes from previous measurements.`) rootCmd.PersistentFlags().IntVarP(&ctx.Limit, "limit", "L", 1, "Limit the number of probes to use") rootCmd.PersistentFlags().BoolVarP(&ctx.JsonOutput, "json", "J", false, "Output results in JSON format (default false)") rootCmd.PersistentFlags().BoolVarP(&ctx.CI, "ci", "C", false, "Disable realtime terminal updates and color suitable for CI and scripting (default false)") diff --git a/cmd/root_test.go b/cmd/root_test.go index 5395fb1..d6135e5 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -8,34 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestCreateLocations(t *testing.T) { - for scenario, fn := range map[string]func(t *testing.T){ - "valid_single": testLocationsSingle, - "valid_multiple": testLocationsMultiple, - "valid_multiple_whitespace": testLocationsMultipleWhitespace, - } { - t.Run(scenario, func(t *testing.T) { - fn(t) - }) - } -} - -func testLocationsSingle(t *testing.T) { - locations := createLocations("New York") - assert.Equal(t, []model.Locations{{Magic: "New York"}}, locations) -} - -func testLocationsMultiple(t *testing.T) { - locations := createLocations("New York,Los Angeles") - assert.Equal(t, []model.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) -} - -// Check if multiple locations with whitespace are parsed correctly -func testLocationsMultipleWhitespace(t *testing.T) { - locations := createLocations("New York, Los Angeles ") - assert.Equal(t, []model.Locations{{Magic: "New York"}, {Magic: "Los Angeles"}}, locations) -} - func TestCreateContext(t *testing.T) { for scenario, fn := range map[string]func(t *testing.T){ "no_arg": testContextNoArg, diff --git a/cmd/traceroute.go b/cmd/traceroute.go index 284ee3f..ff88b13 100644 --- a/cmd/traceroute.go +++ b/cmd/traceroute.go @@ -11,7 +11,7 @@ import ( // tracerouteCmd represents the traceroute command var tracerouteCmd = &cobra.Command{ - Use: "traceroute [target] from [location | measurement ID]", + Use: "traceroute [target] from [location | measurement ID | @1 | first | @-1 | last | previous]", GroupID: "Measurements", Short: "Run a traceroute test", Long: `traceroute tracks the route packets take from an IP network on their way to a given host. @@ -23,6 +23,15 @@ Examples: # Traceroute google.com using probes from previous measurement traceroute google.com from rvasVvKnj48cxNjC + # Traceroute google.com using probes from first measurement in session + traceroute google.com from @1 + + # Traceroute google.com using probes from last measurement in session + traceroute google.com from last + + # Traceroute google.com using probes from second to last measurement in session + traceroute google.com from @-2 + # Traceroute 1.1.1.1 from 2 probes from USA or Belgium in CI mode traceroute 1.1.1.1 from USA,Belgium --limit 2 --ci @@ -49,7 +58,6 @@ Examples: opts = model.PostMeasurement{ Type: "traceroute", Target: ctx.Target, - Locations: createLocations(ctx.From), Limit: ctx.Limit, InProgressUpdates: inProgressUpdates(ctx.CI), Options: &model.MeasurementOptions{ @@ -57,6 +65,12 @@ Examples: Port: port, }, } + isPreviousMeasurementId := false + opts.Locations, isPreviousMeasurementId, err = createLocations(ctx.From) + if err != nil { + cmd.SilenceUsage = true + return err + } res, showHelp, err := client.PostAPI(opts) if err != nil { @@ -67,6 +81,14 @@ Examples: return nil } + // Save measurement ID to history + if !isPreviousMeasurementId { + err := saveMeasurementID(res.ID) + if err != nil { + fmt.Printf("warning: %s\n", err) + } + } + view.OutputResults(res.ID, ctx, opts) return nil }, diff --git a/go.mod b/go.mod index 3b694c2..b8f0922 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,13 @@ require ( github.com/andybalholm/brotli v1.0.5 github.com/charmbracelet/lipgloss v0.7.1 github.com/golang/mock v1.6.0 + github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70 github.com/mattn/go-runewidth v0.0.14 github.com/pkg/errors v0.9.1 github.com/pterm/pterm v0.12.58 + github.com/shirou/gopsutil v3.21.11+incompatible github.com/spf13/cobra v1.7.0 - github.com/stretchr/testify v1.8.2 + github.com/stretchr/testify v1.8.4 golang.org/x/exp v0.0.0-20230321023759-10a507213a29 ) @@ -20,6 +22,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/gookit/color v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -31,8 +34,11 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.7.0 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/sys v0.15.0 // indirect golang.org/x/term v0.7.0 // indirect golang.org/x/text v0.9.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index eccfd84..3881503 100644 --- a/go.sum +++ b/go.sum @@ -25,12 +25,18 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.3 h1:twfIhZs4QLCtimkP7MOxlF3A0U/5cDPseRT9M/+2SCE= github.com/gookit/color v1.5.3/go.mod h1:NUzwzeehUfl7GIb36pqId+UGmRfQcU/WiiyTTeNjHtE= +github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70 h1:xrd41BUTgqxyYFfFwGdt/bnwS8KNYzPraj8WgvJ5NWk= +github.com/icza/backscanner v0.0.0-20230330133933-bf6beb754c70/go.mod h1:GYeBD1CF7AqnKZK+UCytLcY3G+UKo0ByXX/3xfdNyqQ= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -82,24 +88,28 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= @@ -112,6 +122,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -120,8 +131,10 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/lib/target_query.go b/lib/target_query.go index d1613d6..b83fa95 100644 --- a/lib/target_query.go +++ b/lib/target_query.go @@ -52,7 +52,7 @@ func findAndRemoveResolver(args []string) (string, []string) { var resolver string resolverIndex := -1 for i := 0; i < len(args); i++ { - if len(args[i]) > 0 && args[i][0] == '@' { + if len(args[i]) > 0 && args[i][0] == '@' && args[i-1] != "from" { resolver = args[i][1:] resolverIndex = i break