diff --git a/client/cmd/status.go b/client/cmd/status.go index f2e5bcc66..6049d701a 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -19,6 +19,7 @@ import ( var ( detailFlag bool + tableFlag bool ipv4Flag bool ipv6Flag bool jsonFlag bool @@ -43,11 +44,12 @@ func init() { ipsFilterMap = make(map[string]struct{}) prefixNamesFilterMap = make(map[string]struct{}) statusCmd.PersistentFlags().BoolVarP(&detailFlag, "detail", "d", false, "display detailed status information in human-readable format") + statusCmd.PersistentFlags().BoolVarP(&tableFlag, "table", "t", false, "display peers in a table, supports the --filter-by-* flags") statusCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display detailed status information in json format") statusCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display detailed status information in yaml format") statusCmd.PersistentFlags().BoolVarP(&ipv4Flag, "ipv4", "4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33") statusCmd.PersistentFlags().BoolVarP(&ipv6Flag, "ipv6", "6", false, "display only NetBird IPv6 of this peer") - statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6") + statusCmd.MarkFlagsMutuallyExclusive("detail", "table", "json", "yaml", "ipv4", "ipv6") statusCmd.PersistentFlags().StringSliceVarP(&ipsFilter, "filter-by-ips", "I", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1") statusCmd.PersistentFlags().StringSliceVarP(&prefixNamesFilter, "filter-by-names", "N", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud") statusCmd.PersistentFlags().StringVarP(&statusFilter, "filter-by-status", "S", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected") @@ -143,6 +145,8 @@ func statusFunc(cmd *cobra.Command, args []string) error { switch { case detailFlag: statusOutputString = outputInformationHolder.FullDetailSummary() + case tableFlag: + statusOutputString = outputInformationHolder.PeersTable() case jsonFlag: statusOutputString, err = outputInformationHolder.JSON() case yamlFlag: @@ -238,7 +242,7 @@ func parseFilters() error { } func enableDetailFlagWhenFilterFlag() { - if !detailFlag && !jsonFlag && !yamlFlag { + if !detailFlag && !jsonFlag && !yamlFlag && !tableFlag { detailFlag = true } } diff --git a/client/cmd/status_test.go b/client/cmd/status_test.go index 03608eab0..ca0fd97dd 100644 --- a/client/cmd/status_test.go +++ b/client/cmd/status_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParsingOfIP(t *testing.T) { @@ -13,3 +14,61 @@ func TestParsingOfIP(t *testing.T) { assert.Equal(t, "192.168.178.123\n", parsedIP) } + +// resetStatusFlags restores only the status-specific output flags. The global +// root flags (setup-key and friends) are shared with other tests in the +// package, so they must not be touched here. +func resetStatusFlags(t *testing.T) { + t.Helper() + t.Cleanup(func() { + for _, name := range []string{"detail", "table", "json", "yaml", "ipv4", "ipv6"} { + f := statusCmd.Flags().Lookup(name) + require.NotNil(t, f, "flag %s must exist", name) + require.NoError(t, f.Value.Set(f.DefValue)) + f.Changed = false + } + }) +} + +func TestStatusTableFlagExclusive(t *testing.T) { + for _, other := range []string{"detail", "json", "yaml", "ipv4", "ipv6"} { + t.Run(other, func(t *testing.T) { + resetStatusFlags(t) + require.NoError(t, statusCmd.Flags().Set("table", "true")) + require.NoError(t, statusCmd.Flags().Set(other, "true")) + + assert.Error(t, statusCmd.ValidateFlagGroups(), "--table with --%s must be rejected", other) + }) + } + + t.Run("alone", func(t *testing.T) { + resetStatusFlags(t) + require.NoError(t, statusCmd.Flags().Set("table", "true")) + + assert.NoError(t, statusCmd.ValidateFlagGroups()) + }) +} + +func TestFilterEnablesDetailOnlyWithoutOutputFormat(t *testing.T) { + tests := []struct { + name string + table bool + json bool + wantDetail bool + }{ + {name: "no format", wantDetail: true}, + {name: "table", table: true, wantDetail: false}, + {name: "json", json: true, wantDetail: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetStatusFlags(t) + tableFlag, jsonFlag = tt.table, tt.json + + enableDetailFlagWhenFilterFlag() + + assert.Equal(t, tt.wantDetail, detailFlag) + }) + } +} diff --git a/client/status/table.go b/client/status/table.go new file mode 100644 index 000000000..f1cd7e01d --- /dev/null +++ b/client/status/table.go @@ -0,0 +1,55 @@ +package status + +import ( + "fmt" + "strings" + "text/tabwriter" + "time" +) + +// PeersTable renders the peers as an aligned table, one row per peer. +func (o *OutputOverview) PeersTable() string { + var sb strings.Builder + fmt.Fprintf(&sb, "Peers (%d/%d connected):\n", o.Peers.Connected, o.Peers.Total) + + tw := tabwriter.NewWriter(&sb, 0, 0, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tIP\tSTATUS\tCONN\tLATENCY\tRX / TX") + for _, p := range o.Peers.Details { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s / %s\n", + shortName(p.FQDN), + dashIfEmpty(p.IP), + p.Status, + dashIfEmpty(p.ConnType), + tableLatency(p), + toIEC(p.TransferReceived), + toIEC(p.TransferSent), + ) + } + // The writer targets a strings.Builder, which never returns an error. + _ = tw.Flush() + + return sb.String() +} + +// shortName drops the domain suffix, which is the same for every peer. +func shortName(fqdn string) string { + name, _, _ := strings.Cut(fqdn, ".") + return dashIfEmpty(name) +} + +func tableLatency(p PeerStateDetailOutput) string { + if p.Latency <= 0 { + return "-" + } + if p.Latency >= time.Millisecond { + return p.Latency.Round(time.Millisecond).String() + } + return p.Latency.Round(time.Microsecond).String() +} + +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/client/status/table_test.go b/client/status/table_test.go new file mode 100644 index 000000000..c60e57917 --- /dev/null +++ b/client/status/table_test.go @@ -0,0 +1,176 @@ +package status + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + protobuf "google.golang.org/protobuf/proto" + + "github.com/netbirdio/netbird/client/anonymize" + "github.com/netbirdio/netbird/client/proto" +) + +func TestPeersTable(t *testing.T) { + overview := OutputOverview{Peers: PeersStateOutput{ + Total: 2, + Connected: 1, + Details: []PeerStateDetailOutput{ + { + FQDN: "web-01.netbird.cloud", + IP: "100.64.0.12", + PubKey: "k3Vx9pQ2abcdefghijklmnopqrstuvwxyz0123456789=", + Status: "Connected", + ConnType: "P2P", + Latency: 12 * time.Millisecond, + TransferReceived: 1024, + TransferSent: 2048, + }, + { + FQDN: "old-nas.netbird.cloud", + IP: "100.64.0.77", + Status: "Idle", + }, + }, + }} + + lines := strings.Split(strings.TrimRight(overview.PeersTable(), "\n"), "\n") + require.Len(t, lines, 4, "title, header and one line per peer") + + assert.Equal(t, "Peers (1/2 connected):", lines[0]) + assert.Equal(t, + []string{"NAME", "IP", "STATUS", "CONN", "LATENCY", "RX", "/", "TX"}, + strings.Fields(lines[1])) + + connected := strings.Fields(lines[2]) + assert.Equal(t, "web-01", connected[0], "domain suffix must be dropped") + assert.Equal(t, "Connected", connected[2]) + assert.Equal(t, "P2P", connected[3]) + assert.Equal(t, "12ms", connected[4]) + assert.NotContains(t, lines[2], "k3Vx9pQ2", "public key must not be shown") + assert.Contains(t, lines[2], "1.0 KiB / 2.0 KiB") + + idle := strings.Fields(lines[3]) + assert.Equal(t, []string{"old-nas", "100.64.0.77", "Idle", "-", "-", "0", "B", "/", "0", "B"}, idle, + "missing values render as dashes") +} + +func TestPeersTable_NoPeers(t *testing.T) { + out := (&OutputOverview{}).PeersTable() + assert.True(t, strings.HasPrefix(out, "Peers (0/0 connected):\n")) + assert.Contains(t, out, "NAME") +} + +func tableRows(t *testing.T, opts ConvertOptions) []string { + t.Helper() + + opts.DaemonVersion = resp.GetDaemonVersion() + opts.DaemonStatus = ParseDaemonStatus(resp.GetStatus()) + // The anonymizer rewrites slices in place, so work on a copy to keep the + // shared fixture intact for the other tests. + fullStatus := protobuf.Clone(resp.GetFullStatus()).(*proto.FullStatus) + overview := ConvertToStatusOutputOverview(fullStatus, opts) + out := overview.PeersTable() + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + require.GreaterOrEqual(t, len(lines), 2, "title and header are always present") + return lines +} + +// The fixture holds peer-1 (connected, P2P) and peer-2 (connected, relayed). +func TestPeersTable_Filters(t *testing.T) { + tests := []struct { + name string + opts ConvertOptions + wantTitle string + wantPeers []string + }{ + { + name: "no filter lists every peer sorted by IP", + wantTitle: "Peers (2/2 connected):", + wantPeers: []string{"peer-1", "peer-2"}, + }, + { + name: "connection type P2P", + opts: ConvertOptions{ConnectionTypeFilter: "P2P"}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-1"}, + }, + { + name: "connection type Relayed", + opts: ConvertOptions{ConnectionTypeFilter: "relayed"}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-2"}, + }, + { + name: "status with no match", + opts: ConvertOptions{StatusFilter: "idle"}, + wantTitle: "Peers (0/0 connected):", + }, + { + name: "name prefix", + opts: ConvertOptions{ + PrefixNamesFilter: []string{"peer-2"}, + PrefixNamesFilterMap: map[string]struct{}{"peer-2": {}}, + }, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-2"}, + }, + { + name: "IPv4", + opts: ConvertOptions{IPsFilter: map[string]struct{}{"192.168.178.101": {}}}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-1"}, + }, + { + name: "IPv6", + opts: ConvertOptions{IPsFilter: map[string]struct{}{"fd00::1": {}}}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines := tableRows(t, tt.opts) + + assert.Equal(t, tt.wantTitle, lines[0]) + rows := lines[2:] + require.Len(t, rows, len(tt.wantPeers), "one row per matching peer") + for i, name := range tt.wantPeers { + assert.True(t, strings.HasPrefix(rows[i], name+" "), "row %d should be %s, got %q", i, name, rows[i]) + } + }) + } +} + +// The table shows whatever the anonymizer left in the overview, so it must +// carry the redacted names rather than the originals. +func TestPeersTable_Anonymized(t *testing.T) { + lines := tableRows(t, ConvertOptions{Anonymize: true, AnonymizeLevel: anonymize.LevelStrict}) + + for _, row := range lines[2:] { + assert.NotContains(t, row, "awesome-domain.com", "FQDN must be anonymized") + assert.NotContains(t, row, "peer-", "peer name must be anonymized") + } +} + +func TestShortName(t *testing.T) { + assert.Equal(t, "web-01", shortName("web-01.netbird.cloud")) + assert.Equal(t, "web-01", shortName("web-01")) + assert.Equal(t, "-", shortName("")) +} + +func TestTableLatency(t *testing.T) { + tests := map[time.Duration]string{ + 0: "-", + 145939625 * time.Nanosecond: "146ms", + 12 * time.Millisecond: "12ms", + 420*time.Microsecond + 700: "421µs", + } + for in, want := range tests { + assert.Equal(t, want, tableLatency(PeerStateDetailOutput{Latency: in}), "latency %s", in) + } +}