mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-24 22:39:09 +02:00
[client] Add --table output to netbird status (#8305)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user