[client] Add --table output to netbird status (#8305)

This commit is contained in:
Misha Bragin
2026-10-24 14:01:45 -04:00
committed by GitHub
parent 9dd51ca42e
commit dd38fabc82
4 changed files with 296 additions and 2 deletions
+6 -2
View File
@@ -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
}
}
+59
View File
@@ -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)
})
}
}
+55
View File
@@ -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
}
+176
View File
@@ -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)
}
}