feat(cli): add json output to coder speedtest (#13475)

This commit is contained in:
Ethan
2024-06-05 18:31:44 +10:00
committed by GitHub
parent 9a757f8e74
commit a4bba520a2
7 changed files with 227 additions and 39 deletions

View File

@ -7,6 +7,7 @@ import (
"reflect" "reflect"
"strings" "strings"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/xerrors" "golang.org/x/xerrors"
"github.com/coder/serpent" "github.com/coder/serpent"
@ -143,7 +144,11 @@ func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) {
// Format implements OutputFormat. // Format implements OutputFormat.
func (f *tableFormat) Format(_ context.Context, data any) (string, error) { func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
return DisplayTable(data, f.sort, f.columns) headers := make(table.Row, len(f.allColumns))
for i, header := range f.allColumns {
headers[i] = header
}
return renderTable(data, f.sort, headers, f.columns)
} }
type jsonFormat struct{} type jsonFormat struct{}

View File

@ -22,6 +22,13 @@ func Table() table.Writer {
return tableWriter return tableWriter
} }
// This type can be supplied as part of a slice to DisplayTable
// or to a `TableFormat` `Format` call to render a separator.
// Leading separators are not supported and trailing separators
// are ignored by the table formatter.
// e.g. `[]any{someRow, TableSeparator, someRow}`
type TableSeparator struct{}
// filterTableColumns returns configurations to hide columns // filterTableColumns returns configurations to hide columns
// that are not provided in the array. If the array is empty, // that are not provided in the array. If the array is empty,
// no filtering will occur! // no filtering will occur!
@ -47,8 +54,12 @@ func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig
return columnConfigs return columnConfigs
} }
// DisplayTable renders a table as a string. The input argument must be a slice // DisplayTable renders a table as a string. The input argument can be:
// of structs. At least one field in the struct must have a `table:""` tag // - a struct slice.
// - an interface slice, where the first element is a struct,
// and all other elements are of the same type, or a TableSeparator.
//
// At least one field in the struct must have a `table:""` tag
// containing the name of the column in the outputted table. // containing the name of the column in the outputted table.
// //
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"` // If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
@ -66,11 +77,20 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
v := reflect.Indirect(reflect.ValueOf(out)) v := reflect.Indirect(reflect.ValueOf(out))
if v.Kind() != reflect.Slice { if v.Kind() != reflect.Slice {
return "", xerrors.Errorf("DisplayTable called with a non-slice type") return "", xerrors.New("DisplayTable called with a non-slice type")
}
var tableType reflect.Type
if v.Type().Elem().Kind() == reflect.Interface {
if v.Len() == 0 {
return "", xerrors.New("DisplayTable called with empty interface slice")
}
tableType = reflect.Indirect(reflect.ValueOf(v.Index(0).Interface())).Type()
} else {
tableType = v.Type().Elem()
} }
// Get the list of table column headers. // Get the list of table column headers.
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem(), true) headersRaw, defaultSort, err := typeToTableHeaders(tableType, true)
if err != nil { if err != nil {
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err) return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
} }
@ -82,9 +102,8 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
} }
headers := make(table.Row, len(headersRaw)) headers := make(table.Row, len(headersRaw))
for i, header := range headersRaw { for i, header := range headersRaw {
headers[i] = header headers[i] = strings.ReplaceAll(header, "_", " ")
} }
// Verify that the given sort column and filter columns are valid. // Verify that the given sort column and filter columns are valid.
if sort != "" || len(filterColumns) != 0 { if sort != "" || len(filterColumns) != 0 {
headersMap := make(map[string]string, len(headersRaw)) headersMap := make(map[string]string, len(headersRaw))
@ -130,6 +149,11 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`)) return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
} }
} }
return renderTable(out, sort, headers, filterColumns)
}
func renderTable(out any, sort string, headers table.Row, filterColumns []string) (string, error) {
v := reflect.Indirect(reflect.ValueOf(out))
// Setup the table formatter. // Setup the table formatter.
tw := Table() tw := Table()
@ -143,15 +167,22 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
// Write each struct to the table. // Write each struct to the table.
for i := 0; i < v.Len(); i++ { for i := 0; i < v.Len(); i++ {
cur := v.Index(i).Interface()
_, ok := cur.(TableSeparator)
if ok {
tw.AppendSeparator()
continue
}
// Format the row as a slice. // Format the row as a slice.
rowMap, err := valueToTableMap(v.Index(i)) // ValueToTableMap does what `reflect.Indirect` does
rowMap, err := valueToTableMap(reflect.ValueOf(cur))
if err != nil { if err != nil {
return "", xerrors.Errorf("get table row map %v: %w", i, err) return "", xerrors.Errorf("get table row map %v: %w", i, err)
} }
rowSlice := make([]any, len(headers)) rowSlice := make([]any, len(headers))
for i, h := range headersRaw { for i, h := range headers {
v, ok := rowMap[h] v, ok := rowMap[h.(string)]
if !ok { if !ok {
v = nil v = nil
} }
@ -188,25 +219,28 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
// returned. If the table tag is malformed, an error is returned. // returned. If the table tag is malformed, an error is returned.
// //
// The returned name is transformed from "snake_case" to "normal text". // The returned name is transformed from "snake_case" to "normal text".
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, skipParentName bool, err error) { func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
tags, err := structtag.Parse(string(field.Tag)) tags, err := structtag.Parse(string(field.Tag))
if err != nil { if err != nil {
return "", false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
} }
tag, err := tags.Get("table") tag, err := tags.Get("table")
if err != nil || tag.Name == "-" { if err != nil || tag.Name == "-" {
// tags.Get only returns an error if the tag is not found. // tags.Get only returns an error if the tag is not found.
return "", false, false, false, nil return "", false, false, false, false, nil
} }
defaultSortOpt := false defaultSortOpt := false
noSortOpt = false
recursiveOpt := false recursiveOpt := false
skipParentNameOpt := false skipParentNameOpt := false
for _, opt := range tag.Options { for _, opt := range tag.Options {
switch opt { switch opt {
case "default_sort": case "default_sort":
defaultSortOpt = true defaultSortOpt = true
case "nosort":
noSortOpt = true
case "recursive": case "recursive":
recursiveOpt = true recursiveOpt = true
case "recursive_inline": case "recursive_inline":
@ -216,11 +250,11 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, r
recursiveOpt = true recursiveOpt = true
skipParentNameOpt = true skipParentNameOpt = true
default: default:
return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
} }
} }
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, nil return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
} }
func isStructOrStructPointer(t reflect.Type) bool { func isStructOrStructPointer(t reflect.Type) bool {
@ -244,12 +278,16 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
headers := []string{} headers := []string{}
defaultSortName := "" defaultSortName := ""
noSortOpt := false
for i := 0; i < t.NumField(); i++ { for i := 0; i < t.NumField(); i++ {
field := t.Field(i) field := t.Field(i)
name, defaultSort, recursive, skip, err := parseTableStructTag(field) name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
if err != nil { if err != nil {
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
} }
if requireDefault && noSort {
noSortOpt = true
}
if name == "" && (recursive && skip) { if name == "" && (recursive && skip) {
return nil, "", xerrors.Errorf("a name is required for the field %q. "+ return nil, "", xerrors.Errorf("a name is required for the field %q. "+
@ -292,8 +330,8 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
headers = append(headers, name) headers = append(headers, name)
} }
if defaultSortName == "" && requireDefault { if defaultSortName == "" && requireDefault && !noSortOpt {
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String()) return nil, "", xerrors.Errorf("no field marked as default_sort or nosort in type %q", t.String())
} }
return headers, defaultSortName, nil return headers, defaultSortName, nil
@ -320,7 +358,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
for i := 0; i < val.NumField(); i++ { for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i) field := val.Type().Field(i)
fieldVal := val.Field(i) fieldVal := val.Field(i)
name, _, recursive, skip, err := parseTableStructTag(field) name, _, _, recursive, skip, err := parseTableStructTag(field)
if err != nil { if err != nil {
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
} }

View File

@ -218,6 +218,42 @@ Alice 25
compareTables(t, expected, out) compareTables(t, expected, out)
}) })
// This test ensures we can display dynamically typed slices
t.Run("Interfaces", func(t *testing.T) {
t.Parallel()
in := []any{tableTest1{}}
out, err := cliui.DisplayTable(in, "", nil)
t.Log("rendered table:\n" + out)
require.NoError(t, err)
other := []tableTest1{{}}
expected, err := cliui.DisplayTable(other, "", nil)
require.NoError(t, err)
compareTables(t, expected, out)
})
t.Run("WithSeparator", func(t *testing.T) {
t.Parallel()
expected := `
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
-------------------------------------------------------------------------------------------------------------------------------------------------------------
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
-------------------------------------------------------------------------------------------------------------------------------------------------------------
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
`
var inlineIn []any
for _, v := range in {
inlineIn = append(inlineIn, v)
inlineIn = append(inlineIn, cliui.TableSeparator{})
}
out, err := cliui.DisplayTable(inlineIn, "", nil)
t.Log("rendered table:\n" + out)
require.NoError(t, err)
compareTables(t, expected, out)
})
// This test ensures that safeties against invalid use of `table` tags // This test ensures that safeties against invalid use of `table` tags
// causes errors (even without data). // causes errors (even without data).
t.Run("Errors", func(t *testing.T) { t.Run("Errors", func(t *testing.T) {
@ -255,14 +291,6 @@ Alice 25
_, err := cliui.DisplayTable(in, "", nil) _, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err) require.Error(t, err)
}) })
t.Run("WithData", func(t *testing.T) {
t.Parallel()
in := []any{tableTest1{}}
_, err := cliui.DisplayTable(in, "", nil)
require.Error(t, err)
})
}) })
t.Run("NotStruct", func(t *testing.T) { t.Run("NotStruct", func(t *testing.T) {

View File

@ -6,7 +6,6 @@ import (
"os" "os"
"time" "time"
"github.com/jedib0t/go-pretty/v6/table"
"golang.org/x/xerrors" "golang.org/x/xerrors"
tsspeedtest "tailscale.com/net/speedtest" tsspeedtest "tailscale.com/net/speedtest"
"tailscale.com/wgengine/capture" "tailscale.com/wgengine/capture"
@ -19,12 +18,51 @@ import (
"github.com/coder/serpent" "github.com/coder/serpent"
) )
type SpeedtestResult struct {
Overall SpeedtestResultInterval `json:"overall"`
Intervals []SpeedtestResultInterval `json:"intervals"`
}
type SpeedtestResultInterval struct {
StartTimeSeconds float64 `json:"start_time_seconds"`
EndTimeSeconds float64 `json:"end_time_seconds"`
ThroughputMbits float64 `json:"throughput_mbits"`
}
type speedtestTableItem struct {
Interval string `table:"Interval,nosort"`
Throughput string `table:"Throughput"`
}
func (r *RootCmd) speedtest() *serpent.Command { func (r *RootCmd) speedtest() *serpent.Command {
var ( var (
direct bool direct bool
duration time.Duration duration time.Duration
direction string direction string
pcapFile string pcapFile string
formatter = cliui.NewOutputFormatter(
cliui.ChangeFormatterData(cliui.TableFormat([]speedtestTableItem{}, []string{"Interval", "Throughput"}), func(data any) (any, error) {
res, ok := data.(SpeedtestResult)
if !ok {
// This should never happen
return "", xerrors.Errorf("expected speedtestResult, got %T", data)
}
tableRows := make([]any, len(res.Intervals)+2)
for i, r := range res.Intervals {
tableRows[i] = speedtestTableItem{
Interval: fmt.Sprintf("%.2f-%.2f sec", r.StartTimeSeconds, r.EndTimeSeconds),
Throughput: fmt.Sprintf("%.4f Mbits/sec", r.ThroughputMbits),
}
}
tableRows[len(res.Intervals)] = cliui.TableSeparator{}
tableRows[len(res.Intervals)+1] = speedtestTableItem{
Interval: fmt.Sprintf("%.2f-%.2f sec", res.Overall.StartTimeSeconds, res.Overall.EndTimeSeconds),
Throughput: fmt.Sprintf("%.4f Mbits/sec", res.Overall.ThroughputMbits),
}
return tableRows, nil
}),
cliui.JSONFormat(),
)
) )
client := new(codersdk.Client) client := new(codersdk.Client)
cmd := &serpent.Command{ cmd := &serpent.Command{
@ -124,24 +162,32 @@ func (r *RootCmd) speedtest() *serpent.Command {
default: default:
return xerrors.Errorf("invalid direction: %q", direction) return xerrors.Errorf("invalid direction: %q", direction)
} }
cliui.Infof(inv.Stdout, "Starting a %ds %s test...", int(duration.Seconds()), tsDir) cliui.Infof(inv.Stderr, "Starting a %ds %s test...", int(duration.Seconds()), tsDir)
results, err := conn.Speedtest(ctx, tsDir, duration) results, err := conn.Speedtest(ctx, tsDir, duration)
if err != nil { if err != nil {
return err return err
} }
tableWriter := cliui.Table() var outputResult SpeedtestResult
tableWriter.AppendHeader(table.Row{"Interval", "Throughput"})
startTime := results[0].IntervalStart startTime := results[0].IntervalStart
for _, r := range results { outputResult.Intervals = make([]SpeedtestResultInterval, len(results)-1)
if r.Total { for i, r := range results {
tableWriter.AppendSeparator() interval := SpeedtestResultInterval{
StartTimeSeconds: r.IntervalStart.Sub(startTime).Seconds(),
EndTimeSeconds: r.IntervalEnd.Sub(startTime).Seconds(),
ThroughputMbits: r.MBitsPerSecond(),
}
if r.Total {
interval.StartTimeSeconds = 0
outputResult.Overall = interval
} else {
outputResult.Intervals[i] = interval
} }
tableWriter.AppendRow(table.Row{
fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds()),
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
})
} }
_, err = fmt.Fprintln(inv.Stdout, tableWriter.Render()) out, err := formatter.Format(inv.Context(), outputResult)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, out)
return err return err
}, },
} }
@ -173,5 +219,6 @@ func (r *RootCmd) speedtest() *serpent.Command {
Value: serpent.StringOf(&pcapFile), Value: serpent.StringOf(&pcapFile),
}, },
} }
formatter.AttachOptions(&cmd.Options)
return cmd return cmd
} }

View File

@ -1,7 +1,9 @@
package cli_test package cli_test
import ( import (
"bytes"
"context" "context"
"encoding/json"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -10,6 +12,7 @@ import (
"cdr.dev/slog" "cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest" "cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
@ -56,3 +59,45 @@ func TestSpeedtest(t *testing.T) {
}) })
<-cmdDone <-cmdDone
} }
func TestSpeedtestJson(t *testing.T) {
t.Parallel()
t.Skip("Potentially flaky test - see https://github.com/coder/coder/issues/6321")
if testing.Short() {
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
}
client, workspace, agentToken := setupWorkspaceForAgent(t)
_ = agenttest.New(t, client.URL, agentToken)
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
require.Eventually(t, func() bool {
ws, err := client.Workspace(ctx, workspace.ID)
if !assert.NoError(t, err) {
return false
}
a := ws.LatestBuild.Resources[0].Agents[0]
return a.Status == codersdk.WorkspaceAgentConnected &&
a.LifecycleState == codersdk.WorkspaceAgentLifecycleReady
}, testutil.WaitLong, testutil.IntervalFast, "agent is not ready")
inv, root := clitest.New(t, "speedtest", "--output=json", workspace.Name)
clitest.SetupConfig(t, client, root)
out := bytes.NewBuffer(nil)
inv.Stdout = out
ctx, cancel = context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
inv.Logger = slogtest.Make(t, nil).Named("speedtest").Leveled(slog.LevelDebug)
cmdDone := tGo(t, func() {
err := inv.WithContext(ctx).Run()
assert.NoError(t, err)
})
<-cmdDone
var result cli.SpeedtestResult
require.NoError(t, json.Unmarshal(out.Bytes(), &result))
require.Len(t, result.Intervals, 5)
}

View File

@ -6,6 +6,10 @@ USAGE:
Run upload and download tests from your machine to a workspace Run upload and download tests from your machine to a workspace
OPTIONS: OPTIONS:
-c, --column string-array (default: Interval,Throughput)
Columns to display in table output. Available columns: Interval,
Throughput.
-d, --direct bool -d, --direct bool
Specifies whether to wait for a direct connection before testing Specifies whether to wait for a direct connection before testing
speed. speed.
@ -14,6 +18,9 @@ OPTIONS:
Specifies whether to run in reverse mode where the client receives and Specifies whether to run in reverse mode where the client receives and
the server sends. the server sends.
-o, --output string (default: table)
Output format. Available formats: table, json.
--pcap-file string --pcap-file string
Specifies a file to write a network capture to. Specifies a file to write a network capture to.

18
docs/cli/speedtest.md generated
View File

@ -45,3 +45,21 @@ Specifies the duration to monitor traffic.
| Type | <code>string</code> | | Type | <code>string</code> |
Specifies a file to write a network capture to. Specifies a file to write a network capture to.
### -c, --column
| | |
| ------- | -------------------------------- |
| Type | <code>string-array</code> |
| Default | <code>Interval,Throughput</code> |
Columns to display in table output. Available columns: Interval, Throughput.
### -o, --output
| | |
| ------- | ------------------- |
| Type | <code>string</code> |
| Default | <code>table</code> |
Output format. Available formats: table, json.