Skip to content

Commit a4bba52

Browse files
feat(cli): add json output to coder speedtest (coder#13475)
1 parent 9a757f8 commit a4bba52

File tree

7 files changed

+226
-38
lines changed

7 files changed

+226
-38
lines changed

cli/cliui/output.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"reflect"
88
"strings"
99

10+
"github.com/jedib0t/go-pretty/v6/table"
1011
"golang.org/x/xerrors"
1112

1213
"github.com/coder/serpent"
@@ -143,7 +144,11 @@ func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) {
143144

144145
// Format implements OutputFormat.
145146
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
146-
return DisplayTable(data, f.sort, f.columns)
147+
headers := make(table.Row, len(f.allColumns))
148+
for i, header := range f.allColumns {
149+
headers[i] = header
150+
}
151+
return renderTable(data, f.sort, headers, f.columns)
147152
}
148153

149154
type jsonFormat struct{}

cli/cliui/table.go

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ func Table() table.Writer {
2222
return tableWriter
2323
}
2424

25+
// This type can be supplied as part of a slice to DisplayTable
26+
// or to a `TableFormat` `Format` call to render a separator.
27+
// Leading separators are not supported and trailing separators
28+
// are ignored by the table formatter.
29+
// e.g. `[]any{someRow, TableSeparator, someRow}`
30+
type TableSeparator struct{}
31+
2532
// filterTableColumns returns configurations to hide columns
2633
// that are not provided in the array. If the array is empty,
2734
// no filtering will occur!
@@ -47,8 +54,12 @@ func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig
4754
return columnConfigs
4855
}
4956

50-
// DisplayTable renders a table as a string. The input argument must be a slice
51-
// of structs. At least one field in the struct must have a `table:""` tag
57+
// DisplayTable renders a table as a string. The input argument can be:
58+
// - a struct slice.
59+
// - an interface slice, where the first element is a struct,
60+
// and all other elements are of the same type, or a TableSeparator.
61+
//
62+
// At least one field in the struct must have a `table:""` tag
5263
// containing the name of the column in the outputted table.
5364
//
5465
// 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)
6677
v := reflect.Indirect(reflect.ValueOf(out))
6778

6879
if v.Kind() != reflect.Slice {
69-
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
80+
return "", xerrors.New("DisplayTable called with a non-slice type")
81+
}
82+
var tableType reflect.Type
83+
if v.Type().Elem().Kind() == reflect.Interface {
84+
if v.Len() == 0 {
85+
return "", xerrors.New("DisplayTable called with empty interface slice")
86+
}
87+
tableType = reflect.Indirect(reflect.ValueOf(v.Index(0).Interface())).Type()
88+
} else {
89+
tableType = v.Type().Elem()
7090
}
7191

7292
// Get the list of table column headers.
73-
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem(), true)
93+
headersRaw, defaultSort, err := typeToTableHeaders(tableType, true)
7494
if err != nil {
7595
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
7696
}
@@ -82,9 +102,8 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
82102
}
83103
headers := make(table.Row, len(headersRaw))
84104
for i, header := range headersRaw {
85-
headers[i] = header
105+
headers[i] = strings.ReplaceAll(header, "_", " ")
86106
}
87-
88107
// Verify that the given sort column and filter columns are valid.
89108
if sort != "" || len(filterColumns) != 0 {
90109
headersMap := make(map[string]string, len(headersRaw))
@@ -130,6 +149,11 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
130149
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
131150
}
132151
}
152+
return renderTable(out, sort, headers, filterColumns)
153+
}
154+
155+
func renderTable(out any, sort string, headers table.Row, filterColumns []string) (string, error) {
156+
v := reflect.Indirect(reflect.ValueOf(out))
133157

134158
// Setup the table formatter.
135159
tw := Table()
@@ -143,15 +167,22 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
143167

144168
// Write each struct to the table.
145169
for i := 0; i < v.Len(); i++ {
170+
cur := v.Index(i).Interface()
171+
_, ok := cur.(TableSeparator)
172+
if ok {
173+
tw.AppendSeparator()
174+
continue
175+
}
146176
// Format the row as a slice.
147-
rowMap, err := valueToTableMap(v.Index(i))
177+
// ValueToTableMap does what `reflect.Indirect` does
178+
rowMap, err := valueToTableMap(reflect.ValueOf(cur))
148179
if err != nil {
149180
return "", xerrors.Errorf("get table row map %v: %w", i, err)
150181
}
151182

152183
rowSlice := make([]any, len(headers))
153-
for i, h := range headersRaw {
154-
v, ok := rowMap[h]
184+
for i, h := range headers {
185+
v, ok := rowMap[h.(string)]
155186
if !ok {
156187
v = nil
157188
}
@@ -188,25 +219,28 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
188219
// returned. If the table tag is malformed, an error is returned.
189220
//
190221
// The returned name is transformed from "snake_case" to "normal text".
191-
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, skipParentName bool, err error) {
222+
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
192223
tags, err := structtag.Parse(string(field.Tag))
193224
if err != nil {
194-
return "", false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
225+
return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
195226
}
196227

197228
tag, err := tags.Get("table")
198229
if err != nil || tag.Name == "-" {
199230
// tags.Get only returns an error if the tag is not found.
200-
return "", false, false, false, nil
231+
return "", false, false, false, false, nil
201232
}
202233

203234
defaultSortOpt := false
235+
noSortOpt = false
204236
recursiveOpt := false
205237
skipParentNameOpt := false
206238
for _, opt := range tag.Options {
207239
switch opt {
208240
case "default_sort":
209241
defaultSortOpt = true
242+
case "nosort":
243+
noSortOpt = true
210244
case "recursive":
211245
recursiveOpt = true
212246
case "recursive_inline":
@@ -216,11 +250,11 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, r
216250
recursiveOpt = true
217251
skipParentNameOpt = true
218252
default:
219-
return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
253+
return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
220254
}
221255
}
222256

223-
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, nil
257+
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
224258
}
225259

226260
func isStructOrStructPointer(t reflect.Type) bool {
@@ -244,12 +278,16 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
244278

245279
headers := []string{}
246280
defaultSortName := ""
281+
noSortOpt := false
247282
for i := 0; i < t.NumField(); i++ {
248283
field := t.Field(i)
249-
name, defaultSort, recursive, skip, err := parseTableStructTag(field)
284+
name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
250285
if err != nil {
251286
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
252287
}
288+
if requireDefault && noSort {
289+
noSortOpt = true
290+
}
253291

254292
if name == "" && (recursive && skip) {
255293
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,
292330
headers = append(headers, name)
293331
}
294332

295-
if defaultSortName == "" && requireDefault {
296-
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
333+
if defaultSortName == "" && requireDefault && !noSortOpt {
334+
return nil, "", xerrors.Errorf("no field marked as default_sort or nosort in type %q", t.String())
297335
}
298336

299337
return headers, defaultSortName, nil
@@ -320,7 +358,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
320358
for i := 0; i < val.NumField(); i++ {
321359
field := val.Type().Field(i)
322360
fieldVal := val.Field(i)
323-
name, _, recursive, skip, err := parseTableStructTag(field)
361+
name, _, _, recursive, skip, err := parseTableStructTag(field)
324362
if err != nil {
325363
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
326364
}

cli/cliui/table_test.go

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,42 @@ Alice 25
218218
compareTables(t, expected, out)
219219
})
220220

221+
// This test ensures we can display dynamically typed slices
222+
t.Run("Interfaces", func(t *testing.T) {
223+
t.Parallel()
224+
225+
in := []any{tableTest1{}}
226+
out, err := cliui.DisplayTable(in, "", nil)
227+
t.Log("rendered table:\n" + out)
228+
require.NoError(t, err)
229+
other := []tableTest1{{}}
230+
expected, err := cliui.DisplayTable(other, "", nil)
231+
require.NoError(t, err)
232+
compareTables(t, expected, out)
233+
})
234+
235+
t.Run("WithSeparator", func(t *testing.T) {
236+
t.Parallel()
237+
expected := `
238+
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
239+
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
240+
-------------------------------------------------------------------------------------------------------------------------------------------------------------
241+
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
242+
-------------------------------------------------------------------------------------------------------------------------------------------------------------
243+
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
244+
`
245+
246+
var inlineIn []any
247+
for _, v := range in {
248+
inlineIn = append(inlineIn, v)
249+
inlineIn = append(inlineIn, cliui.TableSeparator{})
250+
}
251+
out, err := cliui.DisplayTable(inlineIn, "", nil)
252+
t.Log("rendered table:\n" + out)
253+
require.NoError(t, err)
254+
compareTables(t, expected, out)
255+
})
256+
221257
// This test ensures that safeties against invalid use of `table` tags
222258
// causes errors (even without data).
223259
t.Run("Errors", func(t *testing.T) {
@@ -255,14 +291,6 @@ Alice 25
255291
_, err := cliui.DisplayTable(in, "", nil)
256292
require.Error(t, err)
257293
})
258-
259-
t.Run("WithData", func(t *testing.T) {
260-
t.Parallel()
261-
262-
in := []any{tableTest1{}}
263-
_, err := cliui.DisplayTable(in, "", nil)
264-
require.Error(t, err)
265-
})
266294
})
267295

268296
t.Run("NotStruct", func(t *testing.T) {

cli/speedtest.go

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"os"
77
"time"
88

9-
"github.com/jedib0t/go-pretty/v6/table"
109
"golang.org/x/xerrors"
1110
tsspeedtest "tailscale.com/net/speedtest"
1211
"tailscale.com/wgengine/capture"
@@ -19,12 +18,51 @@ import (
1918
"github.com/coder/serpent"
2019
)
2120

21+
type SpeedtestResult struct {
22+
Overall SpeedtestResultInterval `json:"overall"`
23+
Intervals []SpeedtestResultInterval `json:"intervals"`
24+
}
25+
26+
type SpeedtestResultInterval struct {
27+
StartTimeSeconds float64 `json:"start_time_seconds"`
28+
EndTimeSeconds float64 `json:"end_time_seconds"`
29+
ThroughputMbits float64 `json:"throughput_mbits"`
30+
}
31+
32+
type speedtestTableItem struct {
33+
Interval string `table:"Interval,nosort"`
34+
Throughput string `table:"Throughput"`
35+
}
36+
2237
func (r *RootCmd) speedtest() *serpent.Command {
2338
var (
2439
direct bool
2540
duration time.Duration
2641
direction string
2742
pcapFile string
43+
formatter = cliui.NewOutputFormatter(
44+
cliui.ChangeFormatterData(cliui.TableFormat([]speedtestTableItem{}, []string{"Interval", "Throughput"}), func(data any) (any, error) {
45+
res, ok := data.(SpeedtestResult)
46+
if !ok {
47+
// This should never happen
48+
return "", xerrors.Errorf("expected speedtestResult, got %T", data)
49+
}
50+
tableRows := make([]any, len(res.Intervals)+2)
51+
for i, r := range res.Intervals {
52+
tableRows[i] = speedtestTableItem{
53+
Interval: fmt.Sprintf("%.2f-%.2f sec", r.StartTimeSeconds, r.EndTimeSeconds),
54+
Throughput: fmt.Sprintf("%.4f Mbits/sec", r.ThroughputMbits),
55+
}
56+
}
57+
tableRows[len(res.Intervals)] = cliui.TableSeparator{}
58+
tableRows[len(res.Intervals)+1] = speedtestTableItem{
59+
Interval: fmt.Sprintf("%.2f-%.2f sec", res.Overall.StartTimeSeconds, res.Overall.EndTimeSeconds),
60+
Throughput: fmt.Sprintf("%.4f Mbits/sec", res.Overall.ThroughputMbits),
61+
}
62+
return tableRows, nil
63+
}),
64+
cliui.JSONFormat(),
65+
)
2866
)
2967
client := new(codersdk.Client)
3068
cmd := &serpent.Command{
@@ -124,24 +162,32 @@ func (r *RootCmd) speedtest() *serpent.Command {
124162
default:
125163
return xerrors.Errorf("invalid direction: %q", direction)
126164
}
127-
cliui.Infof(inv.Stdout, "Starting a %ds %s test...", int(duration.Seconds()), tsDir)
165+
cliui.Infof(inv.Stderr, "Starting a %ds %s test...", int(duration.Seconds()), tsDir)
128166
results, err := conn.Speedtest(ctx, tsDir, duration)
129167
if err != nil {
130168
return err
131169
}
132-
tableWriter := cliui.Table()
133-
tableWriter.AppendHeader(table.Row{"Interval", "Throughput"})
170+
var outputResult SpeedtestResult
134171
startTime := results[0].IntervalStart
135-
for _, r := range results {
172+
outputResult.Intervals = make([]SpeedtestResultInterval, len(results)-1)
173+
for i, r := range results {
174+
interval := SpeedtestResultInterval{
175+
StartTimeSeconds: r.IntervalStart.Sub(startTime).Seconds(),
176+
EndTimeSeconds: r.IntervalEnd.Sub(startTime).Seconds(),
177+
ThroughputMbits: r.MBitsPerSecond(),
178+
}
136179
if r.Total {
137-
tableWriter.AppendSeparator()
180+
interval.StartTimeSeconds = 0
181+
outputResult.Overall = interval
182+
} else {
183+
outputResult.Intervals[i] = interval
138184
}
139-
tableWriter.AppendRow(table.Row{
140-
fmt.Sprintf("%.2f-%.2f sec", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds()),
141-
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
142-
})
143185
}
144-
_, err = fmt.Fprintln(inv.Stdout, tableWriter.Render())
186+
out, err := formatter.Format(inv.Context(), outputResult)
187+
if err != nil {
188+
return err
189+
}
190+
_, err = fmt.Fprintln(inv.Stdout, out)
145191
return err
146192
},
147193
}
@@ -173,5 +219,6 @@ func (r *RootCmd) speedtest() *serpent.Command {
173219
Value: serpent.StringOf(&pcapFile),
174220
},
175221
}
222+
formatter.AttachOptions(&cmd.Options)
176223
return cmd
177224
}

0 commit comments

Comments
 (0)