From 482b18fe73e66b407b3c1606dcad688d16613b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Tue, 15 Sep 2026 10:24:58 +0200 Subject: [PATCH 1/5] fix: handle individual probe failures --- cmd/common.go | 4 -- cmd/ping.go | 7 +- cmd/ping_test.go | 40 +++++++++++ cmd/table_test.go | 110 +++++++++++++++------------- view/default.go | 6 ++ view/default_test.go | 31 ++++++++ view/infinite.go | 142 ++++++++++++++++++++++-------------- view/infinite_test.go | 163 +++++++++++++++++++++++++++++++++++------- view/latency_test.go | 24 +++++++ view/output.go | 89 +++++++++++++++++++---- view/output_test.go | 54 +++++++++++++- view/table.go | 32 ++------- view/table_test.go | 83 ++++++++++----------- view/viewer.go | 4 ++ 14 files changed, 567 insertions(+), 222 deletions(-) diff --git a/cmd/common.go b/cmd/common.go index 4e182ac..75be897 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -114,10 +114,6 @@ func (r *Root) handleMeasurement(ctx context.Context, id string, opts *globalpin _, err = r.viewer.OutputTable(res) if err != nil { - if errors.Is(err, view.ErrAllProbesFailed) { - r.Cmd.SilenceErrors = true - } - return err } } diff --git a/cmd/ping.go b/cmd/ping.go index 35baec1..dd03ebb 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -171,7 +171,7 @@ func (r *Root) pingInfinite(ctx context.Context, opts *globalping.MeasurementCre } if errors.Is(err, view.ErrAllProbesFailed) { - r.Cmd.SilenceErrors = true + err = nil } r.evaluateError(err) @@ -230,6 +230,11 @@ func (r *Root) ping(ctx context.Context, opts *globalping.MeasurementCreate) (st infiniteTableOutput, err = r.viewer.OutputInfinite(measurement) + // Do not let ErrAllProbesFailed mask the earlier creation error + if runErr != nil && errors.Is(err, view.ErrAllProbesFailed) { + err = nil + } + if err != nil { r.Cmd.SilenceUsage = true diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 66610e8..708bb15 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -666,6 +666,46 @@ func Test_Execute_Ping_Infinite_Output_TooManyRequests_Error(t *testing.T) { assert.Equal(t, expectedHistoryItems, items) } +func Test_Ping_InfiniteCreationFailureFinishesPendingMeasurement(t *testing.T) { + for _, finalStatus := range []globalping.TestStatus{globalping.TestStatusFinished, globalping.TestStatusFailed} { + t.Run(string(finalStatus), func(t *testing.T) { + ctrl := gomock.NewController(t) + firstResponse := createDefaultMeasurementCreateResponse() + measurement := createDefaultMeasurement_MultipleProbes(globalping.MeasurementStatusInProgress, globalping.TestStatusInProgress) + measurement.Results[0].Result.Status = globalping.TestStatusFinished + + client := apiMocks.NewMockClient(ctrl) + firstCreate := client.EXPECT().CreateMeasurement(gomock.Any(), gomock.Any()).Return(firstResponse, nil) + firstPoll := client.EXPECT().GetMeasurement(gomock.Any(), firstResponse.ID).Return(measurement, nil).After(firstCreate) + viewer := viewMocks.NewMockViewer(ctrl) + firstOutput := viewer.EXPECT().OutputInfinite(measurement).Return("current output", nil).After(firstPoll) + failedCreate := client.EXPECT().CreateMeasurement(gomock.Any(), gomock.Any()).Return(nil, assert.AnError).After(firstOutput) + completed := createDefaultMeasurement_MultipleProbes(globalping.MeasurementStatusFinished, finalStatus) + var outputErr error + + if finalStatus == globalping.TestStatusFailed { + outputErr = view.ErrAllProbesFailed + } + + finalPoll := client.EXPECT().GetMeasurement(gomock.Any(), firstResponse.ID).Return(completed, nil).After(failedCreate) + viewer.EXPECT().OutputInfinite(completed).Return("completed output", outputErr).After(finalPoll) + utilsMock := utilsMocks.NewMockUtils(ctrl) + utilsMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + ctx := createDefaultContext() + ctx.Infinite = true + ctx.Packets = 16 + root := NewRoot(view.NewPrinter(nil, new(bytes.Buffer), new(bytes.Buffer)), ctx, viewer, utilsMock, client, nil, nil) + opts := createDefaultMeasurementCreate("ping") + + output, err := root.ping(t.Context(), opts) + + assert.ErrorIs(t, err, assert.AnError) + assert.Equal(t, "completed output", output) + assert.Equal(t, globalping.MeasurementStatusFinished, ctx.History.Find(firstResponse.ID).Status) + }) + } +} + func Test_Execute_Ping_IPv4(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cmd/table_test.go b/cmd/table_test.go index 95980bd..f69365d 100644 --- a/cmd/table_test.go +++ b/cmd/table_test.go @@ -36,49 +36,58 @@ func Test_TableFlag_IsAvailableOnlyForMeasurementCommands(t *testing.T) { func Test_Execute_TableMeasurement(t *testing.T) { for _, measurementType := range []globalping.MeasurementType{"ping", "traceroute", "mtr", "dns", "http"} { - t.Run(string(measurementType), func(t *testing.T) { - ctrl := gomock.NewController(t) - expectedOpts := createDefaultMeasurementCreate(measurementType) - locations, ok := expectedOpts.Locations.(globalping.LocationOptions) - require.True(t, ok) - locations[0].Magic = "world" - - switch measurementType { - case "dns": - expectedOpts.Options.Query = &globalping.QueryOptions{} - case "http": - expectedOpts.Options.Request = &globalping.RequestOptions{Headers: map[string]string{}} - } - - expectedResponse := createDefaultMeasurementCreateResponse() - expectedMeasurement := createDefaultMeasurement(measurementType) - gbMock := apiMocks.NewMockClient(ctrl) - gbMock.EXPECT().CreateMeasurement(t.Context(), expectedOpts).Return(expectedResponse, nil) - gbMock.EXPECT().GetMeasurement(t.Context(), expectedResponse.ID).Return(expectedMeasurement, nil) - - viewerMock := viewMocks.NewMockViewer(ctrl) - viewerMock.EXPECT().OutputTable(expectedMeasurement).Return("", nil) - viewerMock.EXPECT().OutputShare() - - utilsMock := utilsMocks.NewMockUtils(ctrl) - utilsMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() - - w := new(bytes.Buffer) - ctx := createDefaultContext() - storage := createDefaultTestStorage(t, utilsMock) - root := NewRoot(view.NewPrinter(nil, w, w), ctx, viewerMock, utilsMock, gbMock, nil, storage) - oldArgs := os.Args - t.Cleanup(func() { os.Args = oldArgs }) - os.Args = []string{"globalping", string(measurementType), "jsdelivr.com", "--table", "--latency", "--json", "--ci"} - - err := root.Cmd.ExecuteContext(t.Context()) - - require.NoError(t, err) - assert.True(t, ctx.Table) - assert.False(t, ctx.ToLatency) - assert.False(t, ctx.ToJSON) - assert.Empty(t, w.String()) - }) + for _, status := range []globalping.TestStatus{globalping.TestStatusFinished, globalping.TestStatusFailed} { + name := string(measurementType) + "/" + string(status) + + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + expectedOpts := createDefaultMeasurementCreate(measurementType) + locations, ok := expectedOpts.Locations.(globalping.LocationOptions) + require.True(t, ok) + locations[0].Magic = "world" + + switch measurementType { + case "dns": + expectedOpts.Options.Query = &globalping.QueryOptions{} + case "http": + expectedOpts.Options.Request = &globalping.RequestOptions{Headers: map[string]string{}} + } + + expectedResponse := createDefaultMeasurementCreateResponse() + expectedMeasurement := createDefaultMeasurement(measurementType) + + for i := range expectedMeasurement.Results { + expectedMeasurement.Results[i].Result.Status = status + } + + gbMock := apiMocks.NewMockClient(ctrl) + gbMock.EXPECT().CreateMeasurement(t.Context(), expectedOpts).Return(expectedResponse, nil) + gbMock.EXPECT().GetMeasurement(t.Context(), expectedResponse.ID).Return(expectedMeasurement, nil) + + viewerMock := viewMocks.NewMockViewer(ctrl) + viewerMock.EXPECT().OutputTable(expectedMeasurement).Return("", nil) + viewerMock.EXPECT().OutputShare() + + utilsMock := utilsMocks.NewMockUtils(ctrl) + utilsMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() + + w := new(bytes.Buffer) + ctx := createDefaultContext() + storage := createDefaultTestStorage(t, utilsMock) + root := NewRoot(view.NewPrinter(nil, w, w), ctx, viewerMock, utilsMock, gbMock, nil, storage) + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"globalping", string(measurementType), "jsdelivr.com", "--table", "--latency", "--json", "--ci"} + + err := root.Cmd.ExecuteContext(t.Context()) + + require.NoError(t, err) + assert.True(t, ctx.Table) + assert.False(t, ctx.ToLatency) + assert.False(t, ctx.ToJSON) + assert.Empty(t, w.String()) + }) + } } } @@ -142,13 +151,18 @@ func Test_HandleMeasurement_TableTakesOutputPrecedence(t *testing.T) { assert.False(t, root.Cmd.SilenceErrors) }) - t.Run("all-failed table output suppresses error text", func(t *testing.T) { + t.Run("all-failed table output completes successfully", func(t *testing.T) { ctrl := gomock.NewController(t) measurement := createDefaultMeasurement("ping") + + for i := range measurement.Results { + measurement.Results[i].Result.Status = globalping.TestStatusFailed + } + client := apiMocks.NewMockClient(ctrl) client.EXPECT().GetMeasurement(t.Context(), measurement.ID).Return(measurement, nil) viewer := viewMocks.NewMockViewer(ctrl) - viewer.EXPECT().OutputTable(measurement).Return("", view.ErrAllProbesFailed) + viewer.EXPECT().OutputTable(measurement).Return("", nil) viewer.EXPECT().OutputShare() ctx := createDefaultContext() ctx.Table = true @@ -156,9 +170,9 @@ func Test_HandleMeasurement_TableTakesOutputPrecedence(t *testing.T) { utils.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() root := NewRoot(view.NewPrinter(nil, new(bytes.Buffer), new(bytes.Buffer)), ctx, viewer, utils, client, nil, nil) - require.ErrorIs(t, root.handleMeasurement(t.Context(), measurement.ID, nil), view.ErrAllProbesFailed) - assert.True(t, root.Cmd.SilenceUsage) - assert.True(t, root.Cmd.SilenceErrors) + require.NoError(t, root.handleMeasurement(t.Context(), measurement.ID, nil)) + assert.False(t, root.Cmd.SilenceUsage) + assert.False(t, root.Cmd.SilenceErrors) }) t.Run("polling error finalizes table output", func(t *testing.T) { diff --git a/view/default.go b/view/default.go index f0098ed..8483b6c 100644 --- a/view/default.go +++ b/view/default.go @@ -20,6 +20,12 @@ func (v *viewer) OutputDefault(id string, measurement *globalping.Measurement, o v.printer.ErrPrintln(v.getProbeInfo(result)) + if result.Result.Status == globalping.TestStatusFailed { + v.printer.Println(result.Result.RawOutput) + + continue + } + if v.ctx.Cmd == "http" { switch { case v.ctx.Full: diff --git a/view/default_test.go b/view/default_test.go index e4c50f2..203d655 100644 --- a/view/default_test.go +++ b/view/default_test.go @@ -73,6 +73,37 @@ Body 2 `, w.String()) } +func Test_Output_Default_HTTP_Get_FailedUsesCompleteRawOutput(t *testing.T) { + measurement := &globalping.Measurement{Results: []globalping.ProbeMeasurement{ + { + Probe: globalping.ProbeDetails{Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network"}, + Result: globalping.ProbeResult{ + Status: globalping.TestStatusFailed, + FailureSource: globalping.FailureSourceResolver, + RawOutput: "first failure line\nsecond failure line", + RawBody: pointerTo("successful body formatting must be skipped"), + }, + }, + { + Probe: globalping.ProbeDetails{Continent: "NA", Country: "US", City: "New York", ASN: 456, Network: "Other Network"}, + Result: globalping.ProbeResult{Status: globalping.TestStatusFailed, RawBody: pointerTo("body must also be skipped")}, + }, + }} + opts := &globalping.MeasurementCreate{Options: &globalping.MeasurementOptions{ + Request: &globalping.RequestOptions{Method: http.MethodGet}, + }} + w := new(bytes.Buffer) + errW := new(bytes.Buffer) + printer := NewPrinter(nil, w, errW) + printer.DisableStyling() + viewer := NewViewer(&Context{Cmd: "http", CIMode: true}, printer, nil) + + viewer.OutputDefault(measurementID1, measurement, opts) + + assert.Equal(t, "> Berlin, DE, EU, Network (AS123) — Resolver error\n> New York, US, NA, Other Network (AS456) — Error\n", errW.String()) + assert.Equal(t, "first failure line\nsecond failure line\n\n\n", w.String()) +} + func Test_Output_Default_HTTP_Get_Share(t *testing.T) { measurement := &globalping.Measurement{ Results: []globalping.ProbeMeasurement{ diff --git a/view/infinite.go b/view/infinite.go index 26667fb..1aab5c8 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -29,70 +29,80 @@ func (v *viewer) OutputInfinite(measurement *globalping.Measurement) (string, er } } - if v.ctx.ToLatency { - return v.outputInfinitePingLatency(measurement) - } + streaming := len(measurement.Results) == 1 && !v.ctx.Table && !v.ctx.ToLatency - if len(measurement.Results) == 1 && !v.ctx.Table { - if measurement.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(measurement) { - return "", v.outputFailSummary(measurement) + if measurement.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(measurement) { + if !streaming { + v.clearInfiniteTableOutput() } + return "", v.outputFailSummary(measurement) + } + + if streaming { v.outputStreamingPackets(measurement) return "", nil } - allProbesFailed := measurement.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(measurement) + stats := v.processInfinitePingMeasurement(measurement) - if allProbesFailed && !hasFailedPingStats(measurement) { - v.clearInfiniteTableOutput() - - return "", v.outputFailSummary(measurement) + if v.ctx.ToLatency { + return v.outputInfinitePingLatencyTable(measurement, stats) } - stats := v.processInfinitePingMeasurement(measurement, true) - return v.outputInfinitePingTableView(stats) } type infinitePingProbeStats struct { - measurement *globalping.ProbeMeasurement - stats *MeasurementStats - statsAvailable bool + measurement *globalping.ProbeMeasurement + stats *MeasurementStats + statusMessage string } type infinitePingStats struct { probes []infinitePingProbeStats } -func hasFailedPingStats(m *globalping.Measurement) bool { - for i := range m.Results { - if m.Results[i].Result.Status == globalping.TestStatusFailed { - if _, ok := decodePingMeasurementStats(&m.Results[i].Result); ok { - return true - } - } - } - - return false +type infinitePingProbeStatus struct { + order uint64 + message string } -func decodePingMeasurementStats(result *globalping.ProbeResult) (*MeasurementStats, bool) { +func decodePingMeasurementStats(result *globalping.ProbeResult) *MeasurementStats { stats, err := globalping.DecodePingStats(result.StatsRaw) + timings, timingsErr := globalping.DecodePingTimings(result.TimingsRaw) + decoded := NewMeasurementStats() if err != nil || stats.Total == 0 { - return nil, false + if timingsErr != nil || len(timings) == 0 { + return nil + } + + // A command timeout can leave replies but no final summary. + for _, timing := range timings { + decoded.Min = math.Min(decoded.Min, timing.RTT) + decoded.Max = math.Max(decoded.Max, timing.RTT) + decoded.Tsum += timing.RTT + decoded.Tsum2 += timing.RTT * timing.RTT + } + + decoded.Sent = len(timings) + decoded.Rcv = len(timings) + decoded.Last = timings[len(timings)-1].RTT + decoded.Avg = decoded.Tsum / float64(decoded.Rcv) + decoded.Mdev = computeMdev(decoded.Tsum, decoded.Tsum2, decoded.Rcv, decoded.Avg) + + return decoded } - decoded := NewMeasurementStats() decoded.Sent = stats.Total decoded.Rcv = stats.Rcv decoded.Lost = stats.Drop decoded.Loss = stats.Loss if stats.Rcv == 0 { - return decoded, true + return decoded } decoded.Mdev = stats.Mdev @@ -111,14 +121,28 @@ func decodePingMeasurementStats(result *globalping.ProbeResult) (*MeasurementSta decoded.Tsum2 = float64(stats.Rcv) * (stats.Mdev*stats.Mdev + (*stats.Avg)*(*stats.Avg)) } - if timings, err := globalping.DecodePingTimings(result.TimingsRaw); err == nil && len(timings) > 0 { + if timingsErr == nil && len(timings) > 0 { decoded.Last = timings[len(timings)-1].RTT } - return decoded, true + return decoded } -func (v *viewer) processInfinitePingMeasurement(m *globalping.Measurement, useFailedPacketStats bool) *infinitePingStats { +func (v *viewer) processInfinitePingMeasurement(m *globalping.Measurement) *infinitePingStats { + if v.pingRoundOrders == nil { + v.pingRoundOrders = make(map[string]uint64) + v.pingProbeStatuses = make([]infinitePingProbeStatus, len(m.Results)) + } + + order, knownRound := v.pingRoundOrders[m.ID] + + if !knownRound { + // Rounds are first polled in creation order, but may finish out of order. + v.pingRoundCount++ + order = v.pingRoundCount + v.pingRoundOrders[m.ID] = order + } + if len(v.ctx.AggregatedStats) == 0 { v.ctx.AggregatedStats = make([]*MeasurementStats, len(m.Results)) @@ -140,33 +164,43 @@ func (v *viewer) processInfinitePingMeasurement(m *globalping.Measurement, useFa for i := range m.Results { probeMeasurement := &m.Results[i] - resultStats, hasPacketStats := decodePingMeasurementStats(&probeMeasurement.Result) + var resultStats *MeasurementStats if probeMeasurement.Result.Status == globalping.TestStatusInProgress { resultStats = v.parsePingRawOutput(measurementHistory, probeMeasurement, -1).Stats + } else { + resultStats = decodePingMeasurementStats(&probeMeasurement.Result) } - statsUnavailable := (probeMeasurement.Result.Status == globalping.TestStatusFailed || probeMeasurement.Result.Status == globalping.TestStatusOffline) && (!useFailedPacketStats || !hasPacketStats) + if resultStats == nil { + resultStats = NewMeasurementStats() + } - if statsUnavailable { - preservedStats := *v.ctx.AggregatedStats[i] - newAggregatedStats[i] = &preservedStats - newStats[i] = NewMeasurementStats() - processed.probes[i] = infinitePingProbeStats{measurement: probeMeasurement} + terminalFailure := probeMeasurement.Result.Status == globalping.TestStatusFailed || probeMeasurement.Result.Status == globalping.TestStatusOffline - continue + if terminalFailure { + addMissingInfinitePackets(resultStats, v.ctx.Packets) } - if resultStats == nil { - resultStats = NewMeasurementStats() + probeStatus := &v.pingProbeStatuses[i] + + if order >= probeStatus.order { + switch { + case terminalFailure: + probeStatus.order = order + probeStatus.message = resultStatusLabel(&probeMeasurement.Result) + case probeMeasurement.Result.Status == globalping.TestStatusFinished: + probeStatus.order = order + probeStatus.message = "" + } } newAggregatedStats[i] = mergeMeasurementStats(*v.ctx.AggregatedStats[i], resultStats) newStats[i] = resultStats processed.probes[i] = infinitePingProbeStats{ - measurement: probeMeasurement, - stats: v.aggregateConcurrentStats(newAggregatedStats[i], i, m.ID), - statsAvailable: true, + measurement: probeMeasurement, + stats: v.aggregateConcurrentStats(newAggregatedStats[i], i, m.ID), + statusMessage: probeStatus.message, } } @@ -176,21 +210,21 @@ func (v *viewer) processInfinitePingMeasurement(m *globalping.Measurement, useFa if m.Status != globalping.MeasurementStatusInProgress { v.ctx.AggregatedStats = newAggregatedStats + delete(v.pingRoundOrders, m.ID) } return processed } -func (v *viewer) outputInfinitePingLatency(m *globalping.Measurement) (string, error) { - if m.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(m) { - v.clearInfiniteTableOutput() - - return "", v.outputFailSummary(m) +func addMissingInfinitePackets(stats *MeasurementStats, expected int) { + if expected <= stats.Sent { + return } - stats := v.processInfinitePingMeasurement(m, false) - - return v.outputInfinitePingLatencyTable(m, stats) + missing := expected - stats.Sent + stats.Sent += missing + stats.Lost += missing + stats.Loss = float64(stats.Lost) / float64(stats.Sent) * 100 } func (v *viewer) outputStreamingPackets(m *globalping.Measurement) { diff --git a/view/infinite_test.go b/view/infinite_test.go index c898fdd..c4232d7 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -138,6 +138,7 @@ func Test_OutputInfinite_SingleProbe_Failed(t *testing.T) { measurement := createPingMeasurement(measurementID1) measurement.Status = globalping.MeasurementStatusFinished measurement.Results[0].Result.Status = globalping.TestStatusFailed + measurement.Results[0].Result.FailureSource = globalping.FailureSourceResolver measurement.Results[0].Result.RawOutput = `ping: cdn.jsdelivr.net.xc: Name or service not known` ctx := createDefaultContext("ping") @@ -150,7 +151,7 @@ func Test_OutputInfinite_SingleProbe_Failed(t *testing.T) { assert.Equal(t, "all probes failed", err.Error()) assert.Equal(t, - `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) — Resolver error ping: cdn.jsdelivr.net.xc: Name or service not known `, w.String(), @@ -191,8 +192,8 @@ func Test_DecodePingMeasurementStats_PreservesNullableCells(t *testing.T) { TimingsRaw: json.RawMessage(`[{"rtt":1.234}]`), } - stats, ok := decodePingMeasurementStats(result) - assert.True(t, ok) + stats := decodePingMeasurementStats(result) + require.NotNil(t, stats) merged := mergeMeasurementStats(*NewMeasurementStats(), stats) assert.Equal(t, [7]string{"", "1", "0.00%", "1.23 ms", "-", "-", "-"}, pingTableRowValues(merged, true)) @@ -204,8 +205,8 @@ func Test_DecodePingMeasurementStats_PreservesZeroValues(t *testing.T) { TimingsRaw: json.RawMessage(`[{"rtt":0}]`), } - stats, ok := decodePingMeasurementStats(result) - assert.True(t, ok) + stats := decodePingMeasurementStats(result) + require.NotNil(t, stats) merged := mergeMeasurementStats(*NewMeasurementStats(), stats) assert.Equal(t, [7]string{"", "1", "0.00%", "0.00 ms", "0.00 ms", "0.00 ms", "0.00 ms"}, pingTableRowValues(merged, true)) @@ -256,13 +257,13 @@ func Test_OutputInfinite_Table_AllFailedWithoutStats(t *testing.T) { assert.EqualError(t, err, "all probes failed") assert.Equal(t, failureOutput, w.String()) - assert.Equal(t, "\033[2A\033[0J"+`> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + assert.Equal(t, "\033[2A\033[0J"+`> Berlin, DE, EU, Deutsche Telekom AG (AS3320) — Error ping: unknown host `, failureOutput) assert.Equal(t, 0, ctx.TableOutputRows) } -func Test_OutputInfinite_Table_FailedWithPacketStats(t *testing.T) { +func Test_OutputInfinite_Table_AllFailedWithPacketStatsTerminates(t *testing.T) { measurement := createPingMeasurement(measurementID1) measurement.Results[0].Result.Status = globalping.TestStatusFailed measurement.Results[0].Result.RawOutput = "The measurement command timed out." @@ -277,23 +278,22 @@ func Test_OutputInfinite_Table_FailedWithPacketStats(t *testing.T) { viewer := NewViewer(ctx, printer, nil) output, err := viewer.OutputInfinite(measurement) - require.NoError(t, err) - viewer.OutputSummary(output) - assert.Contains(t, w.String(), "Location") - assert.Contains(t, w.String(), "| 1 | 0.00% | 17.6 ms") - assert.NotContains(t, w.String(), measurement.Results[0].Result.RawOutput) - assert.Equal(t, 1, ctx.TableOutputRows) - assert.Equal(t, 1, ctx.AggregatedStats[0].Sent) - assert.Equal(t, 1, ctx.AggregatedStats[0].Rcv) + assert.ErrorIs(t, err, ErrAllProbesFailed) + assert.Empty(t, output) + assert.Contains(t, w.String(), " — Error\n"+measurement.Results[0].Result.RawOutput) + assert.NotContains(t, w.String(), "Location") + assert.Equal(t, 0, ctx.TableOutputRows) + assert.Nil(t, ctx.AggregatedStats) } -func Test_OutputInfinite_FailedProbePreservesPriorAggregate(t *testing.T) { +func Test_OutputInfinite_FailedProbeCountsPartialRoundAndRestoresAggregate(t *testing.T) { first := createPingMeasurement_MultipleProbes(measurementID1) first.Results = first.Results[:2] first.ProbesCount = len(first.Results) ctx := createDefaultContext("ping") ctx.Infinite = true + ctx.Packets = 16 w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() @@ -302,22 +302,83 @@ func Test_OutputInfinite_FailedProbePreservesPriorAggregate(t *testing.T) { _, err := viewer.OutputInfinite(first) require.NoError(t, err) assert.True(t, ctx.Table) - priorAggregate := *ctx.AggregatedStats[1] - second := createPingMeasurement_MultipleProbes(measurementID2) second.Results = second.Results[:2] second.ProbesCount = len(second.Results) second.Results[1].Result.Status = globalping.TestStatusFailed + second.Results[1].Result.FailureSource = globalping.FailureSourceTarget second.Results[1].Result.RawOutput = "probe failed" - second.Results[1].Result.StatsRaw = nil - second.Results[1].Result.TimingsRaw = nil + second.Results[1].Result.StatsRaw = json.RawMessage(`{"min":null,"avg":null,"max":null,"total":0,"rcv":0,"drop":0,"loss":0}`) + second.Results[1].Result.TimingsRaw = json.RawMessage(`[{"rtt":5},{"rtt":7}]`) ctx.History.Push(&HistoryItem{Id: measurementID2, StartedAt: defaultCurrentTime}) - _, err = viewer.OutputInfinite(second) + failedOutput, err := viewer.OutputInfinite(second) require.NoError(t, err) + assert.Contains(t, failedOutput, "--- Target error ---") + assert.Equal(t, 17, ctx.AggregatedStats[1].Sent) + assert.Equal(t, 3, ctx.AggregatedStats[1].Rcv) + assert.Equal(t, 14, ctx.AggregatedStats[1].Lost) + assert.InDelta(t, (5.457+5+7)/3, ctx.AggregatedStats[1].Avg, 0.0001) + assert.InDelta(t, 29.778849+25+49, ctx.AggregatedStats[1].Tsum2, 0.0001) + + thirdMeasurementID := "3sZfUs3cnzGz1I20" + third := createPingMeasurement_MultipleProbes(thirdMeasurementID) + third.Results = third.Results[:2] + third.ProbesCount = len(third.Results) + ctx.History.Push(&HistoryItem{Id: thirdMeasurementID, StartedAt: defaultCurrentTime}) + + finishedResult := third.Results[1].Result + third.Status = globalping.MeasurementStatusInProgress + third.Results[1].Result = globalping.ProbeResult{Status: globalping.TestStatusInProgress} + + for range 2 { + pendingOutput, pendingErr := viewer.OutputInfinite(third) + require.NoError(t, pendingErr) + assert.Contains(t, pendingOutput, "--- Target error ---") + assert.Equal(t, 17, ctx.AggregatedStats[1].Sent) + assert.Equal(t, 3, ctx.AggregatedStats[1].Rcv) + } + + third.Status = globalping.MeasurementStatusFinished + third.Results[1].Result = finishedResult + + restoredOutput, err := viewer.OutputInfinite(third) + require.NoError(t, err) + assert.NotContains(t, restoredOutput, "Target error") + assert.Contains(t, restoredOutput, "| 18 | 77.78%") + assert.Equal(t, 18, ctx.AggregatedStats[1].Sent) +} + +func Test_OutputInfinite_MultipleProbesShowsFailedAndOfflineStatusRows(t *testing.T) { + measurement := createPingMeasurement_MultipleProbes(measurementID1) + measurement.Results[1].Result.Status = globalping.TestStatusFailed + measurement.Results[1].Result.FailureSource = globalping.FailureSourceInternal + measurement.Results[1].Result.RawOutput = "internal details" + measurement.Results[1].Result.StatsRaw = nil + measurement.Results[1].Result.TimingsRaw = nil + measurement.Results[2].Result.Status = globalping.TestStatusOffline + measurement.Results[2].Result.RawOutput = "offline details" + measurement.Results[2].Result.StatsRaw = nil + measurement.Results[2].Result.TimingsRaw = nil + ctx := createDefaultContext("ping") + ctx.Infinite = true + ctx.Packets = 16 + w := new(bytes.Buffer) + printer := NewPrinter(nil, w, w) + printer.DisableStyling() + viewer := NewViewer(ctx, printer, nil) - assert.Equal(t, priorAggregate, *ctx.AggregatedStats[1]) - assert.Equal(t, NewMeasurementStats(), ctx.History.Find(measurementID2).Stats[1]) + output, err := viewer.OutputInfinite(measurement) + + require.NoError(t, err) + assert.Contains(t, output, "--- Internal error ---") + assert.Contains(t, output, "--- Probe offline ---") + assert.NotContains(t, output, "internal details") + assert.NotContains(t, output, "offline details") + assert.Equal(t, 16, ctx.AggregatedStats[1].Sent) + assert.Equal(t, 16, ctx.AggregatedStats[1].Lost) + assert.Equal(t, 16, ctx.AggregatedStats[2].Sent) + assert.Equal(t, 16, ctx.AggregatedStats[2].Lost) } func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { @@ -644,11 +705,11 @@ func Test_OutputInfinite_MultipleProbes_All_Failed(t *testing.T) { _, err := v.OutputInfinite(measurement) assert.Equal(t, "all probes failed", err.Error()) - assert.Equal(t, `> London, GB, EU, OVH SAS (AS0) + assert.Equal(t, `> London, GB, EU, OVH SAS (AS0) — Error ping: cdn.jsdelivr.net.xc: Name or service not known -> Falkenstein, DE, EU, Hetzner Online GmbH (AS0) +> Falkenstein, DE, EU, Hetzner Online GmbH (AS0) — Error ping: cdn.jsdelivr.net.xc: Name or service not known -> Nuremberg, DE, EU, Hetzner Online GmbH (AS0) +> Nuremberg, DE, EU, Hetzner Online GmbH (AS0) — Error ping: cdn.jsdelivr.net.xc: Name or service not known `, w.String()) @@ -671,7 +732,7 @@ func Test_OutputInfinite_SingleProbe_Offline(t *testing.T) { assert.Equal(t, "all probes failed", err.Error()) assert.Equal(t, - `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) + `> Berlin, DE, EU, Deutsche Telekom AG (AS3320) — Probe offline This probe is currently offline. Please try again later. `, w.String(), @@ -959,3 +1020,51 @@ func assertMeasurementStatsSlice(t *testing.T, expected []*MeasurementStats, act assertMeasurementStats(t, expected[i], actual[i]) } } + +func Test_OutputInfinite_LateOlderResultPreservesNewerStatus(t *testing.T) { + for _, newerStatus := range []globalping.TestStatus{globalping.TestStatusFinished, globalping.TestStatusFailed, globalping.TestStatusOffline} { + t.Run(string(newerStatus), func(t *testing.T) { + ctx := createDefaultContext("ping") + ctx.Infinite = true + ctx.Packets = 16 + printer := NewPrinter(nil, new(bytes.Buffer), new(bytes.Buffer)) + printer.DisableStyling() + v := &viewer{ctx: ctx, printer: printer} + + older := createPingMeasurement_MultipleProbes(measurementID1) + older.Status = globalping.MeasurementStatusInProgress + older.Results[1].Result = globalping.ProbeResult{Status: globalping.TestStatusInProgress} + _, err := v.OutputInfinite(older) + require.NoError(t, err) + + newer := createPingMeasurement_MultipleProbes(measurementID2) + newer.Results[1].Result.Status = newerStatus + newer.Results[1].Result.FailureSource = globalping.FailureSourceInternal + ctx.History.Push(&HistoryItem{Id: measurementID2, StartedAt: defaultCurrentTime}) + _, err = v.OutputInfinite(newer) + require.NoError(t, err) + assert.Len(t, v.pingRoundOrders, 1) + + older.Status = globalping.MeasurementStatusFinished + older.Results[1].Result = createPingMeasurement_MultipleProbes(measurementID1).Results[1].Result + + if newerStatus == globalping.TestStatusFinished { + older.Results[1].Result.Status = globalping.TestStatusFailed + older.Results[1].Result.FailureSource = globalping.FailureSourceTarget + } + + output, err := v.OutputInfinite(older) + require.NoError(t, err) + assert.Empty(t, v.pingRoundOrders) + + switch newerStatus { + case globalping.TestStatusFinished: + assert.NotContains(t, output, "error") + case globalping.TestStatusFailed: + assert.Contains(t, output, "--- Internal error ---") + case globalping.TestStatusOffline: + assert.Contains(t, output, "--- Probe offline ---") + } + }) + } +} diff --git a/view/latency_test.go b/view/latency_test.go index 016a2c3..061c784 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -384,3 +384,27 @@ func Test_Output_Latency_Offline(t *testing.T) { `, errW.String()) assert.Equal(t, "This probe is currently offline. Please try again later.\n\n", w.String()) } + +func Test_Output_Latency_Failed(t *testing.T) { + measurement := &globalping.Measurement{Results: []globalping.ProbeMeasurement{{ + Probe: globalping.ProbeDetails{Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network"}, + Result: globalping.ProbeResult{ + Status: globalping.TestStatusFailed, + FailureSource: globalping.FailureSourceTarget, + RawOutput: "first failure line\nsecond failure line", + StatsRaw: json.RawMessage(`{"min":1,"avg":2,"max":3}`), + }, + }}} + w := new(bytes.Buffer) + errW := new(bytes.Buffer) + printer := NewPrinter(nil, w, errW) + printer.DisableStyling() + viewer := NewViewer(&Context{Cmd: "ping", ToLatency: true}, printer, nil) + + err := viewer.OutputLatency(measurementID1, measurement) + + require.NoError(t, err) + assert.Equal(t, "> Berlin, DE, EU, Network (AS123) — Target error\n", errW.String()) + assert.Equal(t, "first failure line\nsecond failure line\n\n", w.String()) + assert.NotContains(t, w.String(), "Min:") +} diff --git a/view/output.go b/view/output.go index db840d7..5a58dc3 100644 --- a/view/output.go +++ b/view/output.go @@ -15,7 +15,7 @@ var ErrAllProbesFailed = errors.New("all probes failed") func (v *viewer) outputFailSummary(m *globalping.Measurement) error { for i := range m.Results { - v.printer.ErrPrintln(v.getProbeInfo(&m.Results[i])) + v.printer.ErrPrintln(v.getProbeInfoWithTerminalStatus(&m.Results[i])) v.printer.Println(m.Results[i].Result.RawOutput) } @@ -34,25 +34,55 @@ func isSomeTestFinished(m *globalping.Measurement) bool { func (v *viewer) OutputLive(measurement *globalping.Measurement, opts *globalping.MeasurementCreate, w, h int) { output := &strings.Builder{} + headerLines := make(map[int]bool) + lineCount := 0 // Output every result in case of multiple probes for i := range measurement.Results { result := &measurement.Results[i] - // Output slightly different format if state is available - output.WriteString(v.getProbeInfo(result) + "\n") + // Keep headers plain until both width and height have been trimmed. + header := normalizeTableLocation(getProbeInfoText(result)) + status := "" - if v.isBodyOnlyHttpGet(opts) { + if result.Result.Status == globalping.TestStatusFailed { + status = " — " + resultStatusLabel(&result.Result) + } + + maxW := w - 4 + status = runewidth.Truncate(status, max(maxW, 0), "") + header = truncateTableCell(strings.ReplaceAll(header, "\t", " "), max(maxW-runewidth.StringWidth(status), 0)) + headerLines[lineCount] = true + output.WriteString(header + status + "\n") + bodyStart := output.Len() + + switch { + case result.Result.Status == globalping.TestStatusFailed: + output.WriteString(result.Result.RawOutput + "\n\n") + case v.isBodyOnlyHttpGet(opts): if result.Result.RawBody != nil { output.WriteString(strings.TrimSpace(*result.Result.RawBody)) } output.WriteString("\n\n") - } else { + default: output.WriteString(strings.TrimSpace(result.Result.RawOutput) + "\n\n") } + + lineCount += 1 + strings.Count(output.String()[bodyStart:], "\n") + } + + trimmed := trimOutput(output, w, h) + lines := strings.Split(*trimmed, "\n") + firstLine := lineCount + 1 - len(lines) + + for i := range lines { + if headerLines[firstLine+i] { + lines[i] = v.printer.BoldForeground(lines[i], BGYellow) + } } - v.printer.AreaUpdate(trimOutput(output, w, h)) + text := strings.Join(lines, "\n") + v.printer.AreaUpdate(&text) } // Used to trim the output to fit the terminal in live view @@ -72,14 +102,8 @@ func trimOutput(output *strings.Builder, terminalW, terminalH int) *string { lines = lines[len(lines)-maxH:] } - for i := 0; i < len(lines); i++ { - rWidth := runewidth.StringWidth(lines[i]) - - if rWidth > maxW { - line := lines[i] - trimmedLine := lines[i][:len(line)-rWidth+maxW] - lines[i] = trimmedLine - } + for i := range lines { + lines[i] = runewidth.Truncate(lines[i], maxW, "") } txt := strings.Join(lines, "\n") @@ -88,6 +112,24 @@ func trimOutput(output *strings.Builder, terminalW, terminalH int) *string { } func (v *viewer) getProbeInfo(result *globalping.ProbeMeasurement) string { + return v.getProbeInfoWithStatus(result, false) +} + +func (v *viewer) getProbeInfoWithTerminalStatus(result *globalping.ProbeMeasurement) string { + return v.getProbeInfoWithStatus(result, true) +} + +func (v *viewer) getProbeInfoWithStatus(result *globalping.ProbeMeasurement, includeOffline bool) string { + text := getProbeInfoText(result) + + if result.Result.Status == globalping.TestStatusFailed || includeOffline && result.Result.Status == globalping.TestStatusOffline { + text += " — " + resultStatusLabel(&result.Result) + } + + return v.printer.BoldForeground(text, BGYellow) +} + +func getProbeInfoText(result *globalping.ProbeMeasurement) string { var output strings.Builder output.WriteString("> ") output.WriteString(getLocationText(result)) @@ -118,7 +160,24 @@ func (v *viewer) getProbeInfo(result *globalping.ProbeMeasurement) string { } } - return v.printer.BoldForeground(output.String(), BGYellow) + return output.String() +} + +func resultStatusLabel(result *globalping.ProbeResult) string { + if result.Status == globalping.TestStatusOffline { + return "Probe offline" + } + + switch result.FailureSource { + case globalping.FailureSourceTarget: + return "Target error" + case globalping.FailureSourceResolver: + return "Resolver error" + case globalping.FailureSourceInternal: + return "Internal error" + default: + return "Error" + } } func (v *viewer) getShareMessage(id string) string { diff --git a/view/output_test.go b/view/output_test.go index ab38b5e..41b223d 100644 --- a/view/output_test.go +++ b/view/output_test.go @@ -3,6 +3,9 @@ package view import ( "strings" "testing" + "unicode/utf8" + + "github.com/mattn/go-runewidth" "github.com/jsdelivr/globalping-go" "github.com/stretchr/testify/assert" @@ -88,6 +91,55 @@ func Test_OutputLive_NilHTTPBodiesAndProbeStates(t *testing.T) { assert.NotContains(t, w.String(), "%!") } +func Test_OutputLive_HTTPFailureUsesCompleteRawOutput(t *testing.T) { + w := new(strings.Builder) + printer := NewPrinter(nil, w, w) + printer.DisableStyling() + v := viewer{ctx: &Context{Cmd: "http"}, printer: printer} + measurement := &globalping.Measurement{Results: []globalping.ProbeMeasurement{{ + Probe: globalping.ProbeDetails{Continent: "EU", Country: "DE", City: "Berlin", ASN: 123, Network: "Network"}, + Result: globalping.ProbeResult{ + Status: globalping.TestStatusFailed, + FailureSource: globalping.FailureSourceInternal, + RawOutput: "first failure line\nsecond failure line", + RawBody: pointerTo("body must be skipped"), + }, + }}} + opts := &globalping.MeasurementCreate{Options: &globalping.MeasurementOptions{ + Request: &globalping.RequestOptions{Method: "GET"}, + }} + + v.OutputLive(measurement, opts, 80, 24) + + assert.Equal(t, "> Berlin, DE, EU, Network (AS123) — Internal error\nfirst failure line\nsecond failure line\n\n", w.String()) + + measurement.Results[0].Probe.Network = "Deutsche Telekom AG" + measurement.Results[0].Probe.ASN = 3320 + measurement.Results[0].Result.FailureSource = globalping.FailureSourceResolver + + for _, network := range []string{"Deutsche Telekom AG", strings.Repeat("网络é", 30)} { + measurement.Results[0].Probe.Network = network + + for _, width := range []int{50, 80, 120} { + w.Reset() + v.printer = NewPrinter(nil, w, w) + v.OutputLive(measurement, opts, width, 24) + header := strings.Split(w.String(), "\n")[0] + assert.True(t, strings.HasSuffix(header, " — Resolver error\x1b[0m"), header) + plain := strings.TrimSuffix(strings.TrimPrefix(header, "\x1b[1;38;5;43m"), "\x1b[0m") + assert.NotContains(t, plain, "\x1b") + assert.True(t, utf8.ValidString(plain)) + assert.LessOrEqual(t, runewidth.StringWidth(plain), width-4) + } + } + + // Height clipping must not apply header styling to retained body lines. + w.Reset() + v.printer = NewPrinter(nil, w, w) + v.OutputLive(measurement, opts, 80, 7) + assert.Equal(t, "second failure line\n\n", w.String()) +} + func Test_TrimOutput(t *testing.T) { output := &strings.Builder{} output.WriteString(`> London, GB, EU, Network (AS12345) @@ -128,7 +180,7 @@ some text f`) expectedRes := `> London, GB, EU, Network (AS12345) some text a -中文互联文互联网高质量的问答社区和创 作者聚集的原创内容平台于201 1年1月正式上线让人们更 好的分享 知识经验和见解到自己的解答」中文互联网高质量的问答社区和创作者聚集的原 +中文互联文互联网高质量的问答社区和创 作者聚集的原创内容平台于201 1年1月正式上线 some text e some text f` diff --git a/view/table.go b/view/table.go index f7982e7..660a300 100644 --- a/view/table.go +++ b/view/table.go @@ -41,13 +41,6 @@ type tableRenderOptions struct { func (v *viewer) OutputTable(measurement *globalping.Measurement) (string, error) { v.ctx.TableOutputRows = 0 - allProbesFailed := measurement.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(measurement) - renderFailedTable := allProbesFailed && v.ctx.Table - - if allProbesFailed && !renderFailedTable { - return "", v.outputFailSummary(measurement) - } - if measurement.Type == "ping" { if err := validateFinishedPingStats(measurement); err != nil { return "", err @@ -57,10 +50,6 @@ func (v *viewer) OutputTable(measurement *globalping.Measurement) (string, error v.ctx.TableOutputRows = len(measurement.Results) v.outputTableView(measurement) - if renderFailedTable { - return "", ErrAllProbesFailed - } - return "", nil } @@ -143,8 +132,8 @@ func (v *viewer) renderInfinitePingTableVariants(stats *infinitePingStats, areaW var liveRow []string var completedRow []string - if !probeStats.statsAvailable { - liveRow = []string{"", "-", "-", "-", "-", "-", "-"} + if probeStats.statusMessage != "" { + liveRow = []string{"", probeStats.statusMessage} completedRow = append([]string(nil), liveRow...) } else { liveValues := pingTableRowValues(probeStats.stats, false) @@ -297,8 +286,8 @@ func tableRow(measurementType globalping.MeasurementType, trace bool, columns in row[0] = getLocationText(measurement) - if measurement.Result.Status == globalping.TestStatusFailed { - return []string{row[0], failureTableMessage(&measurement.Result)} + if measurement.Result.Status == globalping.TestStatusFailed || measurement.Result.Status == globalping.TestStatusOffline { + return []string{row[0], resultStatusLabel(&measurement.Result)} } if measurement.Result.Status != globalping.TestStatusFinished { @@ -326,19 +315,6 @@ func tableRow(measurementType globalping.MeasurementType, trace bool, columns in return row } -func failureTableMessage(result *globalping.ProbeResult) string { - switch result.FailureSource { - case globalping.FailureSourceTarget: - return "Target error" - case globalping.FailureSourceResolver: - return "Resolver error" - case globalping.FailureSourceInternal: - return "Internal error" - default: - return "Error" - } -} - type tracerouteTableTiming struct { RTT *float64 `json:"rtt"` } diff --git a/view/table_test.go b/view/table_test.go index 4988fed..360aec6 100644 --- a/view/table_test.go +++ b/view/table_test.go @@ -224,7 +224,7 @@ func Test_OutputTable_Ping_UnknownHistoryItem(t *testing.T) { } } -func Test_OutputTable_Ping_PartialOfflineUsesPlaceholders(t *testing.T) { +func Test_OutputTable_Ping_PartialOfflineShowsOfflineLabel(t *testing.T) { measurement := createPingMeasurement(measurementID1) measurement.Results = append(measurement.Results, tableProbe("Paris", "FR", "Offline Network", globalping.TestStatusOffline)) measurement.ProbesCount = len(measurement.Results) @@ -232,11 +232,10 @@ func Test_OutputTable_Ping_PartialOfflineUsesPlaceholders(t *testing.T) { output, err := renderTableForTest(t, measurement, false) require.NoError(t, err) - assertTableForTest(t, output, [][]string{ + assertTableWithFailureForTest(t, output, [][]string{ {"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}, {"Berlin, DE, EU, Deutsche Telekom AG (AS3320)", "1", "0.00%", "17.6 ms", "17.6 ms", "17.6 ms", "17.6 ms"}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-", "-", "-", "-"}, - }) + }, "Paris, FR, EU, Offline Network (AS64500)", "Probe offline") } func Test_OutputTable_Ping_PartialFailureShowsFailureLabel(t *testing.T) { @@ -256,7 +255,7 @@ func Test_OutputTable_Ping_PartialFailureShowsFailureLabel(t *testing.T) { }, "Paris, FR, EU, Failed Network (AS64500)", "Resolver error") } -func Test_FailureTableMessage_MapsFailureSources(t *testing.T) { +func Test_ResultStatusLabel_MapsFailureSources(t *testing.T) { for _, test := range []struct { name string failureSource globalping.FailureSource @@ -267,11 +266,16 @@ func Test_FailureTableMessage_MapsFailureSources(t *testing.T) { {name: "internal", failureSource: globalping.FailureSourceInternal, expected: "Internal error"}, {name: "null", expected: "Error"}, {name: "unknown", failureSource: globalping.FailureSource("other"), expected: "Error"}, + {name: "offline", expected: "Probe offline"}, } { t.Run(test.name, func(t *testing.T) { result := globalping.ProbeResult{FailureSource: test.failureSource, RawOutput: "details"} - assert.Equal(t, test.expected, failureTableMessage(&result)) + if test.name == "offline" { + result.Status = globalping.TestStatusOffline + } + + assert.Equal(t, test.expected, resultStatusLabel(&result)) }) } } @@ -334,12 +338,11 @@ func Test_OutputTable_Traceroute(t *testing.T) { output, err := renderTableForTest(t, measurement, false) require.NoError(t, err) - assertTableForTest(t, output, [][]string{ + assertTableWithFailureForTest(t, output, [][]string{ {"Location", "Hops", "Last", "Min", "Avg", "Max"}, {"Berlin, DE, EU, Trace Network (AS64500)", "3", "100 ms", "1.20 ms", "34.6 ms", "100 ms"}, {"Falkenstein, DE, EU, Timed Out Network (AS64500)", "2", tableTimeoutValue, tableTimeoutValue, tableTimeoutValue, tableTimeoutValue}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-", "-", "-"}, - }) + }, "Paris, FR, EU, Offline Network (AS64500)", "Probe offline") } func Test_OutputTable_Traceroute_LastSkipsTrailingTimeouts(t *testing.T) { @@ -400,12 +403,11 @@ func Test_OutputTable_MTR(t *testing.T) { output, err := renderTableForTest(t, measurement, false) require.NoError(t, err) - assertTableForTest(t, output, [][]string{ + assertTableWithFailureForTest(t, output, [][]string{ {"Location", "Hops", "Last", "Min", "Avg", "Max"}, {"Berlin, DE, EU, MTR Network (AS64500)", "2", "12.3 ms", "1.23 ms", "8.35 ms", "123 ms"}, {"Falkenstein, DE, EU, Timed Out Network (AS64500)", "2", tableTimeoutValue, tableTimeoutValue, tableTimeoutValue, tableTimeoutValue}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-", "-", "-"}, - }) + }, "Paris, FR, EU, Offline Network (AS64500)", "Probe offline") } func Test_OutputTable_MTR_LastSkipsTrailingTimeouts(t *testing.T) { @@ -468,12 +470,11 @@ func Test_OutputTable_DNS(t *testing.T) { output, err := renderTableForTest(t, measurement, false) require.NoError(t, err) - assertTableForTest(t, output, [][]string{ + assertTableWithFailureForTest(t, output, [][]string{ {"Location", "Status", "Answers", "Time", "Resolver"}, {"Berlin, DE, EU, DNS Network (AS64500)", "NOERROR", "2", "4.57 ms", "1.1.1.1"}, {"London, GB, EU, Empty DNS Network (AS64500)", "3", "0", "0 ms", "-"}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-", "-"}, - }) + }, "Paris, FR, EU, Offline Network (AS64500)", "Probe offline") } func Test_OutputTable_DNS_MalformedDecodedFieldsUsePlaceholders(t *testing.T) { @@ -553,15 +554,14 @@ func Test_OutputTable_HTTP(t *testing.T) { output, err := renderTableForTest(t, measurement, false) require.NoError(t, err) - assertTableForTest(t, output, [][]string{ + assertTableWithFailureForTest(t, output, [][]string{ {"Location", "Status", "Content-Length", "Total", "Resolved IP"}, {"Berlin, DE, EU, HTTP Network (AS64500)", "200 OK", "123 B", "44 ms", "192.0.2.10"}, {"London, GB, EU, Numeric Header Network (AS64500)", "204 No Content", "42 B", "5 ms", "192.0.2.11"}, {"Paris, FR, EU, Missing Header Network (AS64500)", "304", "-", "100 ms", "192.0.2.12"}, {"Prague, CZ, EU, Invalid Header Network (AS64500)", "500 Internal Server Error", "-", "1 ms", "192.0.2.13"}, {"Rome, IT, EU, Array Header Network (AS64500)", "200 OK", "12 B", "-", "-"}, - {"Madrid, ES, EU, Offline Network (AS64500)", "-", "-", "-", "-"}, - }) + }, "Madrid, ES, EU, Offline Network (AS64500)", "Probe offline") } func Test_ContentLength_MultiValue(t *testing.T) { @@ -767,37 +767,27 @@ func Test_OutputTable_AllFailedRendersFailureRows(t *testing.T) { output, ctx, err := renderTableWithContextForTest(t, measurement, measurementType == "dns", 7) - require.EqualError(t, err, "all probes failed") - assert.ErrorIs(t, err, ErrAllProbesFailed) + require.NoError(t, err) assert.Equal(t, 2, ctx.TableOutputRows) - var expectedRows [][]string + var header []string switch measurementType { case "ping": - expectedRows = [][]string{ - {"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-", "-", "-", "-"}, - } + header = []string{"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"} case "traceroute", "mtr": - expectedRows = [][]string{ - {"Location", "Hops", "Last", "Min", "Avg", "Max"}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-", "-", "-"}, - } + header = []string{"Location", "Hops", "Last", "Min", "Avg", "Max"} case "dns": - expectedRows = [][]string{ - {"Location", "Answers", "Time", "Resolver"}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-"}, - } + header = []string{"Location", "Answers", "Time", "Resolver"} case "http": - expectedRows = [][]string{ - {"Location", "Status", "Total", "Resolved IP"}, - {"Paris, FR, EU, Offline Network (AS64500)", "-", "-", "-"}, - } + header = []string{"Location", "Status", "Total", "Resolved IP"} } - assertTableWithFailureForTest(t, output, expectedRows, - "Berlin, DE, EU, Failed Network (AS64500)", "Target error") + lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n") + require.Len(t, lines, 3) + assertTableForTest(t, lines[0]+"\n", [][]string{header}) + assertSpanningRowForTest(t, lines[0], lines[1], "Paris, FR, EU, Offline Network (AS64500)", "Probe offline") + assertSpanningRowForTest(t, lines[0], lines[2], "Berlin, DE, EU, Failed Network (AS64500)", "Target error") }) } } @@ -880,12 +870,17 @@ func assertTableWithFailureForTest(t *testing.T, output string, expectedRows [][ require.Len(t, lines, len(expectedRows)+1) assertTableForTest(t, strings.Join(lines[:len(expectedRows)], "\n")+"\n", expectedRows) - headerSeparatorOffsets := displaySeparatorOffsetsForTest(lines[0]) - failureParts := strings.Split(lines[len(lines)-1], colSeparator) + assertSpanningRowForTest(t, lines[0], lines[len(lines)-1], failureLocation, failureMessage) +} + +func assertSpanningRowForTest(t *testing.T, header, row, location, message string) { + t.Helper() + headerSeparatorOffsets := displaySeparatorOffsetsForTest(header) + failureParts := strings.Split(row, colSeparator) require.Len(t, failureParts, 2) - assert.Equal(t, headerSeparatorOffsets[0], displaySeparatorOffsetsForTest(lines[len(lines)-1])[0]) - assert.Equal(t, failureLocation, strings.TrimSpace(failureParts[0])) - assert.Equal(t, "--- "+failureMessage+" ---", strings.TrimSpace(failureParts[1])) + assert.Equal(t, headerSeparatorOffsets[0], displaySeparatorOffsetsForTest(row)[0]) + assert.Equal(t, location, strings.TrimSpace(failureParts[0])) + assert.Equal(t, "--- "+message+" ---", strings.TrimSpace(failureParts[1])) leftPadding := runewidth.StringWidth(failureParts[1]) - runewidth.StringWidth(strings.TrimLeft(failureParts[1], " ")) rightPadding := runewidth.StringWidth(failureParts[1]) - runewidth.StringWidth(strings.TrimRight(failureParts[1], " ")) assert.InDelta(t, leftPadding, rightPadding, 1, "failure message must be centered") diff --git a/view/viewer.go b/view/viewer.go index 5e5d077..8dcab9c 100644 --- a/view/viewer.go +++ b/view/viewer.go @@ -20,6 +20,10 @@ type viewer struct { ctx *Context printer *Printer utils utils.Utils + + pingProbeStatuses []infinitePingProbeStatus + pingRoundOrders map[string]uint64 + pingRoundCount uint64 } func NewViewer( From 6ca249b1617f1c052b327907b5343b9c9947bd35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Tue, 15 Sep 2026 14:01:18 +0200 Subject: [PATCH 2/5] refactor: separate infinite ping run state from rendering --- cmd/install_probe_test.go | 7 +- cmd/ping.go | 23 +- cmd/ping_test.go | 48 +- cmd/root.go | 2 +- cmd/timeout_test.go | 6 +- cmd/utils_test.go | 17 +- mocks/view/mock_viewer.go | 33 +- view/context.go | 28 +- view/default_test.go | 14 +- view/history.go | 29 +- view/infinite.go | 575 +----------------- view/infinite_ping_run.go | 302 +++++++++ view/infinite_test.go | 261 ++++---- view/json_test.go | 1 - view/latency_test.go | 11 +- view/ping_stats.go | 365 +++++++++++ view/{summary.go => ping_summary.go} | 8 +- .../{summary_test.go => ping_summary_test.go} | 41 +- view/share_test.go | 20 +- view/table.go | 10 +- view/table_test.go | 6 +- view/utils_test.go | 1 - view/viewer.go | 16 +- 23 files changed, 976 insertions(+), 848 deletions(-) create mode 100644 view/infinite_ping_run.go create mode 100644 view/ping_stats.go rename view/{summary.go => ping_summary.go} (78%) rename view/{summary_test.go => ping_summary_test.go} (54%) diff --git a/cmd/install_probe_test.go b/cmd/install_probe_test.go index e5dda9d..b92fc08 100644 --- a/cmd/install_probe_test.go +++ b/cmd/install_probe_test.go @@ -44,10 +44,9 @@ Please confirm to pull and run our Docker container (globalping/globalping-probe `, w.String()) expectedCtx := &view.Context{ - History: view.NewHistoryBuffer(1), - From: "world", - Limit: 1, - RunSessionStartedAt: defaultCurrentTime, + History: view.NewHistoryBuffer(1), + From: "world", + Limit: 1, } assert.Equal(t, expectedCtx, ctx) } diff --git a/cmd/ping.go b/cmd/ping.go index dd03ebb..0aec409 100644 --- a/cmd/ping.go +++ b/cmd/ping.go @@ -150,9 +150,12 @@ func (r *Root) pingInfinite(ctx context.Context, opts *globalping.MeasurementCre done := make(chan struct{}) var infiniteTableOutput string var err error + + run := view.NewInfinitePingRun(r.ctx.Protocol, r.ctx.Packets, r.utils.Now(), r.utils.Now) + go func() { defer close(done) - infiniteTableOutput, err = r.ping(runCtx, opts) + infiniteTableOutput, err = r.runInfinitePing(runCtx, opts, run) }() select { @@ -167,7 +170,7 @@ func (r *Root) pingInfinite(ctx context.Context, opts *globalping.MeasurementCre } if err == nil && !r.ctx.ToLatency { - r.viewer.OutputSummary(infiniteTableOutput) + r.viewer.OutputPingSummary(infiniteTableOutput, run.Summary()) } if errors.Is(err, view.ErrAllProbesFailed) { @@ -180,12 +183,11 @@ func (r *Root) pingInfinite(ctx context.Context, opts *globalping.MeasurementCre return err } -func (r *Root) ping(ctx context.Context, opts *globalping.MeasurementCreate) (string, error) { +func (r *Root) runInfinitePing(ctx context.Context, opts *globalping.MeasurementCreate, run *view.InfinitePingRun) (string, error) { var infiniteTableOutput string var runErr error mbuf := NewMeasurementsBuffer(10) // 10 is the maximum number of measurements that can be in progress at the same time awaitStates := map[string]*measurementAwaitState{} - r.ctx.RunSessionStartedAt = r.utils.Now() for { if err := ctx.Err(); err != nil { @@ -228,7 +230,16 @@ func (r *Root) ping(ctx context.Context, opts *globalping.MeasurementCreate) (st continue } - infiniteTableOutput, err = r.viewer.OutputInfinite(measurement) + streamRawOutput := len(measurement.Results) == 1 && !r.ctx.Table && !r.ctx.ToLatency + output, err := run.Update(measurement, streamRawOutput, r.ctx.MeasurementsCreated) + + if err != nil { + r.Cmd.SilenceUsage = true + + return infiniteTableOutput, err + } + + infiniteTableOutput, err = r.viewer.OutputInfinite(output) // Do not let ErrAllProbesFailed mask the earlier creation error if runErr != nil && errors.Is(err, view.ErrAllProbesFailed) { @@ -264,6 +275,7 @@ func (r *Root) ping(ctx context.Context, opts *globalping.MeasurementCreate) (st if err != nil { runErr = err // Return the error after all measurements have finished } else { + run.Add(hm.Id, hm.StartedAt) mbuf.Append(hm) } @@ -313,6 +325,7 @@ func (r *Root) ping(ctx context.Context, opts *globalping.MeasurementCreate) (st return infiniteTableOutput, err } + run.Add(hm.Id, hm.StartedAt) mbuf.Append(hm) } } diff --git a/cmd/ping_test.go b/cmd/ping_test.go index 708bb15..fa71abd 100644 --- a/cmd/ping_test.go +++ b/cmd/ping_test.go @@ -19,6 +19,14 @@ import ( "go.uber.org/mock/gomock" ) +func pingOutputFor(measurement *globalping.Measurement) gomock.Matcher { + return gomock.Cond(func(value any) bool { + output, ok := value.(*view.InfinitePingOutput) + + return ok && output.Measurement == measurement + }) +} + func Test_Execute_Ping_Default(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -372,22 +380,22 @@ func Test_Execute_Ping_Infinite(t *testing.T) { }) viewerMock := viewMocks.NewMockViewer(ctrl) - waitFn := func(_ *globalping.Measurement) (string, error) { time.Sleep(5 * time.Millisecond); return "", nil } - viewerMock.EXPECT().OutputInfinite(expectedMeasurement1).DoAndReturn(waitFn) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement2).DoAndReturn(waitFn) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement3).DoAndReturn(waitFn) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement4).DoAndReturn(waitFn) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement2).DoAndReturn(waitFn) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement3).DoAndReturn(waitFn) + waitFn := func(_ *view.InfinitePingOutput) (string, error) { time.Sleep(5 * time.Millisecond); return "", nil } + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement1)).DoAndReturn(waitFn) + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement2)).DoAndReturn(waitFn) + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement3)).DoAndReturn(waitFn) + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement4)).DoAndReturn(waitFn) + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement2)).DoAndReturn(waitFn) + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement3)).DoAndReturn(waitFn) finalOutputStarted := make(chan struct{}) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement4).DoAndReturn(func(_ *globalping.Measurement) (string, error) { + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement4)).DoAndReturn(func(_ *view.InfinitePingOutput) (string, error) { close(finalOutputStarted) <-finalRunCtx.Done() return "", nil }) - viewerMock.EXPECT().OutputSummary("").Times(1) + viewerMock.EXPECT().OutputPingSummary("", gomock.Any()).Times(1) viewerMock.EXPECT().OutputShare().Times(1) utilsMock := utilsMocks.NewMockUtils(ctrl) @@ -425,7 +433,6 @@ func Test_Execute_Ping_Infinite(t *testing.T) { Protocol: "ICMP", Port: 80, MeasurementsCreated: 4, - RunSessionStartedAt: defaultCurrentTime, } expectedCtx.History = &view.HistoryBuffer{ Index: 4, @@ -496,6 +503,7 @@ func Test_Execute_Ping_Infinite_TableInCI(t *testing.T) { expectedResponse := createDefaultMeasurementCreateResponse() expectedMeasurement := createDefaultMeasurement("ping") + expectedMeasurement.Results[0].Result.StatsRaw = []byte(`{"total":0,"rcv":0,"drop":0,"loss":0}`) gbMock := apiMocks.NewMockClient(ctrl) gbMock.EXPECT().CreateMeasurement(gomock.Any(), expectedOpts).Return(expectedResponse, nil) @@ -508,13 +516,13 @@ func Test_Execute_Ping_Infinite_TableInCI(t *testing.T) { outputStarted := make(chan struct{}) viewerMock := viewMocks.NewMockViewer(ctrl) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement).DoAndReturn(func(*globalping.Measurement) (string, error) { + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement)).DoAndReturn(func(*view.InfinitePingOutput) (string, error) { close(outputStarted) <-runCtx.Done() return "final table", nil }) - viewerMock.EXPECT().OutputSummary("final table").Times(1) + viewerMock.EXPECT().OutputPingSummary("final table", gomock.Any()).Times(1) viewerMock.EXPECT().OutputShare().Times(1) utilsMock := utilsMocks.NewMockUtils(ctrl) @@ -557,8 +565,8 @@ func Test_Execute_Ping_Infinite_Output_Error(t *testing.T) { gbMock.EXPECT().GetMeasurement(gomock.Any(), measurementID1).Return(expectedMeasurement, nil) viewerMock := viewMocks.NewMockViewer(ctrl) - viewerMock.EXPECT().OutputInfinite(expectedMeasurement).Return("", errors.New("error message")) - viewerMock.EXPECT().OutputSummary(gomock.Any()).Times(0) + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement)).Return("", errors.New("error message")) + viewerMock.EXPECT().OutputPingSummary(gomock.Any(), gomock.Any()).Times(0) viewerMock.EXPECT().OutputShare().Times(1) utilsMock := utilsMocks.NewMockUtils(ctrl) @@ -622,10 +630,10 @@ func Test_Execute_Ping_Infinite_Output_TooManyRequests_Error(t *testing.T) { gbMock.EXPECT().GetMeasurement(gomock.Any(), measurementID1).Return(expectedMeasurement, nil) viewerMock := viewMocks.NewMockViewer(ctrl) - waitFn := func(_ *globalping.Measurement) (string, error) { time.Sleep(5 * time.Millisecond); return "", nil } - viewerMock.EXPECT().OutputInfinite(expectedMeasurement).DoAndReturn(waitFn) + waitFn := func(_ *view.InfinitePingOutput) (string, error) { time.Sleep(5 * time.Millisecond); return "", nil } + viewerMock.EXPECT().OutputInfinite(pingOutputFor(expectedMeasurement)).DoAndReturn(waitFn) - viewerMock.EXPECT().OutputSummary(gomock.Any()).Times(0) + viewerMock.EXPECT().OutputPingSummary(gomock.Any(), gomock.Any()).Times(0) viewerMock.EXPECT().OutputShare().Times(1) utilsMock := utilsMocks.NewMockUtils(ctrl) @@ -678,7 +686,7 @@ func Test_Ping_InfiniteCreationFailureFinishesPendingMeasurement(t *testing.T) { firstCreate := client.EXPECT().CreateMeasurement(gomock.Any(), gomock.Any()).Return(firstResponse, nil) firstPoll := client.EXPECT().GetMeasurement(gomock.Any(), firstResponse.ID).Return(measurement, nil).After(firstCreate) viewer := viewMocks.NewMockViewer(ctrl) - firstOutput := viewer.EXPECT().OutputInfinite(measurement).Return("current output", nil).After(firstPoll) + firstOutput := viewer.EXPECT().OutputInfinite(pingOutputFor(measurement)).Return("current output", nil).After(firstPoll) failedCreate := client.EXPECT().CreateMeasurement(gomock.Any(), gomock.Any()).Return(nil, assert.AnError).After(firstOutput) completed := createDefaultMeasurement_MultipleProbes(globalping.MeasurementStatusFinished, finalStatus) var outputErr error @@ -688,7 +696,7 @@ func Test_Ping_InfiniteCreationFailureFinishesPendingMeasurement(t *testing.T) { } finalPoll := client.EXPECT().GetMeasurement(gomock.Any(), firstResponse.ID).Return(completed, nil).After(failedCreate) - viewer.EXPECT().OutputInfinite(completed).Return("completed output", outputErr).After(finalPoll) + viewer.EXPECT().OutputInfinite(pingOutputFor(completed)).Return("completed output", outputErr).After(finalPoll) utilsMock := utilsMocks.NewMockUtils(ctrl) utilsMock.EXPECT().Now().Return(defaultCurrentTime).AnyTimes() ctx := createDefaultContext() @@ -697,7 +705,7 @@ func Test_Ping_InfiniteCreationFailureFinishesPendingMeasurement(t *testing.T) { root := NewRoot(view.NewPrinter(nil, new(bytes.Buffer), new(bytes.Buffer)), ctx, viewer, utilsMock, client, nil, nil) opts := createDefaultMeasurementCreate("ping") - output, err := root.ping(t.Context(), opts) + output, err := root.runInfinitePing(t.Context(), opts, view.NewInfinitePingRun(ctx.Protocol, ctx.Packets, utilsMock.Now(), utilsMock.Now)) assert.ErrorIs(t, err, assert.AnError) assert.Equal(t, "completed output", output) diff --git a/cmd/root.go b/cmd/root.go index ee081f9..51bafe6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,7 +77,7 @@ func Execute() { }) globalpingProbe := probe.NewProbe() - viewer := view.NewViewer(ctx, printer, _utils) + viewer := view.NewViewer(ctx, printer) root := NewRoot(printer, ctx, viewer, _utils, apiClient, globalpingProbe, localStorage) err := root.Cmd.Execute() diff --git a/cmd/timeout_test.go b/cmd/timeout_test.go index bc5f09f..b057761 100644 --- a/cmd/timeout_test.go +++ b/cmd/timeout_test.go @@ -260,9 +260,9 @@ func Test_PingInfinite_MeasurementTimeoutAndCancellation(t *testing.T) { viewer := viewMocks.NewMockViewer(ctrl) if test.timedOut { - viewer.EXPECT().OutputInfinite(measurement).Times(0) + viewer.EXPECT().OutputInfinite(pingOutputFor(measurement)).Times(0) } else { - viewer.EXPECT().OutputInfinite(measurement).Return("", nil) + viewer.EXPECT().OutputInfinite(pingOutputFor(measurement)).Return("", nil) } utils := utilsMocks.NewMockUtils(ctrl) @@ -288,7 +288,7 @@ func Test_PingInfinite_MeasurementTimeoutAndCancellation(t *testing.T) { viewCtx.APIMinInterval = time.Hour root := NewRoot(view.NewPrinter(nil, new(bytes.Buffer), new(bytes.Buffer)), viewCtx, viewer, utils, client, nil, nil) - _, err := root.ping(ctx, opts) + _, err := root.runInfinitePing(ctx, opts, view.NewInfinitePingRun(viewCtx.Protocol, viewCtx.Packets, utils.Now(), utils.Now)) assert.ErrorIs(t, err, test.expected) assert.Equal(t, 1, createCalls) diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 87928a8..60350e0 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -84,17 +84,20 @@ func createDefaultMeasurement_MultipleProbes(measurementStatus globalping.Measur Results: []globalping.ProbeMeasurement{ { Result: globalping.ProbeResult{ - Status: testStatus, + Status: testStatus, + StatsRaw: []byte(`{"total":0,"rcv":0,"drop":0,"loss":0}`), }, }, { Result: globalping.ProbeResult{ - Status: testStatus, + Status: testStatus, + StatsRaw: []byte(`{"total":0,"rcv":0,"drop":0,"loss":0}`), }, }, { Result: globalping.ProbeResult{ - Status: testStatus, + Status: testStatus, + StatsRaw: []byte(`{"total":0,"rcv":0,"drop":0,"loss":0}`), }, }, }, @@ -103,10 +106,9 @@ func createDefaultMeasurement_MultipleProbes(measurementStatus globalping.Measur func createDefaultContext() *view.Context { ctx := &view.Context{ - History: view.NewHistoryBuffer(1), - From: "world", - Limit: 1, - RunSessionStartedAt: defaultCurrentTime, + History: view.NewHistoryBuffer(1), + From: "world", + Limit: 1, } return ctx @@ -138,7 +140,6 @@ func createDefaultExpectedContext(cmd string) *view.Context { CIMode: true, History: view.NewHistoryBuffer(1), MeasurementsCreated: 1, - RunSessionStartedAt: defaultCurrentTime, } switch cmd { diff --git a/mocks/view/mock_viewer.go b/mocks/view/mock_viewer.go index 1325dd5..23c8c8b 100644 --- a/mocks/view/mock_viewer.go +++ b/mocks/view/mock_viewer.go @@ -12,6 +12,7 @@ package view import ( reflect "reflect" + view "github.com/jsdelivr/globalping-cli/view" globalping "github.com/jsdelivr/globalping-go" gomock "go.uber.org/mock/gomock" ) @@ -52,18 +53,18 @@ func (mr *MockViewerMockRecorder) OutputDefault(id, measurement, opts any) *gomo } // OutputInfinite mocks base method. -func (m *MockViewer) OutputInfinite(measurement *globalping.Measurement) (string, error) { +func (m *MockViewer) OutputInfinite(output *view.InfinitePingOutput) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OutputInfinite", measurement) + ret := m.ctrl.Call(m, "OutputInfinite", output) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // OutputInfinite indicates an expected call of OutputInfinite. -func (mr *MockViewerMockRecorder) OutputInfinite(measurement any) *gomock.Call { +func (mr *MockViewerMockRecorder) OutputInfinite(output any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputInfinite", reflect.TypeOf((*MockViewer)(nil).OutputInfinite), measurement) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputInfinite", reflect.TypeOf((*MockViewer)(nil).OutputInfinite), output) } // OutputJSON mocks base method. @@ -104,28 +105,28 @@ func (mr *MockViewerMockRecorder) OutputLive(measurement, opts, w, h any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputLive", reflect.TypeOf((*MockViewer)(nil).OutputLive), measurement, opts, w, h) } -// OutputShare mocks base method. -func (m *MockViewer) OutputShare() { +// OutputPingSummary mocks base method. +func (m *MockViewer) OutputPingSummary(infiniteTableOutput string, summary *view.PingSummary) { m.ctrl.T.Helper() - m.ctrl.Call(m, "OutputShare") + m.ctrl.Call(m, "OutputPingSummary", infiniteTableOutput, summary) } -// OutputShare indicates an expected call of OutputShare. -func (mr *MockViewerMockRecorder) OutputShare() *gomock.Call { +// OutputPingSummary indicates an expected call of OutputPingSummary. +func (mr *MockViewerMockRecorder) OutputPingSummary(infiniteTableOutput, summary any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputShare", reflect.TypeOf((*MockViewer)(nil).OutputShare)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputPingSummary", reflect.TypeOf((*MockViewer)(nil).OutputPingSummary), infiniteTableOutput, summary) } -// OutputSummary mocks base method. -func (m *MockViewer) OutputSummary(infiniteTableOutput string) { +// OutputShare mocks base method. +func (m *MockViewer) OutputShare() { m.ctrl.T.Helper() - m.ctrl.Call(m, "OutputSummary", infiniteTableOutput) + m.ctrl.Call(m, "OutputShare") } -// OutputSummary indicates an expected call of OutputSummary. -func (mr *MockViewerMockRecorder) OutputSummary(infiniteTableOutput any) *gomock.Call { +// OutputShare indicates an expected call of OutputShare. +func (mr *MockViewerMockRecorder) OutputShare() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputSummary", reflect.TypeOf((*MockViewer)(nil).OutputSummary), infiniteTableOutput) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OutputShare", reflect.TypeOf((*MockViewer)(nil).OutputShare)) } // OutputTable mocks base method. diff --git a/view/context.go b/view/context.go index f367a75..25acfa4 100644 --- a/view/context.go +++ b/view/context.go @@ -1,9 +1,6 @@ package view -import ( - "math" - "time" -) +import "time" type Context struct { Cmd string @@ -41,30 +38,7 @@ type Context struct { IsLocationFromSession bool // Determine whether the previous location is used RecordToSession bool // Record measurement to session history - Hostname string - IsHeaderPrinted bool TableOutputRows int - AggregatedStats []*MeasurementStats MeasurementsCreated int History *HistoryBuffer // History of measurements - RunSessionStartedAt time.Time -} - -type MeasurementStats struct { - Sent int // Number of packets sent - Rcv int // Number of packets received - Lost int // Number of packets lost - Loss float64 // Percentage of packets lost - Last float64 // Last RTT - Min float64 // Minimum RTT - Avg float64 // Average RTT - Max float64 // Maximum RTT - Mdev float64 // Mean deviation of RTT - Time float64 // Total time of measurement, in milliseconds - Tsum float64 // Total sum of RTT - Tsum2 float64 // Total sum of RTT squared -} - -func NewMeasurementStats() *MeasurementStats { - return &MeasurementStats{Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} } diff --git a/view/default_test.go b/view/default_test.go index 203d655..2cdad76 100644 --- a/view/default_test.go +++ b/view/default_test.go @@ -61,7 +61,7 @@ func Test_Output_Default_HTTP_Get(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "http", CIMode: true, - }, printer, nil) + }, printer) viewer.OutputDefault(measurementID1, measurement, opts) @@ -96,7 +96,7 @@ func Test_Output_Default_HTTP_Get_FailedUsesCompleteRawOutput(t *testing.T) { errW := new(bytes.Buffer) printer := NewPrinter(nil, w, errW) printer.DisableStyling() - viewer := NewViewer(&Context{Cmd: "http", CIMode: true}, printer, nil) + viewer := NewViewer(&Context{Cmd: "http", CIMode: true}, printer) viewer.OutputDefault(measurementID1, measurement, opts) @@ -155,7 +155,7 @@ func Test_Output_Default_HTTP_Get_Share(t *testing.T) { Cmd: "http", CIMode: true, Share: true, - }, printer, nil) + }, printer) viewer.OutputDefault(measurementID1, measurement, opts) @@ -264,7 +264,7 @@ func Test_Output_Default_HTTP_Get_Full(t *testing.T) { Cmd: "http", CIMode: true, Full: true, - }, printer, nil) + }, printer) viewer.OutputDefault(measurementID1, measurement, opts) @@ -352,7 +352,7 @@ func Test_Output_Default_HTTP_Head(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "http", CIMode: true, - }, printer, nil) + }, printer) viewer.OutputDefault(measurementID1, measurement, opts) @@ -408,7 +408,7 @@ func Test_Output_Default_HTTP_Get_NullableFields(t *testing.T) { errW := new(bytes.Buffer) printer := NewPrinter(nil, w, errW) printer.DisableStyling() - viewer := NewViewer(&Context{Cmd: "http", Full: true}, printer, nil) + viewer := NewViewer(&Context{Cmd: "http", Full: true}, printer) viewer.OutputDefault(measurementID1, measurement, opts) @@ -469,7 +469,7 @@ func Test_Output_Default_Ping(t *testing.T) { viewer := NewViewer(&Context{ Cmd: "ping", CIMode: true, - }, printer, nil) + }, printer) viewer.OutputDefault(measurementID1, measurement, opts) diff --git a/view/history.go b/view/history.go index 3409f46..e40f775 100644 --- a/view/history.go +++ b/view/history.go @@ -13,12 +13,10 @@ type HistoryBuffer struct { } type HistoryItem struct { - Id string - Status globalping.MeasurementStatus - ProbeStatus []globalping.TestStatus - LinesPrinted int - StartedAt time.Time - Stats []*MeasurementStats + Id string + Status globalping.MeasurementStatus + ProbeStatus []globalping.TestStatus + StartedAt time.Time } func NewHistoryBuffer(size int) *HistoryBuffer { @@ -50,25 +48,6 @@ func (h *HistoryBuffer) Find(id string) *HistoryItem { return nil } -func (h *HistoryBuffer) FilterByStatus(status globalping.MeasurementStatus) []*HistoryItem { - items := make([]*HistoryItem, 0, len(h.Slice)) - i := h.Index - - for { - if h.Slice[i] != nil && h.Slice[i].Status == status { - items = append(items, h.Slice[i]) - } - - i = (i + 1) % len(h.Slice) - - if i == h.Index { - break - } - } - - return items -} - func (h *HistoryBuffer) Push(m *HistoryItem) { h.Slice[h.Index] = m h.Index = (h.Index + 1) % len(h.Slice) diff --git a/view/infinite.go b/view/infinite.go index 1aab5c8..ae2c9eb 100644 --- a/view/infinite.go +++ b/view/infinite.go @@ -1,594 +1,71 @@ package view import ( - "bufio" "fmt" - "math" - "strconv" - "strings" "github.com/jsdelivr/globalping-cli/utils" "github.com/jsdelivr/globalping-go" ) -var ( - apiCreditInfo = "Consuming 1 API credit for every 16 packets until stopped.\n" - apiCreditConsumptionInfo = "Consuming ~%s/minute.\n" - apiCreditLastConsumptionInfo = "" - apiCreditLastMeasurementCount = 0 -) +const apiCreditInfo = "Consuming 1 API credit for every 16 packets until stopped.\n" -func (v *viewer) OutputInfinite(measurement *globalping.Measurement) (string, error) { - if v.ctx.Infinite && len(measurement.Results) > 1 && !v.ctx.ToLatency { - v.ctx.Table = true - } +func (v *viewer) OutputInfinite(output *InfinitePingOutput) (string, error) { + m := output.Measurement - if v.ctx.ToLatency || v.ctx.Table { - if err := validateFinishedPingStats(measurement); err != nil { - return "", err - } + if !output.streamRawOutput && !v.ctx.ToLatency { + v.ctx.Table = true } - streaming := len(measurement.Results) == 1 && !v.ctx.Table && !v.ctx.ToLatency - - if measurement.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(measurement) { - if !streaming { + if m.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(m) { + if !output.streamRawOutput { v.clearInfiniteTableOutput() } - return "", v.outputFailSummary(measurement) - } - - if streaming { - v.outputStreamingPackets(measurement) - - return "", nil - } - - stats := v.processInfinitePingMeasurement(measurement) - - if v.ctx.ToLatency { - return v.outputInfinitePingLatencyTable(measurement, stats) + return "", v.outputFailSummary(m) } - return v.outputInfinitePingTableView(stats) -} - -type infinitePingProbeStats struct { - measurement *globalping.ProbeMeasurement - stats *MeasurementStats - statusMessage string -} - -type infinitePingStats struct { - probes []infinitePingProbeStats -} - -type infinitePingProbeStatus struct { - order uint64 - message string -} - -func decodePingMeasurementStats(result *globalping.ProbeResult) *MeasurementStats { - stats, err := globalping.DecodePingStats(result.StatsRaw) - timings, timingsErr := globalping.DecodePingTimings(result.TimingsRaw) - decoded := NewMeasurementStats() - - if err != nil || stats.Total == 0 { - if timingsErr != nil || len(timings) == 0 { - return nil + if output.streamRawOutput { + if output.initial { + v.printer.ErrPrint(v.printer.Color(apiCreditInfo, FGBrightYellow)) } - // A command timeout can leave replies but no final summary. - for _, timing := range timings { - decoded.Min = math.Min(decoded.Min, timing.RTT) - decoded.Max = math.Max(decoded.Max, timing.RTT) - decoded.Tsum += timing.RTT - decoded.Tsum2 += timing.RTT * timing.RTT - } - - decoded.Sent = len(timings) - decoded.Rcv = len(timings) - decoded.Last = timings[len(timings)-1].RTT - decoded.Avg = decoded.Tsum / float64(decoded.Rcv) - decoded.Mdev = computeMdev(decoded.Tsum, decoded.Tsum2, decoded.Rcv, decoded.Avg) - - return decoded - } - - decoded.Sent = stats.Total - decoded.Rcv = stats.Rcv - decoded.Lost = stats.Drop - decoded.Loss = stats.Loss - - if stats.Rcv == 0 { - return decoded - } - - decoded.Mdev = stats.Mdev - - if stats.Min != nil { - decoded.Min = *stats.Min - } - - if stats.Max != nil { - decoded.Max = *stats.Max - } - - if stats.Avg != nil { - decoded.Avg = *stats.Avg - decoded.Tsum = *stats.Avg * float64(stats.Rcv) - decoded.Tsum2 = float64(stats.Rcv) * (stats.Mdev*stats.Mdev + (*stats.Avg)*(*stats.Avg)) - } - - if timingsErr == nil && len(timings) > 0 { - decoded.Last = timings[len(timings)-1].RTT - } - - return decoded -} - -func (v *viewer) processInfinitePingMeasurement(m *globalping.Measurement) *infinitePingStats { - if v.pingRoundOrders == nil { - v.pingRoundOrders = make(map[string]uint64) - v.pingProbeStatuses = make([]infinitePingProbeStatus, len(m.Results)) - } - - order, knownRound := v.pingRoundOrders[m.ID] - - if !knownRound { - // Rounds are first polled in creation order, but may finish out of order. - v.pingRoundCount++ - order = v.pingRoundCount - v.pingRoundOrders[m.ID] = order - } - - if len(v.ctx.AggregatedStats) == 0 { - v.ctx.AggregatedStats = make([]*MeasurementStats, len(m.Results)) - - for i := range m.Results { - v.ctx.AggregatedStats[i] = NewMeasurementStats() - } - } - - hm := v.ctx.History.Find(m.ID) - measurementHistory := hm - - if measurementHistory == nil { - measurementHistory = &HistoryItem{Id: m.ID, StartedAt: v.ctx.RunSessionStartedAt} - } - - processed := &infinitePingStats{probes: make([]infinitePingProbeStats, len(m.Results))} - newAggregatedStats := make([]*MeasurementStats, len(m.Results)) - newStats := make([]*MeasurementStats, len(m.Results)) - - for i := range m.Results { - probeMeasurement := &m.Results[i] - var resultStats *MeasurementStats - - if probeMeasurement.Result.Status == globalping.TestStatusInProgress { - resultStats = v.parsePingRawOutput(measurementHistory, probeMeasurement, -1).Stats - } else { - resultStats = decodePingMeasurementStats(&probeMeasurement.Result) - } - - if resultStats == nil { - resultStats = NewMeasurementStats() - } - - terminalFailure := probeMeasurement.Result.Status == globalping.TestStatusFailed || probeMeasurement.Result.Status == globalping.TestStatusOffline - - if terminalFailure { - addMissingInfinitePackets(resultStats, v.ctx.Packets) - } - - probeStatus := &v.pingProbeStatuses[i] - - if order >= probeStatus.order { - switch { - case terminalFailure: - probeStatus.order = order - probeStatus.message = resultStatusLabel(&probeMeasurement.Result) - case probeMeasurement.Result.Status == globalping.TestStatusFinished: - probeStatus.order = order - probeStatus.message = "" - } - } - - newAggregatedStats[i] = mergeMeasurementStats(*v.ctx.AggregatedStats[i], resultStats) - newStats[i] = resultStats - processed.probes[i] = infinitePingProbeStats{ - measurement: probeMeasurement, - stats: v.aggregateConcurrentStats(newAggregatedStats[i], i, m.ID), - statusMessage: probeStatus.message, - } - } - - if hm != nil { - hm.Stats = newStats - } - - if m.Status != globalping.MeasurementStatusInProgress { - v.ctx.AggregatedStats = newAggregatedStats - delete(v.pingRoundOrders, m.ID) - } - - return processed -} - -func addMissingInfinitePackets(stats *MeasurementStats, expected int) { - if expected <= stats.Sent { - return - } - - missing := expected - stats.Sent - stats.Sent += missing - stats.Lost += missing - stats.Loss = float64(stats.Lost) / float64(stats.Sent) * 100 -} - -func (v *viewer) outputStreamingPackets(m *globalping.Measurement) { - if len(v.ctx.AggregatedStats) == 0 { - v.ctx.AggregatedStats = []*MeasurementStats{NewMeasurementStats()} - v.printer.ErrPrint(v.getAPICreditInfo()) - } - - probeMeasurement := &m.Results[0] - hm := v.ctx.History.Find(m.ID) - - if probeMeasurement.Result.RawOutput != "" { - concurrentStats := v.aggregateConcurrentStats(v.ctx.AggregatedStats[0], 0, m.ID) - parsedOutput := v.parsePingRawOutput(hm, probeMeasurement, concurrentStats.Sent) - - if len(hm.Stats) == 0 { - hm.Stats = make([]*MeasurementStats, 1) - } - - hm.Stats[0] = parsedOutput.Stats - - if !v.ctx.IsHeaderPrinted { - v.ctx.Hostname = parsedOutput.Hostname - v.printer.ErrPrintln(v.getProbeInfo(probeMeasurement)) + if output.showHeader { + v.printer.ErrPrintln(v.getProbeInfo(&m.Results[0])) if v.ctx.Protocol == "ICMP" { v.printer.Printf("PING %s (%s) %s bytes of data.\n", - parsedOutput.Hostname, - parsedOutput.Address, - parsedOutput.BytesOfData, - ) + output.packets.Hostname, output.packets.Address, output.packets.BytesOfData) } else { - v.printer.Println(parsedOutput.Header) + v.printer.Println(output.packets.Header) } - - v.ctx.IsHeaderPrinted = true } - for hm.LinesPrinted < len(parsedOutput.RawPacketLines) { - v.printer.Println(parsedOutput.RawPacketLines[hm.LinesPrinted]) - hm.LinesPrinted++ - } - - if m.Status != globalping.MeasurementStatusInProgress { - v.ctx.AggregatedStats[0] = mergeMeasurementStats(*v.ctx.AggregatedStats[0], parsedOutput.Stats) - } - } -} - -func (v *viewer) aggregateConcurrentStats(completed *MeasurementStats, probeIndex int, excludeId string) *MeasurementStats { - inProgressStats := v.ctx.History.FilterByStatus(globalping.MeasurementStatusInProgress) - - for i := range inProgressStats { - if inProgressStats[i].Id == excludeId { - continue - } - - if len(inProgressStats[i].Stats) == 0 { - continue - } - - completed = mergeMeasurementStats(*completed, inProgressStats[i].Stats[probeIndex]) - } - - return completed -} - -func mergeMeasurementStats(stats MeasurementStats, newStats *MeasurementStats) *MeasurementStats { - if newStats.Rcv > 0 { - hadReceivedPackets := stats.Rcv > 0 - minUnavailable := newStats.Min == math.MaxFloat64 || (hadReceivedPackets && stats.Min == math.MaxFloat64) - avgUnavailable := newStats.Avg == -1 || (hadReceivedPackets && stats.Avg == -1) - maxUnavailable := newStats.Max == -1 || (hadReceivedPackets && stats.Max == -1) - - if newStats.Min < stats.Min { - stats.Min = newStats.Min - } - - if newStats.Max > stats.Max { - stats.Max = newStats.Max - } - - stats.Tsum += newStats.Tsum - stats.Tsum2 += newStats.Tsum2 - stats.Rcv += newStats.Rcv - stats.Avg = stats.Tsum / float64(stats.Rcv) - stats.Mdev = computeMdev(stats.Tsum, stats.Tsum2, stats.Rcv, stats.Avg) - stats.Last = newStats.Last - - if minUnavailable { - stats.Min = math.MaxFloat64 - } - - if avgUnavailable { - stats.Avg = -1 - stats.Mdev = 0 - } - - if maxUnavailable { - stats.Max = -1 - } - } - - stats.Sent += newStats.Sent - stats.Lost += newStats.Lost - stats.Time += newStats.Time - - if stats.Sent > 0 { - stats.Loss = float64(stats.Lost) / float64(stats.Sent) * 100 - } - - return &stats -} - -type ParsedPingOutput struct { - Header string - Hostname string - Address string - BytesOfData string - RawPacketLines []string - Timings []globalping.PingTiming - Stats *MeasurementStats -} - -// Parse ping's raw output. Adapted from iputils ping: https://github.com/iputils/iputils/tree/1c08152/ping -// -// - If startSequence is -1, RawPacketLines will be empty -func (v *viewer) parsePingRawOutput( - hm *HistoryItem, - m *globalping.ProbeMeasurement, - startSequence int, -) *ParsedPingOutput { - res := &ParsedPingOutput{ - Timings: make([]globalping.PingTiming, 0), - Stats: NewMeasurementStats(), - } - - if m.Result.RawOutput == "" { - return res - } - - scanner := bufio.NewScanner(strings.NewReader(m.Result.RawOutput)) - scanner.Scan() - res.Header = scanner.Text() - words := strings.Split(res.Header, " ") - - if len(words) > 2 { - res.Hostname = words[1] - - if len(words[2]) > 1 && words[2][0] == '(' { - res.Address = words[2][1 : len(words[2])-1] - } else { - res.Address = words[2] - } - - if v.ctx.Protocol == "ICMP" { - res.BytesOfData = words[3] - } - } - - sentMap := make([]bool, 0) - - for scanner.Scan() { - line := scanner.Text() - - if len(line) == 0 { - break - } - - if v.ctx.Protocol == "TCP" { - line, sentMap = parseTCPLine(line, sentMap, startSequence, res) - } else { - line, sentMap = parseICMPLine(line, sentMap, startSequence, res) - } - - if startSequence != -1 { - res.RawPacketLines = append(res.RawPacketLines, line) - } - } - - hasSummary := scanner.Scan() - - if hasSummary { - // Parse summary - scanner.Scan() // skip --- ping statistics --- - line := scanner.Text() - words = strings.Split(line, " ") - - if len(words) > 9 && words[1] == "packets" && words[2] == "transmitted," { - res.Stats.Sent, _ = strconv.Atoi(words[0]) - res.Stats.Rcv, _ = strconv.Atoi(words[3]) - - if v.ctx.Protocol == "TCP" { - res.Stats.Time, _ = strconv.ParseFloat(words[9], 64) - } else { - res.Stats.Time, _ = strconv.ParseFloat(words[9][:len(words[9])-2], 64) - } - } - } else { - res.Stats.Time = float64(v.utils.Now().Sub(hm.StartedAt).Milliseconds()) - } - - if res.Stats.Sent > 0 { - res.Stats.Lost = res.Stats.Sent - res.Stats.Rcv - res.Stats.Loss = float64(res.Stats.Lost) / float64(res.Stats.Sent) * 100 - - if res.Stats.Rcv > 0 { - res.Stats.Avg = res.Stats.Tsum / float64(res.Stats.Rcv) - res.Stats.Mdev = computeMdev(res.Stats.Tsum, res.Stats.Tsum2, res.Stats.Rcv, res.Stats.Avg) - res.Stats.Last = res.Timings[len(res.Timings)-1].RTT - } - } - - return res -} - -func parseICMPLine(line string, sentMap []bool, startSequence int, res *ParsedPingOutput) (string, []bool) { - seq := -1 - seqIndex := 0 - words := strings.Split(line, " ") - - for seqIndex < len(words) { - if strings.HasPrefix(words[seqIndex], "icmp_seq=") { - n, err := strconv.Atoi(words[seqIndex][9:]) - - if err == nil { - seq = n - 1 // seq starts at 1 - } - - break - } - - seqIndex++ - } - - if seq >= len(sentMap) { - sentMap = append(sentMap, false) - } - - // Get timing - if seq != -1 { - if words[1] == "bytes" && words[2] == "from" { - if !sentMap[seq] { - res.Stats.Sent++ - } - - res.Stats.Rcv++ - ttl, _ := strconv.Atoi(words[seqIndex+1][4:]) - rtt, _ := strconv.ParseFloat(words[seqIndex+2][5:], 64) - res.Stats.Min = math.Min(res.Stats.Min, rtt) - res.Stats.Max = math.Max(res.Stats.Max, rtt) - res.Stats.Tsum += rtt - res.Stats.Tsum2 += rtt * rtt - res.Timings = append(res.Timings, globalping.PingTiming{ - TTL: &ttl, - RTT: rtt, - }) - } else { - if !sentMap[seq] { - res.Stats.Sent++ - } - - sentMap[seq] = true - } - - // replace sequence number - if startSequence != -1 { - words[seqIndex] = "icmp_seq=" + strconv.Itoa(startSequence+seq+1) - line = strings.Join(words, " ") - } - } - - return line, sentMap -} - -func parseTCPLine(line string, sentMap []bool, startSequence int, res *ParsedPingOutput) (string, []bool) { - seq := -1 - seqIndex := 0 - words := strings.Split(line, " ") - - for seqIndex < len(words) { - if strings.HasPrefix(words[seqIndex], "tcp_conn=") { - n, err := strconv.Atoi(words[seqIndex][9:]) - - if err == nil { - seq = n - 1 // seq starts at 1 + if output.packets != nil { + for _, line := range output.packets.RawPacketLines { + v.printer.Println(line) } - - break } - seqIndex++ - } - - if seq >= len(sentMap) { - sentMap = append(sentMap, false) - } - - // Get timing - if seq != -1 { - if words[0] == "Reply" && words[1] == "from" { - if !sentMap[seq] { - res.Stats.Sent++ - } - - res.Stats.Rcv++ - rtt, _ := strconv.ParseFloat(words[seqIndex+1][5:], 64) - res.Stats.Min = math.Min(res.Stats.Min, rtt) - res.Stats.Max = math.Max(res.Stats.Max, rtt) - res.Stats.Tsum += rtt - res.Stats.Tsum2 += rtt * rtt - res.Timings = append(res.Timings, globalping.PingTiming{ - RTT: rtt, - }) - } else { - if !sentMap[seq] { - res.Stats.Sent++ - } - - sentMap[seq] = true - } - - // replace sequence number - if startSequence != -1 { - words[seqIndex] = "tcp_conn=" + strconv.Itoa(startSequence+seq+1) - line = strings.Join(words, " ") - } + return "", nil } - return line, sentMap -} - -// https://github.com/iputils/iputils/tree/1c08152/ping/ping_common.c#L917 -func computeMdev(tsum float64, tsum2 float64, rcv int, avg float64) float64 { - if tsum < math.MaxInt32 { - return math.Sqrt((tsum2 - ((tsum * tsum) / float64(rcv))) / float64(rcv)) + if v.ctx.ToLatency { + return v.outputInfinitePingLatencyTable(m, output) } - return math.Sqrt(tsum2/float64(rcv) - avg*avg) -} - -func (v *viewer) getAPICreditInfo() string { - return v.printer.Color(apiCreditInfo, FGBrightYellow) + return v.outputInfinitePingTableView(output) } -func (v *viewer) getAPICreditConsumptionInfo(width int) string { - if v.ctx.MeasurementsCreated < 2 { +func (v *viewer) getAPICreditConsumptionInfo(output *InfinitePingOutput, width int) string { + if !output.showCredits { return "" } - if v.ctx.MeasurementsCreated == apiCreditLastMeasurementCount { - return apiCreditLastConsumptionInfo - } - - apiCreditLastMeasurementCount = v.ctx.MeasurementsCreated - elapsedMinutes := v.utils.Now().Sub(v.ctx.RunSessionStartedAt).Minutes() - consumption := int64(math.Ceil(float64((apiCreditLastMeasurementCount-1)*(len(v.ctx.AggregatedStats))) / elapsedMinutes)) - info := fmt.Sprintf(apiCreditConsumptionInfo, utils.Pluralize(consumption, "API credit")) + info := fmt.Sprintf("Consuming ~%s/minute.\n", utils.Pluralize(output.creditsPerMinute, "API credit")) if len(info) > width-4 { info = info[:max(width-5, 0)] + "..." } - apiCreditLastConsumptionInfo = v.printer.Color(info, FGBrightYellow) - - return apiCreditLastConsumptionInfo + return v.printer.Color(info, FGBrightYellow) } diff --git a/view/infinite_ping_run.go b/view/infinite_ping_run.go new file mode 100644 index 0000000..212eaac --- /dev/null +++ b/view/infinite_ping_run.go @@ -0,0 +1,302 @@ +package view + +import ( + "fmt" + "math" + "slices" + "time" + + "github.com/jsdelivr/globalping-go" +) + +// InfinitePingRun is owned by the command loop. Rounds are registered in creation order; +// each update produces a snapshot that can be rendered without changing the run. +type InfinitePingRun struct { + protocol string + packets int + startedAt time.Time + now func() time.Time + rounds []*pingRound + completed []*MeasurementStats + statuses []pingProbeStatus + hostname string + headerPrinted bool + creditCount int + creditsPerMinute int64 +} + +type pingRound struct { + id string + startedAt time.Time + finished bool + stats []*MeasurementStats + statuses []pingProbeStatus + linesPrinted int +} + +type pingProbeStatus struct { + status globalping.TestStatus + source globalping.FailureSource + last *float64 +} + +// InfinitePingOutput contains one prepared redraw or batch of newly received packet lines. +type InfinitePingOutput struct { + Measurement *globalping.Measurement + probes []infinitePingProbeStats + streamRawOutput bool + initial bool + showHeader bool + packets *ParsedPingOutput + creditsPerMinute int64 + showCredits bool +} + +type infinitePingProbeStats struct { + measurement *globalping.ProbeMeasurement + stats *MeasurementStats + statusMessage string +} + +type PingSummary struct { + Hostname string + Stats *MeasurementStats +} + +func NewInfinitePingRun(protocol string, packets int, startedAt time.Time, now func() time.Time) *InfinitePingRun { + return &InfinitePingRun{protocol: protocol, packets: packets, startedAt: startedAt, now: now} +} + +func (r *InfinitePingRun) Add(id string, startedAt time.Time) { + r.rounds = append(r.rounds, &pingRound{id: id, startedAt: startedAt}) +} + +func (r *InfinitePingRun) Update(m *globalping.Measurement, streamRawOutput bool, created int) (*InfinitePingOutput, error) { + output := &InfinitePingOutput{Measurement: m, streamRawOutput: streamRawOutput} + + if !streamRawOutput { + if err := validateFinishedPingStats(m); err != nil { + return nil, err + } + } + + index := slices.IndexFunc(r.rounds, func(round *pingRound) bool { return round.id == m.ID }) + + if index < 0 { + return nil, fmt.Errorf("ping round %s has not been registered", m.ID) + } + + round := r.rounds[index] + + if m.Status != globalping.MeasurementStatusInProgress && !isSomeTestFinished(m) { + round.finished = true + round.stats = nil + r.compactRounds() + + return output, nil + } + + output.initial = len(r.completed) == 0 + + if output.initial { + r.completed = make([]*MeasurementStats, len(m.Results)) + r.statuses = make([]pingProbeStatus, len(m.Results)) + + for i := range r.completed { + r.completed[i] = NewMeasurementStats() + } + } + + if streamRawOutput { + r.updatePackets(round, m, output) + } else { + r.updateTable(round, m, output) + } + + if m.Status != globalping.MeasurementStatusInProgress { + round.finished = true + + for i, stats := range round.stats { + r.completed[i] = mergeMeasurementStats(*r.completed[i], stats) + } + + round.stats = nil + r.compactRounds() + } + + if !streamRawOutput && created >= 2 { + if r.creditCount != created { + r.creditCount = created + minutes := r.now().Sub(r.startedAt).Minutes() + r.creditsPerMinute = int64(math.Ceil(float64((created-1)*len(r.completed)) / minutes)) + } + + output.showCredits = true + output.creditsPerMinute = r.creditsPerMinute + } + + return output, nil +} + +func (r *InfinitePingRun) updateTable(round *pingRound, m *globalping.Measurement, output *InfinitePingOutput) { + round.stats = make([]*MeasurementStats, len(m.Results)) + round.statuses = make([]pingProbeStatus, len(m.Results)) + + for i := range m.Results { + result := &m.Results[i] + var stats *MeasurementStats + + if result.Result.Status == globalping.TestStatusInProgress { + stats = parsePingRawOutput(r.protocol, round.startedAt, r.now, result, -1).Stats + } else { + stats = decodePingMeasurementStats(&result.Result) + } + + if stats == nil { + stats = NewMeasurementStats() + } + + if result.Result.Status == globalping.TestStatusFailed || result.Result.Status == globalping.TestStatusOffline { + if missing := r.packets - stats.Sent; missing > 0 { + stats.Sent += missing + stats.Lost += missing + stats.Loss = float64(stats.Lost) / float64(stats.Sent) * 100 + } + } + + round.stats[i] = stats + round.statuses[i] = pingProbeStatus{status: result.Result.Status, source: result.Result.FailureSource} + + if stats.Last >= 0 { + round.statuses[i].last = &stats.Last + } + } + + statuses := slices.Clone(r.statuses) + + for _, active := range r.rounds { + mergePingStatuses(statuses, active.statuses) + } + + output.probes = make([]infinitePingProbeStats, len(m.Results)) + + for i := range m.Results { + stats := r.aggregatePending(r.completed[i], i) + statusMessage := "" + + if statuses[i].status == globalping.TestStatusFailed || statuses[i].status == globalping.TestStatusOffline { + statusMessage = resultStatusLabel(&globalping.ProbeResult{Status: statuses[i].status, FailureSource: statuses[i].source}) + } + + output.probes[i] = infinitePingProbeStats{ + measurement: &m.Results[i], + stats: stats, + statusMessage: statusMessage, + } + } +} + +func (r *InfinitePingRun) updatePackets(round *pingRound, m *globalping.Measurement, output *InfinitePingOutput) { + result := &m.Results[0] + + if result.Result.RawOutput == "" { + round.stats = nil + + return + } + + sent := r.completed[0].Sent + + for _, other := range r.rounds { + if other != round && !other.finished && len(other.stats) > 0 { + sent += other.stats[0].Sent + } + } + + parsed := parsePingRawOutput(r.protocol, round.startedAt, r.now, result, sent) + round.stats = []*MeasurementStats{parsed.Stats} + round.statuses = make([]pingProbeStatus, 1) + + if parsed.Stats.Last >= 0 { + round.statuses[0].last = &parsed.Stats.Last + } + + output.showHeader = !r.headerPrinted + + if output.showHeader { + r.hostname = parsed.Hostname + r.headerPrinted = true + } + + lines := parsed.RawPacketLines + parsed.RawPacketLines = lines[min(round.linesPrinted, len(lines)):] + round.linesPrinted = max(round.linesPrinted, len(lines)) + output.packets = parsed +} + +func (r *InfinitePingRun) aggregatePending(completed *MeasurementStats, probe int) *MeasurementStats { + stats := *completed + var last *float64 + + if len(r.statuses) > 0 { + last = r.statuses[probe].last + } + + for _, round := range r.rounds { + if !round.finished && len(round.stats) > 0 { + stats = *mergeMeasurementStats(stats, round.stats[probe]) + } + + if len(round.statuses) > 0 && round.statuses[probe].last != nil { + last = round.statuses[probe].last + } + } + + if last != nil { + stats.Last = *last + } + + return &stats +} + +// Completed round statistics are already in the totals. Keep only the latest +// terminal statuses and available RTTs between pending rounds, so retained state stays bounded. +func (r *InfinitePingRun) compactRounds() { + rounds := r.rounds[:0] + + for _, round := range r.rounds { + switch { + case round.finished && len(rounds) == 0: + mergePingStatuses(r.statuses, round.statuses) + case round.finished && rounds[len(rounds)-1].finished: + mergePingStatuses(rounds[len(rounds)-1].statuses, round.statuses) + default: + rounds = append(rounds, round) + } + } + + clear(r.rounds[len(rounds):]) + r.rounds = rounds +} + +func mergePingStatuses(statuses, newer []pingProbeStatus) { + for i, status := range newer { + switch status.status { + case globalping.TestStatusFinished, globalping.TestStatusFailed, globalping.TestStatusOffline: + statuses[i].status = status.status + statuses[i].source = status.source + } + + if status.last != nil { + statuses[i].last = status.last + } + } +} + +func (r *InfinitePingRun) Summary() *PingSummary { + if len(r.completed) != 1 { + return nil + } + + return &PingSummary{Hostname: r.hostname, Stats: r.aggregatePending(r.completed[0], 0)} +} diff --git a/view/infinite_test.go b/view/infinite_test.go index c4232d7..71a241d 100644 --- a/view/infinite_test.go +++ b/view/infinite_test.go @@ -7,12 +7,37 @@ import ( "time" utilsMocks "github.com/jsdelivr/globalping-cli/mocks/utils" + "github.com/jsdelivr/globalping-cli/utils" "github.com/jsdelivr/globalping-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) +func newPingTestRun(ctx *Context, clock utils.Utils) *InfinitePingRun { + var now func() time.Time + + if clock != nil { + now = clock.Now + } + + run := NewInfinitePingRun(ctx.Protocol, ctx.Packets, defaultCurrentTime, now) + run.Add(measurementID1, defaultCurrentTime) + + return run +} + +func outputPing(viewer Viewer, run *InfinitePingRun, ctx *Context, m *globalping.Measurement) (string, error) { + streamRawOutput := len(m.Results) == 1 && !ctx.Table && !ctx.ToLatency + output, err := run.Update(m, streamRawOutput, ctx.MeasurementsCreated) + + if err != nil { + return "", err + } + + return viewer.OutputInfinite(output) +} + func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -22,19 +47,19 @@ func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { ctx := createDefaultContext("ping") ctx.Infinite = true - hm := ctx.History.Find(measurementID1) w := new(bytes.Buffer) errW := new(bytes.Buffer) printer := NewPrinter(nil, w, errW) printer.DisableStyling() - viewer := NewViewer(ctx, printer, utilsMock) + run := newPingTestRun(ctx, utilsMock) + viewer := NewViewer(ctx, printer) measurement := createPingMeasurement(measurementID1) measurement.Status = globalping.MeasurementStatusInProgress measurement.Results[0].Result.Status = globalping.TestStatusInProgress measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data.` - _, err := viewer.OutputInfinite(measurement) + _, err := outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) assert.Equal(t, apiCreditInfo+ @@ -47,7 +72,7 @@ func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { measurement.Results[0].Result.RawOutput = `PING jsdelivr.map.fastly.net (151.101.1.229) 56(84) bytes of data. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms` - _, err = viewer.OutputInfinite(measurement) + _, err = outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) assert.Equal(t, apiCreditInfo+ @@ -62,7 +87,7 @@ func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=56 time=12.9 ms 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=2 ttl=56 time=12.7 ms` - _, err = viewer.OutputInfinite(measurement) + _, err = outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) assert.Equal(t, apiCreditInfo+ @@ -76,7 +101,7 @@ func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { expectedStats := &MeasurementStats{Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 12.7, Min: 12.7, Avg: 12.8, Max: 12.9, Time: 500, Tsum: 25.6, Tsum2: 327.7, Mdev: 0.0999} - assertMeasurementStats(t, expectedStats, hm.Stats[0]) + assertMeasurementStats(t, expectedStats, run.rounds[0].stats[0]) measurement.Status = globalping.MeasurementStatusFinished measurement.Results[0].Result.Status = globalping.TestStatusFinished @@ -89,7 +114,7 @@ func Test_OutputInfinite_SingleProbe_InProgress(t *testing.T) { 3 packets transmitted, 3 received, 0% packet loss, time 1001ms rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` - _, err = viewer.OutputInfinite(measurement) + _, err = outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) assert.Equal(t, apiCreditInfo+ @@ -104,15 +129,12 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` expectedStats = &MeasurementStats{Sent: 3, Rcv: 3, Lost: 0, Loss: 0, Last: 13, Min: 12.7, Avg: 12.8666, Max: 13, Time: 1001, Tsum: 38.6, Tsum2: 496.7, Mdev: 0.1247} - assertMeasurementStats(t, expectedStats, ctx.AggregatedStats[0]) + assertMeasurementStats(t, expectedStats, run.completed[0]) ctx.MeasurementsCreated = 2 - ctx.History.Push(&HistoryItem{ - Id: measurementID2, - StartedAt: defaultCurrentTime.Add(1 * time.Millisecond), - }) + run.Add(measurementID2, defaultCurrentTime.Add(1*time.Millisecond)) measurement.ID = measurementID2 - _, err = viewer.OutputInfinite(measurement) + _, err = outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) assert.Equal(t, apiCreditInfo+ @@ -131,7 +153,7 @@ rtt min/avg/max/mdev = 12.711/12.854/12.952/0.103 ms` expectedStats = &MeasurementStats{Sent: 6, Rcv: 6, Lost: 0, Loss: 0, Last: 13, Min: 12.7, Avg: 12.8666, Max: 13, Time: 2002, Tsum: 77.2, Tsum2: 993.4, Mdev: 0.1247} - assertMeasurementStats(t, expectedStats, ctx.AggregatedStats[0]) + assertMeasurementStats(t, expectedStats, run.completed[0]) } func Test_OutputInfinite_SingleProbe_Failed(t *testing.T) { @@ -146,8 +168,9 @@ func Test_OutputInfinite_SingleProbe_Failed(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) - _, err := viewer.OutputInfinite(measurement) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) + _, err := outputPing(viewer, run, ctx, measurement) assert.Equal(t, "all probes failed", err.Error()) assert.Equal(t, @@ -157,7 +180,7 @@ ping: cdn.jsdelivr.net.xc: Name or service not known w.String(), ) - assert.Nil(t, ctx.AggregatedStats) + assert.Nil(t, run.completed) } func Test_OutputInfinite_SingleProbe_TableUsesTableOutput(t *testing.T) { @@ -170,9 +193,10 @@ func Test_OutputInfinite_SingleProbe_TableUsesTableOutput(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) - output, err := viewer.OutputInfinite(measurement) + output, err := outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) assert.Contains(t, w.String(), "Location") @@ -180,7 +204,7 @@ func Test_OutputInfinite_SingleProbe_TableUsesTableOutput(t *testing.T) { assert.NotContains(t, w.String(), "PING ") assert.NotContains(t, w.String(), tableTimeoutValue) - viewer.OutputSummary(output) + viewer.OutputPingSummary(output, run.Summary()) assert.Contains(t, w.String(), tableTimeoutValue) assert.Equal(t, 1, ctx.TableOutputRows) @@ -220,9 +244,10 @@ func Test_OutputInfinite_SingleProbe_LatencyUsesCompletedOutput(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) - output, err := viewer.OutputInfinite(measurement) + output, err := outputPing(viewer, run, ctx, measurement) require.NoError(t, err) assert.Contains(t, output, "Location") @@ -239,8 +264,9 @@ func Test_OutputInfinite_Table_AllFailedWithoutStats(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) - _, err := viewer.OutputInfinite(createPingMeasurement(measurementID1)) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) + _, err := outputPing(viewer, run, ctx, createPingMeasurement(measurementID1)) require.NoError(t, err) w.Reset() @@ -249,11 +275,11 @@ func Test_OutputInfinite_Table_AllFailedWithoutStats(t *testing.T) { measurement.Results[0].Result.RawOutput = "ping: unknown host" measurement.Results[0].Result.StatsRaw = nil measurement.Results[0].Result.TimingsRaw = nil - ctx.History.Push(&HistoryItem{Id: measurementID2, StartedAt: defaultCurrentTime}) + run.Add(measurementID2, defaultCurrentTime) - output, err := viewer.OutputInfinite(measurement) + output, err := outputPing(viewer, run, ctx, measurement) failureOutput := w.String() - viewer.OutputSummary(output) + viewer.OutputPingSummary(output, run.Summary()) assert.EqualError(t, err, "all probes failed") assert.Equal(t, failureOutput, w.String()) @@ -275,16 +301,17 @@ func Test_OutputInfinite_Table_AllFailedWithPacketStatsTerminates(t *testing.T) w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) - output, err := viewer.OutputInfinite(measurement) + output, err := outputPing(viewer, run, ctx, measurement) assert.ErrorIs(t, err, ErrAllProbesFailed) assert.Empty(t, output) assert.Contains(t, w.String(), " — Error\n"+measurement.Results[0].Result.RawOutput) assert.NotContains(t, w.String(), "Location") assert.Equal(t, 0, ctx.TableOutputRows) - assert.Nil(t, ctx.AggregatedStats) + assert.Nil(t, run.completed) } func Test_OutputInfinite_FailedProbeCountsPartialRoundAndRestoresAggregate(t *testing.T) { @@ -297,9 +324,10 @@ func Test_OutputInfinite_FailedProbeCountsPartialRoundAndRestoresAggregate(t *te w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) - _, err := viewer.OutputInfinite(first) + _, err := outputPing(viewer, run, ctx, first) require.NoError(t, err) assert.True(t, ctx.Table) second := createPingMeasurement_MultipleProbes(measurementID2) @@ -310,43 +338,43 @@ func Test_OutputInfinite_FailedProbeCountsPartialRoundAndRestoresAggregate(t *te second.Results[1].Result.RawOutput = "probe failed" second.Results[1].Result.StatsRaw = json.RawMessage(`{"min":null,"avg":null,"max":null,"total":0,"rcv":0,"drop":0,"loss":0}`) second.Results[1].Result.TimingsRaw = json.RawMessage(`[{"rtt":5},{"rtt":7}]`) - ctx.History.Push(&HistoryItem{Id: measurementID2, StartedAt: defaultCurrentTime}) + run.Add(measurementID2, defaultCurrentTime) - failedOutput, err := viewer.OutputInfinite(second) + failedOutput, err := outputPing(viewer, run, ctx, second) require.NoError(t, err) assert.Contains(t, failedOutput, "--- Target error ---") - assert.Equal(t, 17, ctx.AggregatedStats[1].Sent) - assert.Equal(t, 3, ctx.AggregatedStats[1].Rcv) - assert.Equal(t, 14, ctx.AggregatedStats[1].Lost) - assert.InDelta(t, (5.457+5+7)/3, ctx.AggregatedStats[1].Avg, 0.0001) - assert.InDelta(t, 29.778849+25+49, ctx.AggregatedStats[1].Tsum2, 0.0001) + assert.Equal(t, 17, run.completed[1].Sent) + assert.Equal(t, 3, run.completed[1].Rcv) + assert.Equal(t, 14, run.completed[1].Lost) + assert.InDelta(t, (5.457+5+7)/3, run.completed[1].Avg, 0.0001) + assert.InDelta(t, 29.778849+25+49, run.completed[1].Tsum2, 0.0001) thirdMeasurementID := "3sZfUs3cnzGz1I20" third := createPingMeasurement_MultipleProbes(thirdMeasurementID) third.Results = third.Results[:2] third.ProbesCount = len(third.Results) - ctx.History.Push(&HistoryItem{Id: thirdMeasurementID, StartedAt: defaultCurrentTime}) + run.Add(thirdMeasurementID, defaultCurrentTime) finishedResult := third.Results[1].Result third.Status = globalping.MeasurementStatusInProgress third.Results[1].Result = globalping.ProbeResult{Status: globalping.TestStatusInProgress} for range 2 { - pendingOutput, pendingErr := viewer.OutputInfinite(third) + pendingOutput, pendingErr := outputPing(viewer, run, ctx, third) require.NoError(t, pendingErr) assert.Contains(t, pendingOutput, "--- Target error ---") - assert.Equal(t, 17, ctx.AggregatedStats[1].Sent) - assert.Equal(t, 3, ctx.AggregatedStats[1].Rcv) + assert.Equal(t, 17, run.completed[1].Sent) + assert.Equal(t, 3, run.completed[1].Rcv) } third.Status = globalping.MeasurementStatusFinished third.Results[1].Result = finishedResult - restoredOutput, err := viewer.OutputInfinite(third) + restoredOutput, err := outputPing(viewer, run, ctx, third) require.NoError(t, err) assert.NotContains(t, restoredOutput, "Target error") assert.Contains(t, restoredOutput, "| 18 | 77.78%") - assert.Equal(t, 18, ctx.AggregatedStats[1].Sent) + assert.Equal(t, 18, run.completed[1].Sent) } func Test_OutputInfinite_MultipleProbesShowsFailedAndOfflineStatusRows(t *testing.T) { @@ -366,19 +394,20 @@ func Test_OutputInfinite_MultipleProbesShowsFailedAndOfflineStatusRows(t *testin w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) - output, err := viewer.OutputInfinite(measurement) + output, err := outputPing(viewer, run, ctx, measurement) require.NoError(t, err) assert.Contains(t, output, "--- Internal error ---") assert.Contains(t, output, "--- Probe offline ---") assert.NotContains(t, output, "internal details") assert.NotContains(t, output, "offline details") - assert.Equal(t, 16, ctx.AggregatedStats[1].Sent) - assert.Equal(t, 16, ctx.AggregatedStats[1].Lost) - assert.Equal(t, 16, ctx.AggregatedStats[2].Sent) - assert.Equal(t, 16, ctx.AggregatedStats[2].Lost) + assert.Equal(t, 16, run.completed[1].Sent) + assert.Equal(t, 16, run.completed[1].Lost) + assert.Equal(t, 16, run.completed[2].Sent) + assert.Equal(t, 16, run.completed[2].Lost) } func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { @@ -400,7 +429,8 @@ func Test_OutputInfinite_MultipleProbes_MultipleCalls(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, utilsMock) + run := newPingTestRun(ctx, utilsMock) + viewer := NewViewer(ctx, printer) // Call 1 expectedOutput := `Location | Sent | Loss | Last | Min | Avg | Max @@ -408,7 +438,7 @@ London, GB, EU, OVH SAS (AS0) | 0 | 0.00% | - | Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms ` - _, err := viewer.OutputInfinite(measurement) + _, err := outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) expectedStats := []*MeasurementStats{ @@ -416,9 +446,9 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4. NewMeasurementStats(), NewMeasurementStats(), } - assertMeasurementStats(t, expectedStats[0], ctx.AggregatedStats[0]) - assertMeasurementStats(t, expectedStats[1], ctx.AggregatedStats[1]) - assertMeasurementStats(t, expectedStats[2], ctx.AggregatedStats[2]) + assertMeasurementStats(t, expectedStats[0], run.completed[0]) + assertMeasurementStats(t, expectedStats[1], run.completed[1]) + assertMeasurementStats(t, expectedStats[2], run.completed[2]) measurement.Results[0].Result.RawOutput = `PING (146.75.73.229) 56(84) bytes of data. 64 bytes from 146.75.73.229 (146.75.73.229): icmp_seq=1 ttl=52 time=17.6 ms @@ -432,12 +462,12 @@ Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 5.46 ms | 5. Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms ` - _, err = viewer.OutputInfinite(measurement) + _, err = outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) - assertMeasurementStats(t, expectedStats[0], ctx.AggregatedStats[0]) - assertMeasurementStats(t, expectedStats[1], ctx.AggregatedStats[1]) - assertMeasurementStats(t, expectedStats[2], ctx.AggregatedStats[2]) + assertMeasurementStats(t, expectedStats[0], run.completed[0]) + assertMeasurementStats(t, expectedStats[1], run.completed[1]) + assertMeasurementStats(t, expectedStats[2], run.completed[2]) measurement.Status = globalping.MeasurementStatusFinished measurement.Results[0].Result.Status = globalping.TestStatusFinished @@ -461,7 +491,7 @@ Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 5.46 ms | 5. Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms ` - _, err = viewer.OutputInfinite(measurement) + _, err = outputPing(viewer, run, ctx, measurement) assert.NoError(t, err) expectedStats = []*MeasurementStats{ @@ -469,9 +499,9 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4. {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Tsum: 5.457, Tsum2: 29.778849}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Tsum: 4.069, Tsum2: 16.556761}, } - assertMeasurementStats(t, expectedStats[0], ctx.AggregatedStats[0]) - assertMeasurementStats(t, expectedStats[1], ctx.AggregatedStats[1]) - assertMeasurementStats(t, expectedStats[2], ctx.AggregatedStats[2]) + assertMeasurementStats(t, expectedStats[0], run.completed[0]) + assertMeasurementStats(t, expectedStats[1], run.completed[1]) + assertMeasurementStats(t, expectedStats[2], run.completed[2]) // Call 4 measurement2 := createPingMeasurement_MultipleProbes(measurementID2) @@ -479,12 +509,9 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4. measurement2.Results[0].Result.StatsRaw = measurement.Results[0].Result.StatsRaw measurement2.Results[0].Result.TimingsRaw = measurement.Results[0].Result.TimingsRaw - ctx.History.Push(&HistoryItem{ - Id: measurementID2, - StartedAt: defaultCurrentTime.Add(1 * time.Millisecond), - }) + run.Add(measurementID2, defaultCurrentTime.Add(1*time.Millisecond)) - _, err = viewer.OutputInfinite(measurement2) + _, err = outputPing(viewer, run, ctx, measurement2) assert.NoError(t, err) expectedStats = []*MeasurementStats{ @@ -492,9 +519,9 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4. {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Tsum: 10.914, Tsum2: 59.557698}, {Sent: 2, Rcv: 2, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Tsum: 8.138, Tsum2: 33.113522}, } - assertMeasurementStats(t, expectedStats[0], ctx.AggregatedStats[0]) - assertMeasurementStats(t, expectedStats[1], ctx.AggregatedStats[1]) - assertMeasurementStats(t, expectedStats[2], ctx.AggregatedStats[2]) + assertMeasurementStats(t, expectedStats[0], run.completed[0]) + assertMeasurementStats(t, expectedStats[1], run.completed[1]) + assertMeasurementStats(t, expectedStats[2], run.completed[2]) expectedOutput += "\033[4A\033[0J" + `Location | Sent | Loss | Last | Min | Avg | Max @@ -527,12 +554,11 @@ func Test_OutputInfinite_MultipleProbes_MultipleConcurrentCalls(t *testing.T) { ctx := createDefaultContext("ping") ctx.Infinite = true - hm1 := ctx.History.Find(measurementID1) - hm1.Status = globalping.MeasurementStatusInProgress w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, utilsMock) + run := newPingTestRun(ctx, utilsMock) + viewer := NewViewer(ctx, printer) expectedOutput := `Location | Sent | Loss | Last | Min | Avg | Max London, GB, EU, OVH SAS (AS0) | 1 | 0.00% | 10.0 ms | 10.0 ms | 10.0 ms | 10.0 ms @@ -540,7 +566,7 @@ Falkenstein, DE, EU, Hetzner Online GmbH (AS0) | 0 | 0.00% | - | Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms ` - _, err := viewer.OutputInfinite(measurement1) + _, err := outputPing(viewer, run, ctx, measurement1) assert.NoError(t, err) // Call 2 @@ -555,11 +581,7 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4. 64 bytes from 151.101.1.229 (151.101.1.229): icmp_seq=1 ttl=60 time=20 ms` measurement2.Results[1].Result.StatsRaw = nil measurement2.Results[1].Result.TimingsRaw = nil - ctx.History.Push(&HistoryItem{ - Id: measurementID2, - Status: globalping.MeasurementStatusInProgress, - StartedAt: defaultCurrentTime.Add(1 * time.Millisecond), - }) + run.Add(measurementID2, defaultCurrentTime.Add(1*time.Millisecond)) ctx.MeasurementsCreated = 2 expectedOutput += "\033[4A\033[0J" + @@ -570,7 +592,7 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4. Consuming ~360 API credits/minute. ` - _, err = viewer.OutputInfinite(measurement2) + _, err = outputPing(viewer, run, ctx, measurement2) assert.NoError(t, err) // Call 3 @@ -586,7 +608,7 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4. Consuming ~360 API credits/minute. ` - _, err = viewer.OutputInfinite(measurement1) + _, err = outputPing(viewer, run, ctx, measurement1) assert.NoError(t, err) // Call 4 @@ -613,7 +635,6 @@ rtt min/avg/max/mdev = 10/15/25/5 ms` rtt min/avg/max/mdev = 20/25/30/5 ms` measurement1.Results[1].Result.StatsRaw = json.RawMessage(`{"min":20,"avg":25,"max":30,"total":3,"rcv":3,"drop":0,"loss":0,"mdev":4.082}`) measurement1.Results[1].Result.TimingsRaw = json.RawMessage(`[{"ttl":60,"rtt":20},{"ttl":30,"rtt":25},{"ttl":30,"rtt":30}]`) - hm1.Status = globalping.MeasurementStatusFinished expectedOutput += "\033[5A\033[0J" + `Location | Sent | Loss | Last | Min | Avg | Max @@ -623,7 +644,7 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 2 | 0.00% | 4.07 ms | 4. Consuming ~360 API credits/minute. ` - _, err = viewer.OutputInfinite(measurement1) + _, err = outputPing(viewer, run, ctx, measurement1) assert.NoError(t, err) // Call 5 @@ -639,7 +660,7 @@ rtt min/avg/max/mdev = 10/15/25/5 ms` measurement2.Results[0].Result.StatsRaw = json.RawMessage(`{"min":10,"avg":16.667,"max":25,"total":3,"rcv":3,"drop":0,"loss":0,"mdev":6.236}`) measurement2.Results[0].Result.TimingsRaw = json.RawMessage(`[{"ttl":60,"rtt":10},{"ttl":30,"rtt":15},{"ttl":30,"rtt":25}]`) - _, err = viewer.OutputInfinite(measurement2) + _, err = outputPing(viewer, run, ctx, measurement2) assert.NoError(t, err) expectedOutput += "\033[5A\033[0J" + @@ -664,8 +685,9 @@ func Test_OutputInfinite_MultipleProbes(t *testing.T) { ctx := createDefaultContext("ping") ctx.Infinite = true w := new(bytes.Buffer) - v := NewViewer(ctx, NewPrinter(nil, w, w), utilsMock) - _, err := v.OutputInfinite(measurement) + run := newPingTestRun(ctx, utilsMock) + v := NewViewer(ctx, NewPrinter(nil, w, w)) + _, err := outputPing(v, run, ctx, measurement) assert.NoError(t, err) expectedOutput := "\033[96mLocation \033[0m | \033[96mSent\033[0m | \033[96m Loss\033[0m | \033[96m Last\033[0m | \033[96m Min\033[0m | \033[96m Avg\033[0m | \033[96m Max\033[0m" + @@ -681,7 +703,7 @@ Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4. {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 5.46, Min: 5.457, Avg: 5.457, Max: 5.457, Tsum: 5.457, Tsum2: 29.778849}, {Sent: 1, Rcv: 1, Lost: 0, Loss: 0, Last: 4.07, Min: 4.069, Avg: 4.069, Max: 4.069, Tsum: 4.069, Tsum2: 16.556761}, }, - ctx.AggregatedStats, + run.completed, ) } @@ -701,8 +723,9 @@ func Test_OutputInfinite_MultipleProbes_All_Failed(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - v := NewViewer(ctx, printer, nil) - _, err := v.OutputInfinite(measurement) + run := newPingTestRun(ctx, nil) + v := NewViewer(ctx, printer) + _, err := outputPing(v, run, ctx, measurement) assert.Equal(t, "all probes failed", err.Error()) assert.Equal(t, `> London, GB, EU, OVH SAS (AS0) — Error @@ -713,7 +736,7 @@ ping: cdn.jsdelivr.net.xc: Name or service not known ping: cdn.jsdelivr.net.xc: Name or service not known `, w.String()) - assert.Nil(t, ctx.AggregatedStats) + assert.Nil(t, run.completed) } func Test_OutputInfinite_SingleProbe_Offline(t *testing.T) { @@ -727,8 +750,9 @@ func Test_OutputInfinite_SingleProbe_Offline(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) - _, err := viewer.OutputInfinite(measurement) + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, printer) + _, err := outputPing(viewer, run, ctx, measurement) assert.Equal(t, "all probes failed", err.Error()) assert.Equal(t, @@ -737,13 +761,12 @@ This probe is currently offline. Please try again later. `, w.String(), ) - assert.Nil(t, ctx.AggregatedStats) + assert.Nil(t, run.completed) } func Test_ParsePingRawOutput_Full(t *testing.T) { ctx := createDefaultContext("ping") ctx.Protocol = "ICMP" - v := viewer{ctx: ctx} hm := ctx.History.Find(measurementID1) m := &globalping.ProbeMeasurement{ @@ -759,7 +782,7 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, }, } - res := v.parsePingRawOutput(hm, m, -1) + res := parsePingRawOutput(ctx.Protocol, hm.StartedAt, nil, m, -1) assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Nil(t, res.RawPacketLines) @@ -787,7 +810,6 @@ rtt min/avg/max/mdev = 1.061/1.090/1.108/0.020 ms`, func Test_ParsePingRawOutput_Full_TCP(t *testing.T) { ctx := createDefaultContext("ping") ctx.Protocol = "TCP" - v := viewer{ctx: ctx} hm := ctx.History.Find(measurementID1) m := &globalping.ProbeMeasurement{ @@ -803,7 +825,7 @@ rtt min/avg/max/mdev = 28.915/29.034/29.225/0.136 ms`, }, } - res := v.parsePingRawOutput(hm, m, -1) + res := parsePingRawOutput(ctx.Protocol, hm.StartedAt, nil, m, -1) assert.Equal(t, "104.21.23.24", res.Address) assert.Equal(t, "", res.BytesOfData) assert.Nil(t, res.RawPacketLines) @@ -837,7 +859,6 @@ func Test_ParsePingRawOutput_NoStats(t *testing.T) { ctx := createDefaultContext("ping") ctx.Protocol = "ICMP" - v := viewer{ctx: ctx, utils: utilsMock} hm := ctx.History.Find(measurementID1) @@ -852,7 +873,7 @@ no answer yet for icmp_seq=2 no answer yet for icmp_seq=4`, }, } - res := v.parsePingRawOutput(hm, m, -1) + res := parsePingRawOutput(ctx.Protocol, hm.StartedAt, utilsMock.Now, m, -1) assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Nil(t, res.RawPacketLines) @@ -886,7 +907,6 @@ func Test_ParsePingRawOutput_NoStats_WithStartIncmpSeq(t *testing.T) { ctx := createDefaultContext("ping") ctx.Protocol = "ICMP" - v := viewer{ctx: ctx, utils: utilsMock} hm := ctx.History.Find(measurementID1) @@ -901,7 +921,7 @@ no answer yet for icmp_seq=2 no answer yet for icmp_seq=4`, }, } - res := v.parsePingRawOutput(hm, m, 4) + res := parsePingRawOutput(ctx.Protocol, hm.StartedAt, utilsMock.Now, m, 4) assert.Equal(t, "142.250.65.174", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Equal(t, []string{ @@ -943,7 +963,6 @@ func Test_ParsePingRawOutput_WithRedirect(t *testing.T) { ctx := createDefaultContext("ping") ctx.Protocol = "ICMP" - v := viewer{ctx: ctx, utils: utilsMock} hm := ctx.History.Find(measurementID1) @@ -956,7 +975,7 @@ From goldenfast.net (103.102.153.1): icmp_seq=2 Redirect Host(New nexthop: golde 64 bytes from 104.18.187.31 (104.18.187.31): icmp_seq=2 ttl=59 time=0.705 ms`, }, } - res := v.parsePingRawOutput(hm, m, 0) + res := parsePingRawOutput(ctx.Protocol, hm.StartedAt, utilsMock.Now, m, 0) assert.Equal(t, "104.18.187.31", res.Address) assert.Equal(t, "56(84)", res.BytesOfData) assert.Equal(t, []string{ @@ -1029,22 +1048,40 @@ func Test_OutputInfinite_LateOlderResultPreservesNewerStatus(t *testing.T) { ctx.Packets = 16 printer := NewPrinter(nil, new(bytes.Buffer), new(bytes.Buffer)) printer.DisableStyling() + run := newPingTestRun(ctx, nil) v := &viewer{ctx: ctx, printer: printer} older := createPingMeasurement_MultipleProbes(measurementID1) older.Status = globalping.MeasurementStatusInProgress older.Results[1].Result = globalping.ProbeResult{Status: globalping.TestStatusInProgress} - _, err := v.OutputInfinite(older) + _, err := outputPing(v, run, ctx, older) require.NoError(t, err) newer := createPingMeasurement_MultipleProbes(measurementID2) + newer.Results[0].Result.StatsRaw = json.RawMessage(`{"min":20,"avg":20,"max":20,"total":1,"rcv":1,"drop":0,"loss":0}`) + newer.Results[0].Result.TimingsRaw = json.RawMessage(`[{"rtt":20}]`) newer.Results[1].Result.Status = newerStatus newer.Results[1].Result.FailureSource = globalping.FailureSourceInternal - ctx.History.Push(&HistoryItem{Id: measurementID2, StartedAt: defaultCurrentTime}) - _, err = v.OutputInfinite(newer) + run.Add(measurementID2, defaultCurrentTime) + _, err = outputPing(v, run, ctx, newer) + require.NoError(t, err) + assert.Len(t, run.rounds, 2) + assert.Equal(t, 20.0, run.aggregatePending(run.completed[0], 0).Last) + + // A newer completed round without replies must preserve the last available RTT, + // including when its state is compacted with the preceding completed round. + withoutReplies := createPingMeasurement_MultipleProbes("without-replies") + withoutReplies.Results[0].Result.StatsRaw = json.RawMessage(`{"total":16,"rcv":0,"drop":16,"loss":100}`) + withoutReplies.Results[0].Result.TimingsRaw = nil + withoutReplies.Results[1].Result = newer.Results[1].Result + run.Add(withoutReplies.ID, defaultCurrentTime) + prepared, err := run.Update(withoutReplies, false, 1) require.NoError(t, err) - assert.Len(t, v.pingRoundOrders, 1) + assert.Equal(t, 20.0, prepared.probes[0].stats.Last) + assert.Len(t, run.rounds, 2) + older.Results[0].Result.StatsRaw = json.RawMessage(`{"min":10,"avg":10,"max":10,"total":1,"rcv":1,"drop":0,"loss":0}`) + older.Results[0].Result.TimingsRaw = json.RawMessage(`[{"rtt":10}]`) older.Status = globalping.MeasurementStatusFinished older.Results[1].Result = createPingMeasurement_MultipleProbes(measurementID1).Results[1].Result @@ -1053,9 +1090,11 @@ func Test_OutputInfinite_LateOlderResultPreservesNewerStatus(t *testing.T) { older.Results[1].Result.FailureSource = globalping.FailureSourceTarget } - output, err := v.OutputInfinite(older) + output, err := outputPing(v, run, ctx, older) require.NoError(t, err) - assert.Empty(t, v.pingRoundOrders) + assert.Empty(t, run.rounds) + assert.Equal(t, 20.0, run.aggregatePending(run.completed[0], 0).Last) + assert.Contains(t, output, "20.0 ms") switch newerStatus { case globalping.TestStatusFinished: diff --git a/view/json_test.go b/view/json_test.go index 19db335..5265265 100644 --- a/view/json_test.go +++ b/view/json_test.go @@ -20,7 +20,6 @@ func Test_Output_Json(t *testing.T) { Share: true, }, printer, - nil, ) viewer.OutputJSON(measurementID1, b) diff --git a/view/latency_test.go b/view/latency_test.go index 061c784..b1b083f 100644 --- a/view/latency_test.go +++ b/view/latency_test.go @@ -54,7 +54,6 @@ func Test_Output_Latency_Ping(t *testing.T) { ToLatency: true, }, NewPrinter(nil, w, errW), - nil, ) err := viewer.OutputLatency(measurementID1, measurement) @@ -100,7 +99,6 @@ func Test_Output_Latency_Ping_StylingDisabled(t *testing.T) { ToLatency: true, }, printer, - nil, ) err := viewer.OutputLatency(measurementID1, measurement) @@ -142,7 +140,6 @@ func Test_Output_Latency_DNS(t *testing.T) { ToLatency: true, }, NewPrinter(nil, w, w), - nil, ) err := viewer.OutputLatency(measurementID1, measurement) @@ -182,7 +179,6 @@ func Test_Output_Latency_DNS_StylingDisabled(t *testing.T) { ToLatency: true, }, printer, - nil, ) err := viewer.OutputLatency(measurementID1, measurement) @@ -222,7 +218,6 @@ func Test_Output_Latency_Http(t *testing.T) { ToLatency: true, }, NewPrinter(nil, w, w), - nil, ) err := viewer.OutputLatency(measurementID1, measurement) @@ -268,7 +263,6 @@ func Test_Output_Latency_Http_StylingDisabled(t *testing.T) { ToLatency: true, }, printer, - nil, ) err := viewer.OutputLatency(measurementID1, measurement) @@ -330,7 +324,7 @@ func Test_Output_Latency_NullableValues(t *testing.T) { errW := new(bytes.Buffer) printer := NewPrinter(nil, w, errW) printer.DisableStyling() - viewer := NewViewer(&Context{Cmd: test.command, ToLatency: true}, printer, nil) + viewer := NewViewer(&Context{Cmd: test.command, ToLatency: true}, printer) err := viewer.OutputLatency(measurementID1, measurement) @@ -373,7 +367,6 @@ func Test_Output_Latency_Offline(t *testing.T) { Share: true, }, printer, - nil, ) err := viewer.OutputLatency(measurementID1, measurement) @@ -399,7 +392,7 @@ func Test_Output_Latency_Failed(t *testing.T) { errW := new(bytes.Buffer) printer := NewPrinter(nil, w, errW) printer.DisableStyling() - viewer := NewViewer(&Context{Cmd: "ping", ToLatency: true}, printer, nil) + viewer := NewViewer(&Context{Cmd: "ping", ToLatency: true}, printer) err := viewer.OutputLatency(measurementID1, measurement) diff --git a/view/ping_stats.go b/view/ping_stats.go new file mode 100644 index 0000000..371e090 --- /dev/null +++ b/view/ping_stats.go @@ -0,0 +1,365 @@ +package view + +import ( + "bufio" + "math" + "strconv" + "strings" + "time" + + "github.com/jsdelivr/globalping-go" +) + +type MeasurementStats struct { + Sent int // Number of packets sent + Rcv int // Number of packets received + Lost int // Number of packets lost + Loss float64 // Percentage of packets lost + Last float64 // Last RTT + Min float64 // Minimum RTT + Avg float64 // Average RTT + Max float64 // Maximum RTT + Mdev float64 // Mean deviation of RTT + Time float64 // Total time of measurement, in milliseconds + Tsum float64 // Total sum of RTT + Tsum2 float64 // Total sum of RTT squared +} + +func NewMeasurementStats() *MeasurementStats { + return &MeasurementStats{Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1} +} + +func decodePingMeasurementStats(result *globalping.ProbeResult) *MeasurementStats { + stats, err := globalping.DecodePingStats(result.StatsRaw) + timings, timingsErr := globalping.DecodePingTimings(result.TimingsRaw) + decoded := NewMeasurementStats() + + if err != nil || stats.Total == 0 { + if timingsErr != nil || len(timings) == 0 { + return nil + } + + // A command timeout can leave replies but no final summary. + for _, timing := range timings { + decoded.Min = math.Min(decoded.Min, timing.RTT) + decoded.Max = math.Max(decoded.Max, timing.RTT) + decoded.Tsum += timing.RTT + decoded.Tsum2 += timing.RTT * timing.RTT + } + + decoded.Sent = len(timings) + decoded.Rcv = len(timings) + decoded.Last = timings[len(timings)-1].RTT + decoded.Avg = decoded.Tsum / float64(decoded.Rcv) + decoded.Mdev = computeMdev(decoded.Tsum, decoded.Tsum2, decoded.Rcv, decoded.Avg) + + return decoded + } + + decoded.Sent = stats.Total + decoded.Rcv = stats.Rcv + decoded.Lost = stats.Drop + decoded.Loss = stats.Loss + + if stats.Rcv == 0 { + return decoded + } + + decoded.Mdev = stats.Mdev + + if stats.Min != nil { + decoded.Min = *stats.Min + } + + if stats.Max != nil { + decoded.Max = *stats.Max + } + + if stats.Avg != nil { + decoded.Avg = *stats.Avg + decoded.Tsum = *stats.Avg * float64(stats.Rcv) + decoded.Tsum2 = float64(stats.Rcv) * (stats.Mdev*stats.Mdev + (*stats.Avg)*(*stats.Avg)) + } + + if timingsErr == nil && len(timings) > 0 { + decoded.Last = timings[len(timings)-1].RTT + } + + return decoded +} + +func mergeMeasurementStats(stats MeasurementStats, newStats *MeasurementStats) *MeasurementStats { + if newStats.Rcv > 0 { + hadReceivedPackets := stats.Rcv > 0 + minUnavailable := newStats.Min == math.MaxFloat64 || (hadReceivedPackets && stats.Min == math.MaxFloat64) + avgUnavailable := newStats.Avg == -1 || (hadReceivedPackets && stats.Avg == -1) + maxUnavailable := newStats.Max == -1 || (hadReceivedPackets && stats.Max == -1) + + if newStats.Min < stats.Min { + stats.Min = newStats.Min + } + + if newStats.Max > stats.Max { + stats.Max = newStats.Max + } + + stats.Tsum += newStats.Tsum + stats.Tsum2 += newStats.Tsum2 + stats.Rcv += newStats.Rcv + stats.Avg = stats.Tsum / float64(stats.Rcv) + stats.Mdev = computeMdev(stats.Tsum, stats.Tsum2, stats.Rcv, stats.Avg) + stats.Last = newStats.Last + + if minUnavailable { + stats.Min = math.MaxFloat64 + } + + if avgUnavailable { + stats.Avg = -1 + stats.Mdev = 0 + } + + if maxUnavailable { + stats.Max = -1 + } + } + + stats.Sent += newStats.Sent + stats.Lost += newStats.Lost + stats.Time += newStats.Time + + if stats.Sent > 0 { + stats.Loss = float64(stats.Lost) / float64(stats.Sent) * 100 + } + + return &stats +} + +type ParsedPingOutput struct { + Header string + Hostname string + Address string + BytesOfData string + RawPacketLines []string + Timings []globalping.PingTiming + Stats *MeasurementStats +} + +// Parse ping's raw output. Adapted from iputils ping: https://github.com/iputils/iputils/tree/1c08152/ping +// +// - If startSequence is -1, RawPacketLines will be empty +func parsePingRawOutput( + protocol string, + startedAt time.Time, + now func() time.Time, + m *globalping.ProbeMeasurement, + startSequence int, +) *ParsedPingOutput { + res := &ParsedPingOutput{ + Timings: make([]globalping.PingTiming, 0), + Stats: NewMeasurementStats(), + } + + if m.Result.RawOutput == "" { + return res + } + + scanner := bufio.NewScanner(strings.NewReader(m.Result.RawOutput)) + scanner.Scan() + res.Header = scanner.Text() + words := strings.Split(res.Header, " ") + + if len(words) > 2 { + res.Hostname = words[1] + + if len(words[2]) > 1 && words[2][0] == '(' { + res.Address = words[2][1 : len(words[2])-1] + } else { + res.Address = words[2] + } + + if protocol == "ICMP" { + res.BytesOfData = words[3] + } + } + + sentMap := make([]bool, 0) + + for scanner.Scan() { + line := scanner.Text() + + if len(line) == 0 { + break + } + + if protocol == "TCP" { + line, sentMap = parseTCPLine(line, sentMap, startSequence, res) + } else { + line, sentMap = parseICMPLine(line, sentMap, startSequence, res) + } + + if startSequence != -1 { + res.RawPacketLines = append(res.RawPacketLines, line) + } + } + + hasSummary := scanner.Scan() + + if hasSummary { + // Parse summary + scanner.Scan() // skip --- ping statistics --- + line := scanner.Text() + words = strings.Split(line, " ") + + if len(words) > 9 && words[1] == "packets" && words[2] == "transmitted," { + res.Stats.Sent, _ = strconv.Atoi(words[0]) + res.Stats.Rcv, _ = strconv.Atoi(words[3]) + + if protocol == "TCP" { + res.Stats.Time, _ = strconv.ParseFloat(words[9], 64) + } else { + res.Stats.Time, _ = strconv.ParseFloat(words[9][:len(words[9])-2], 64) + } + } + } else { + res.Stats.Time = float64(now().Sub(startedAt).Milliseconds()) + } + + if res.Stats.Sent > 0 { + res.Stats.Lost = res.Stats.Sent - res.Stats.Rcv + res.Stats.Loss = float64(res.Stats.Lost) / float64(res.Stats.Sent) * 100 + + if res.Stats.Rcv > 0 { + res.Stats.Avg = res.Stats.Tsum / float64(res.Stats.Rcv) + res.Stats.Mdev = computeMdev(res.Stats.Tsum, res.Stats.Tsum2, res.Stats.Rcv, res.Stats.Avg) + res.Stats.Last = res.Timings[len(res.Timings)-1].RTT + } + } + + return res +} + +func parseICMPLine(line string, sentMap []bool, startSequence int, res *ParsedPingOutput) (string, []bool) { + seq := -1 + seqIndex := 0 + words := strings.Split(line, " ") + + for seqIndex < len(words) { + if strings.HasPrefix(words[seqIndex], "icmp_seq=") { + n, err := strconv.Atoi(words[seqIndex][9:]) + + if err == nil { + seq = n - 1 // seq starts at 1 + } + + break + } + + seqIndex++ + } + + if seq >= len(sentMap) { + sentMap = append(sentMap, false) + } + + // Get timing + if seq != -1 { + if words[1] == "bytes" && words[2] == "from" { + if !sentMap[seq] { + res.Stats.Sent++ + } + + res.Stats.Rcv++ + ttl, _ := strconv.Atoi(words[seqIndex+1][4:]) + rtt, _ := strconv.ParseFloat(words[seqIndex+2][5:], 64) + res.Stats.Min = math.Min(res.Stats.Min, rtt) + res.Stats.Max = math.Max(res.Stats.Max, rtt) + res.Stats.Tsum += rtt + res.Stats.Tsum2 += rtt * rtt + res.Timings = append(res.Timings, globalping.PingTiming{ + TTL: &ttl, + RTT: rtt, + }) + } else { + if !sentMap[seq] { + res.Stats.Sent++ + } + + sentMap[seq] = true + } + + // replace sequence number + if startSequence != -1 { + words[seqIndex] = "icmp_seq=" + strconv.Itoa(startSequence+seq+1) + line = strings.Join(words, " ") + } + } + + return line, sentMap +} + +func parseTCPLine(line string, sentMap []bool, startSequence int, res *ParsedPingOutput) (string, []bool) { + seq := -1 + seqIndex := 0 + words := strings.Split(line, " ") + + for seqIndex < len(words) { + if strings.HasPrefix(words[seqIndex], "tcp_conn=") { + n, err := strconv.Atoi(words[seqIndex][9:]) + + if err == nil { + seq = n - 1 // seq starts at 1 + } + + break + } + + seqIndex++ + } + + if seq >= len(sentMap) { + sentMap = append(sentMap, false) + } + + // Get timing + if seq != -1 { + if words[0] == "Reply" && words[1] == "from" { + if !sentMap[seq] { + res.Stats.Sent++ + } + + res.Stats.Rcv++ + rtt, _ := strconv.ParseFloat(words[seqIndex+1][5:], 64) + res.Stats.Min = math.Min(res.Stats.Min, rtt) + res.Stats.Max = math.Max(res.Stats.Max, rtt) + res.Stats.Tsum += rtt + res.Stats.Tsum2 += rtt * rtt + res.Timings = append(res.Timings, globalping.PingTiming{ + RTT: rtt, + }) + } else { + if !sentMap[seq] { + res.Stats.Sent++ + } + + sentMap[seq] = true + } + + // replace sequence number + if startSequence != -1 { + words[seqIndex] = "tcp_conn=" + strconv.Itoa(startSequence+seq+1) + line = strings.Join(words, " ") + } + } + + return line, sentMap +} + +// https://github.com/iputils/iputils/tree/1c08152/ping/ping_common.c#L917 +func computeMdev(tsum float64, tsum2 float64, rcv int, avg float64) float64 { + if tsum < math.MaxInt32 { + return math.Sqrt((tsum2 - ((tsum * tsum) / float64(rcv))) / float64(rcv)) + } + + return math.Sqrt(tsum2/float64(rcv) - avg*avg) +} diff --git a/view/summary.go b/view/ping_summary.go similarity index 78% rename from view/summary.go rename to view/ping_summary.go index 0e3eb08..a53e0bc 100644 --- a/view/summary.go +++ b/view/ping_summary.go @@ -5,7 +5,7 @@ import ( "math" ) -func (v *viewer) OutputSummary(infiniteTableOutput string) { +func (v *viewer) OutputPingSummary(infiniteTableOutput string, summary *PingSummary) { if v.ctx.Infinite && v.ctx.Table { if infiniteTableOutput == "" { return @@ -20,13 +20,13 @@ func (v *viewer) OutputSummary(infiniteTableOutput string) { return } - if len(v.ctx.AggregatedStats) != 1 { + if summary == nil { return } - stats := v.aggregateConcurrentStats(v.ctx.AggregatedStats[0], 0, "") + stats := summary.Stats - v.printer.Printf("\n--- %s ping statistics ---\n", v.ctx.Hostname) + v.printer.Printf("\n--- %s ping statistics ---\n", summary.Hostname) v.printer.Printf("%d packets transmitted, %d received, %.2f%% packet loss, time %.0fms\n", stats.Sent, stats.Rcv, diff --git a/view/summary_test.go b/view/ping_summary_test.go similarity index 54% rename from view/summary_test.go rename to view/ping_summary_test.go index b73c88c..8eda3af 100644 --- a/view/summary_test.go +++ b/view/ping_summary_test.go @@ -4,16 +4,16 @@ import ( "bytes" "testing" - "github.com/jsdelivr/globalping-go" "github.com/stretchr/testify/assert" ) -func Test_OutputSummary(t *testing.T) { +func Test_OutputPingSummary(t *testing.T) { t.Run("No_stats", func(t *testing.T) { w := new(bytes.Buffer) ctx := createDefaultContext("ping") - viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil) - viewer.OutputSummary("") + run := newPingTestRun(ctx, nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, w)) + viewer.OutputPingSummary("", run.Summary()) assert.Equal(t, "", w.String()) }) @@ -21,24 +21,24 @@ func Test_OutputSummary(t *testing.T) { t.Run("With_stats_Single_location", func(t *testing.T) { w := new(bytes.Buffer) ctx := createDefaultContext("ping") - ctx.AggregatedStats = []*MeasurementStats{ + run := newPingTestRun(ctx, nil) + run.completed = []*MeasurementStats{ NewMeasurementStats(), } - ctx.AggregatedStats[0].Sent = 1 - ctx.AggregatedStats[0].Rcv = 0 - ctx.AggregatedStats[0].Lost = 1 - ctx.AggregatedStats[0].Loss = 100 - ctx.AggregatedStats[0].Time = 1000 - hm := &HistoryItem{ - Id: measurementID2, - Status: globalping.MeasurementStatusInProgress, - Stats: []*MeasurementStats{ + run.completed[0].Sent = 1 + run.completed[0].Rcv = 0 + run.completed[0].Lost = 1 + run.completed[0].Loss = 100 + run.completed[0].Time = 1000 + round := &pingRound{ + id: measurementID2, + stats: []*MeasurementStats{ {Sent: 9, Rcv: 9, Lost: 0, Loss: 0, Last: 0.77, Min: 0.77, Avg: 0.77, Max: 0.77, Time: 1000, Tsum: 6.93, Tsum2: 5.3361}, }, } - ctx.History.Push(hm) - viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil) - viewer.OutputSummary("") + run.rounds = append(run.rounds, round) + viewer := NewViewer(ctx, NewPrinter(nil, w, w)) + viewer.OutputPingSummary("", run.Summary()) assert.Equal(t, ` --- ping statistics --- @@ -51,12 +51,13 @@ rtt min/avg/max/mdev = 0.770/0.770/0.770/0.000 ms t.Run("Multiple_locations", func(t *testing.T) { w := new(bytes.Buffer) ctx := createDefaultContext("ping") - ctx.AggregatedStats = []*MeasurementStats{ + run := newPingTestRun(ctx, nil) + run.completed = []*MeasurementStats{ NewMeasurementStats(), NewMeasurementStats(), } - viewer := NewViewer(ctx, NewPrinter(nil, w, w), nil) - viewer.OutputSummary("") + viewer := NewViewer(ctx, NewPrinter(nil, w, w)) + viewer.OutputPingSummary("", run.Summary()) assert.Equal(t, "", w.String()) }) diff --git a/view/share_test.go b/view/share_test.go index 2583f7d..93239e1 100644 --- a/view/share_test.go +++ b/view/share_test.go @@ -3,7 +3,6 @@ package view import ( "bytes" "fmt" - "math" "testing" "github.com/stretchr/testify/assert" @@ -12,13 +11,10 @@ import ( func Test_OutputShare(t *testing.T) { t.Run("Single_location", func(t *testing.T) { ctx := createDefaultContext("ping") - ctx.AggregatedStats = []*MeasurementStats{ - {Sent: 1, Rcv: 0, Lost: 1, Loss: 100, Last: -1, Min: math.MaxFloat64, Avg: -1, Max: -1, Time: 0}, - } ctx.Share = true w := new(bytes.Buffer) errw := new(bytes.Buffer) - viewer := NewViewer(ctx, NewPrinter(nil, w, errw), nil) + viewer := NewViewer(ctx, NewPrinter(nil, w, errw)) viewer.OutputShare() assert.Equal(t, "", w.String()) @@ -36,7 +32,7 @@ func Test_OutputShare(t *testing.T) { errw := new(bytes.Buffer) printer := NewPrinter(nil, w, errw) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + viewer := NewViewer(ctx, printer) viewer.OutputShare() assert.Equal(t, "", w.String()) @@ -46,10 +42,6 @@ func Test_OutputShare(t *testing.T) { t.Run("Multiple_locations", func(t *testing.T) { ctx := createDefaultContext("ping") - ctx.AggregatedStats = []*MeasurementStats{ - NewMeasurementStats(), - NewMeasurementStats(), - } ctx.TableOutputRows = 2 ctx.History.Push(&HistoryItem{Id: measurementID2}) ctx.Share = true @@ -57,7 +49,7 @@ func Test_OutputShare(t *testing.T) { errw := new(bytes.Buffer) printer := NewPrinter(nil, w, errw) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + viewer := NewViewer(ctx, printer) viewer.OutputShare() assert.Equal(t, "", w.String()) @@ -69,10 +61,6 @@ func Test_OutputShare(t *testing.T) { history := NewHistoryBuffer(1) history.Push(&HistoryItem{Id: measurementID2}) ctx := &Context{ - AggregatedStats: []*MeasurementStats{ - NewMeasurementStats(), - NewMeasurementStats(), - }, TableOutputRows: 2, History: history, Share: true, @@ -83,7 +71,7 @@ func Test_OutputShare(t *testing.T) { errw := new(bytes.Buffer) printer := NewPrinter(nil, w, errw) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + viewer := NewViewer(ctx, printer) viewer.OutputShare() assert.Equal(t, "", w.String()) diff --git a/view/table.go b/view/table.go index 660a300..a9c1dee 100644 --- a/view/table.go +++ b/view/table.go @@ -80,7 +80,7 @@ func (v *viewer) outputTableView(m *globalping.Measurement) { v.printer.AreaUpdate(&output) } -func (v *viewer) outputInfinitePingTableView(stats *infinitePingStats) (string, error) { +func (v *viewer) outputInfinitePingTableView(stats *InfinitePingOutput) (string, error) { v.ctx.TableOutputRows = len(stats.probes) liveOutput, completedOutput := v.generateInfinitePingTableOutputs(stats) @@ -95,7 +95,7 @@ func (v *viewer) outputInfinitePingTableView(stats *infinitePingStats) (string, return completedOutput, nil } -func (v *viewer) outputInfinitePingLatencyTable(m *globalping.Measurement, stats *infinitePingStats) (string, error) { +func (v *viewer) outputInfinitePingLatencyTable(m *globalping.Measurement, stats *InfinitePingOutput) (string, error) { v.ctx.TableOutputRows = len(stats.probes) liveOutput, completedOutput := v.generateInfinitePingTableOutputs(stats) @@ -115,15 +115,15 @@ func (v *viewer) clearInfiniteTableOutput() { v.printer.AreaClear() } -func (v *viewer) generateInfinitePingTableOutputs(stats *infinitePingStats) (string, string) { +func (v *viewer) generateInfinitePingTableOutputs(stats *InfinitePingOutput) (string, string) { width, _ := v.printer.GetSize() liveOutput, completedOutput := v.renderInfinitePingTableVariants(stats, width-2) - creditInfo := v.getAPICreditConsumptionInfo(width) + creditInfo := v.getAPICreditConsumptionInfo(stats, width) return liveOutput + creditInfo, completedOutput + creditInfo } -func (v *viewer) renderInfinitePingTableVariants(stats *infinitePingStats, areaWidth int) (string, string) { +func (v *viewer) renderInfinitePingTableVariants(stats *InfinitePingOutput, areaWidth int) (string, string) { liveRows := [][]string{{"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}} completedRows := [][]string{{"Location", "Sent", "Loss", "Last", "Min", "Avg", "Max"}} diff --git a/view/table_test.go b/view/table_test.go index 360aec6..5e3830d 100644 --- a/view/table_test.go +++ b/view/table_test.go @@ -214,7 +214,7 @@ func Test_OutputTable_Ping_UnknownHistoryItem(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + viewer := NewViewer(ctx, printer) _, err := viewer.OutputTable(measurement) require.NoError(t, err) @@ -746,7 +746,7 @@ func Test_OutputTableThenShare_SeparatesMultiRowTable(t *testing.T) { w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - v := NewViewer(ctx, printer, nil) + v := NewViewer(ctx, printer) _, err := v.OutputTable(measurement) require.NoError(t, err) @@ -836,7 +836,7 @@ func renderTableWithContextForTest(t *testing.T, measurement *globalping.Measure w := new(bytes.Buffer) printer := NewPrinter(nil, w, w) printer.DisableStyling() - viewer := NewViewer(ctx, printer, nil) + viewer := NewViewer(ctx, printer) _, err := viewer.OutputTable(measurement) return w.String(), ctx, err diff --git a/view/utils_test.go b/view/utils_test.go index a9496a7..74e8519 100644 --- a/view/utils_test.go +++ b/view/utils_test.go @@ -145,7 +145,6 @@ func createDefaultContext(cmd string) *Context { Cmd: cmd, MeasurementsCreated: 1, History: NewHistoryBuffer(3), - RunSessionStartedAt: defaultCurrentTime, } if cmd == "ping" { diff --git a/view/viewer.go b/view/viewer.go index 8dcab9c..24bbe18 100644 --- a/view/viewer.go +++ b/view/viewer.go @@ -1,39 +1,29 @@ package view -import ( - "github.com/jsdelivr/globalping-cli/utils" - "github.com/jsdelivr/globalping-go" -) +import "github.com/jsdelivr/globalping-go" type Viewer interface { OutputDefault(id string, measurement *globalping.Measurement, opts *globalping.MeasurementCreate) OutputJSON(id string, measurement []byte) OutputLatency(id string, measurement *globalping.Measurement) error - OutputInfinite(measurement *globalping.Measurement) (string, error) + OutputInfinite(output *InfinitePingOutput) (string, error) OutputTable(measurement *globalping.Measurement) (string, error) OutputLive(measurement *globalping.Measurement, opts *globalping.MeasurementCreate, w, h int) - OutputSummary(infiniteTableOutput string) + OutputPingSummary(infiniteTableOutput string, summary *PingSummary) OutputShare() } type viewer struct { ctx *Context printer *Printer - utils utils.Utils - - pingProbeStatuses []infinitePingProbeStatus - pingRoundOrders map[string]uint64 - pingRoundCount uint64 } func NewViewer( ctx *Context, printer *Printer, - utils utils.Utils, ) Viewer { return &viewer{ ctx: ctx, printer: printer, - utils: utils, } } From 8a3be486812d2b80784479557df0511add485124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Tue, 15 Sep 2026 14:43:06 +0200 Subject: [PATCH 3/5] refactor: remove redundant probe header wrapper --- view/output.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/view/output.go b/view/output.go index 0910215..eb2e468 100644 --- a/view/output.go +++ b/view/output.go @@ -15,7 +15,7 @@ var ErrAllProbesFailed = errors.New("all probes failed") func (v *viewer) outputFailSummary(m *globalping.Measurement) error { for i := range m.Results { - v.printer.ErrPrintln(v.getProbeInfoWithTerminalStatus(&m.Results[i])) + v.printer.ErrPrintln(v.getProbeInfoWithStatus(&m.Results[i], true)) v.printer.Println(m.Results[i].Result.RawOutput) } @@ -115,10 +115,6 @@ func (v *viewer) getProbeInfo(result *globalping.ProbeMeasurement) string { return v.getProbeInfoWithStatus(result, false) } -func (v *viewer) getProbeInfoWithTerminalStatus(result *globalping.ProbeMeasurement) string { - return v.getProbeInfoWithStatus(result, true) -} - func (v *viewer) getProbeInfoWithStatus(result *globalping.ProbeMeasurement, includeOffline bool) string { text := getProbeInfoText(result) From 53b68279dd3d5e1dcc74ac83eff53e03adf0f6e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Thu, 17 Sep 2026 13:59:38 +0200 Subject: [PATCH 4/5] refactor: move shared text helpers out of table rendering --- view/output.go | 25 +++++++++++++++++++++++-- view/table.go | 31 +++++-------------------------- view/table_test.go | 4 ++-- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/view/output.go b/view/output.go index eb2e468..f47c0db 100644 --- a/view/output.go +++ b/view/output.go @@ -3,6 +3,7 @@ package view import ( "errors" "net/http" + "regexp" "strconv" "strings" @@ -13,6 +14,26 @@ import ( var ErrAllProbesFailed = errors.New("all probes failed") +var lineBreakPattern = regexp.MustCompile(`\r\n|\r|\n`) + +func normalizeLineBreaks(value string) string { + return lineBreakPattern.ReplaceAllString(value, "; ") +} + +func truncateText(value string, width int) string { + if runewidth.StringWidth(value) <= width { + return value + } + + tail := "" + + if width >= 3 { + tail = "..." + } + + return runewidth.Truncate(value, width, tail) +} + func (v *viewer) outputFailSummary(m *globalping.Measurement) error { for i := range m.Results { v.printer.ErrPrintln(v.getProbeInfoWithStatus(&m.Results[i], true)) @@ -41,7 +62,7 @@ func (v *viewer) OutputLive(measurement *globalping.Measurement, opts *globalpin for i := range measurement.Results { result := &measurement.Results[i] // Keep headers plain until both width and height have been trimmed. - header := normalizeTableLocation(getProbeInfoText(result)) + header := normalizeLineBreaks(getProbeInfoText(result)) status := "" if result.Result.Status == globalping.TestStatusFailed { @@ -50,7 +71,7 @@ func (v *viewer) OutputLive(measurement *globalping.Measurement, opts *globalpin maxW := w - 4 status = runewidth.Truncate(status, max(maxW, 0), "") - header = truncateTableCell(strings.ReplaceAll(header, "\t", " "), max(maxW-runewidth.StringWidth(status), 0)) + header = truncateText(strings.ReplaceAll(header, "\t", " "), max(maxW-runewidth.StringWidth(status), 0)) headerLines[lineCount] = true output.WriteString(header + status + "\n") bodyStart := output.Len() diff --git a/view/table.go b/view/table.go index 4740647..bce5307 100644 --- a/view/table.go +++ b/view/table.go @@ -21,10 +21,7 @@ const ( tableTimeoutValue = "time out" ) -var ( - httpBodySeparator = regexp.MustCompile(`\r?\n\r?\n`) - tableLocationLineBreak = regexp.MustCompile(`\r\n|\r|\n`) -) +var httpBodySeparator = regexp.MustCompile(`\r?\n\r?\n`) type httpSizeColumn int @@ -817,7 +814,7 @@ func (v *viewer) renderTable(rows [][]string, areaWidth int, measurementType glo } if column == 0 { - value = normalizeTableLocation(value) + value = normalizeLineBreaks(value) } columnWidths[column] = max(columnWidths[column], runewidth.StringWidth(value)) @@ -880,10 +877,10 @@ func (v *viewer) renderTable(rows [][]string, areaWidth int, measurementType glo value := strings.ReplaceAll(row[column], "\t", " ") if column == 0 { - value = normalizeTableLocation(value) + value = normalizeLineBreaks(value) } - value = truncateTableCell(value, columnWidths[column]) + value = truncateText(value, columnWidths[column]) value = padTableCell(value, columnWidths[column], column > 0) if color != ColorNone { @@ -911,7 +908,7 @@ func (v *viewer) renderTable(rows [][]string, areaWidth int, measurementType glo } spanWidth = min(max(spanWidth, runewidth.StringWidth(value)), availableWidth) - value = truncateTableCell(value, spanWidth) + value = truncateText(value, spanWidth) output.WriteString(centerTableCell(value, spanWidth)) } @@ -921,10 +918,6 @@ func (v *viewer) renderTable(rows [][]string, areaWidth int, measurementType glo return output.String() } -func normalizeTableLocation(value string) string { - return tableLocationLineBreak.ReplaceAllString(value, "; ") -} - func limitTableRows(output string, maxRows int) string { if output == "" || maxRows <= 0 { return "" @@ -1031,20 +1024,6 @@ func httpStatusCode(status string) (string, bool) { return code, true } -func truncateTableCell(value string, width int) string { - if runewidth.StringWidth(value) <= width { - return value - } - - tail := "" - - if width >= 3 { - tail = "..." - } - - return runewidth.Truncate(value, width, tail) -} - func padTableCell(value string, width int, left bool) string { padding := strings.Repeat(" ", max(width-runewidth.StringWidth(value), 0)) diff --git a/view/table_test.go b/view/table_test.go index 26a477e..4bbfcc8 100644 --- a/view/table_test.go +++ b/view/table_test.go @@ -94,8 +94,8 @@ func Test_GenerateMeasurementTable_Ping_LineBreaksNormalized(t *testing.T) { "Falkenstein, DE, EU, Hetzner Online GmbH; Lorem ipsum; Lorem ipsum dolor sit amet (AS0) | 1 | 0.00% | 5.46 ms | 5.46 ms | 5.46 ms | 5.46 ms\n" + "Nuremberg, DE, EU, Hetzner Online GmbH (AS0) | 1 | 0.00% | 4.07 ms | 4.07 ms | 4.07 ms | 4.07 ms\n" assert.Equal(t, expectedTable, table) - assert.Equal(t, "city; state; network", normalizeTableLocation("city\r\nstate\nnetwork")) - assert.Equal(t, "city; state; network", normalizeTableLocation("city\rstate\rnetwork")) + assert.Equal(t, "city; state; network", normalizeLineBreaks("city\r\nstate\nnetwork")) + assert.Equal(t, "city; state; network", normalizeLineBreaks("city\rstate\rnetwork")) } func Test_GenerateMeasurementTable_Ping_MaxTruncated(t *testing.T) { From b8dbb683ec6519295dbbc3d80688f008ca5388f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Thu, 17 Sep 2026 14:43:38 +0200 Subject: [PATCH 5/5] fix: preserve offline HTTP diagnostics in default and live output --- view/default.go | 2 +- view/output.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/view/default.go b/view/default.go index 8483b6c..99c8c07 100644 --- a/view/default.go +++ b/view/default.go @@ -20,7 +20,7 @@ func (v *viewer) OutputDefault(id string, measurement *globalping.Measurement, o v.printer.ErrPrintln(v.getProbeInfo(result)) - if result.Result.Status == globalping.TestStatusFailed { + if result.Result.Status == globalping.TestStatusFailed || result.Result.Status == globalping.TestStatusOffline { v.printer.Println(result.Result.RawOutput) continue diff --git a/view/output.go b/view/output.go index f47c0db..7cc3ea4 100644 --- a/view/output.go +++ b/view/output.go @@ -77,7 +77,7 @@ func (v *viewer) OutputLive(measurement *globalping.Measurement, opts *globalpin bodyStart := output.Len() switch { - case result.Result.Status == globalping.TestStatusFailed: + case result.Result.Status == globalping.TestStatusFailed || result.Result.Status == globalping.TestStatusOffline: output.WriteString(result.Result.RawOutput + "\n\n") case v.isBodyOnlyHttpGet(opts): if result.Result.RawBody != nil {