diff --git a/pkg/commands/stats/domain_inspector.go b/pkg/commands/stats/domain_inspector.go index 45492d324..80636715c 100644 --- a/pkg/commands/stats/domain_inspector.go +++ b/pkg/commands/stats/domain_inspector.go @@ -143,40 +143,9 @@ func (c *DomainInspectorCommand) Exec(_ io.Reader, out io.Writer) error { if fastly.ToValue(resp.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Status)) } - return writeDomainInspector(out, resp) - } -} - -func writeDomainInspector(out io.Writer, resp *fastly.DomainInspector) error { - if resp.Meta != nil { - if resp.Meta.Start != nil { - text.Output(out, "Start: %s", *resp.Meta.Start) - } - if resp.Meta.End != nil { - text.Output(out, "End: %s", *resp.Meta.End) - } - fmt.Fprintln(out, "---") - } - for _, d := range resp.Data { - if d.Dimensions != nil { - for k, v := range d.Dimensions { - text.Output(out, "%s: %s", k, fastly.ToValue(v)) - } - } - for _, v := range d.Values { - if v.Timestamp != nil { - text.Output(out, " Timestamp: %s", time.Unix(int64(*v.Timestamp), 0).UTC()) //nolint:gosec // timestamp won't overflow - } - text.Output(out, " Requests: %d", fastly.ToValue(v.Requests)) - text.Output(out, " Bandwidth: %d", fastly.ToValue(v.Bandwidth)) - text.Output(out, " Edge Requests: %d", fastly.ToValue(v.EdgeRequests)) - text.Output(out, " Edge Hit Ratio: %.4f", fastly.ToValue(v.EdgeHitRatio)) - } - } - if resp.Meta != nil && resp.Meta.NextCursor != nil { - text.Output(out, "Next cursor: %s", *resp.Meta.NextCursor) + text.PrintDomainInspectorTbl(out, resp) + return nil } - return nil } func parseTime(s string) (time.Time, error) { diff --git a/pkg/commands/stats/domain_inspector_test.go b/pkg/commands/stats/domain_inspector_test.go index 5a7e75cff..b738c2293 100644 --- a/pkg/commands/stats/domain_inspector_test.go +++ b/pkg/commands/stats/domain_inspector_test.go @@ -32,7 +32,7 @@ func TestDomainInspector(t *testing.T) { api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsOK, }, - wantOutput: "Requests:", + wantOutput: "REQUESTS", }, { name: "success json", @@ -78,7 +78,7 @@ func TestDomainInspector(t *testing.T) { api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsAssertStart(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)), }, - wantOutput: "Requests:", + wantOutput: "REQUESTS", }, { name: "from Unix epoch maps to Start", @@ -86,7 +86,7 @@ func TestDomainInspector(t *testing.T) { api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsAssertStart(time.Unix(1705312800, 0)), }, - wantOutput: "Requests:", + wantOutput: "REQUESTS", }, { name: "to RFC3339 maps to End", @@ -94,7 +94,7 @@ func TestDomainInspector(t *testing.T) { api: mock.API{ GetDomainMetricsForServiceFn: getDomainMetricsAssertEnd(time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)), }, - wantOutput: "Requests:", + wantOutput: "REQUESTS", }, { name: "from invalid format error", diff --git a/pkg/commands/stats/origin_inspector.go b/pkg/commands/stats/origin_inspector.go index 26350685f..66afe8a11 100644 --- a/pkg/commands/stats/origin_inspector.go +++ b/pkg/commands/stats/origin_inspector.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "time" "github.com/fastly/go-fastly/v13/fastly" @@ -142,38 +141,7 @@ func (c *OriginInspectorCommand) Exec(_ io.Reader, out io.Writer) error { if fastly.ToValue(resp.Status) != statusSuccess { return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Status)) } - return writeOriginInspector(out, resp) - } -} - -func writeOriginInspector(out io.Writer, resp *fastly.OriginInspector) error { - if resp.Meta != nil { - if resp.Meta.Start != nil { - text.Output(out, "Start: %s", *resp.Meta.Start) - } - if resp.Meta.End != nil { - text.Output(out, "End: %s", *resp.Meta.End) - } - fmt.Fprintln(out, "---") - } - for _, d := range resp.Data { - if d.Dimensions != nil { - for k, v := range d.Dimensions { - text.Output(out, "%s: %s", k, v) - } - } - for _, v := range d.Values { - if v.Timestamp != nil { - text.Output(out, " Timestamp: %s", time.Unix(int64(*v.Timestamp), 0).UTC()) //nolint:gosec // timestamp won't overflow - } - text.Output(out, " Responses: %d", fastly.ToValue(v.Responses)) - text.Output(out, " Status 2xx: %d", fastly.ToValue(v.Status2xx)) - text.Output(out, " Status 4xx: %d", fastly.ToValue(v.Status4xx)) - text.Output(out, " Status 5xx: %d", fastly.ToValue(v.Status5xx)) - } - } - if resp.Meta != nil && resp.Meta.NextCursor != nil { - text.Output(out, "Next cursor: %s", *resp.Meta.NextCursor) + text.PrintOriginInspectorTbl(out, resp) + return nil } - return nil } diff --git a/pkg/commands/stats/origin_inspector_test.go b/pkg/commands/stats/origin_inspector_test.go index ee60d4276..109af43f9 100644 --- a/pkg/commands/stats/origin_inspector_test.go +++ b/pkg/commands/stats/origin_inspector_test.go @@ -32,7 +32,7 @@ func TestOriginInspector(t *testing.T) { api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsOK, }, - wantOutput: "Responses:", + wantOutput: "RESPONSES", }, { name: "success json", @@ -78,7 +78,7 @@ func TestOriginInspector(t *testing.T) { api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsAssertStart(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)), }, - wantOutput: "Responses:", + wantOutput: "RESPONSES", }, { name: "from Unix epoch maps to Start", @@ -86,7 +86,7 @@ func TestOriginInspector(t *testing.T) { api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsAssertStart(time.Unix(1705312800, 0)), }, - wantOutput: "Responses:", + wantOutput: "RESPONSES", }, { name: "to RFC3339 maps to End", @@ -94,7 +94,7 @@ func TestOriginInspector(t *testing.T) { api: mock.API{ GetOriginMetricsForServiceFn: getOriginMetricsAssertEnd(time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)), }, - wantOutput: "Responses:", + wantOutput: "RESPONSES", }, { name: "from invalid format error", diff --git a/pkg/commands/stats/usage.go b/pkg/commands/stats/usage.go index 88f05cdff..4b4b7c81c 100644 --- a/pkg/commands/stats/usage.go +++ b/pkg/commands/stats/usage.go @@ -5,8 +5,6 @@ import ( "encoding/json" "fmt" "io" - "maps" - "slices" "github.com/fastly/go-fastly/v13/fastly" @@ -84,7 +82,8 @@ func (c *UsageCommand) execPlain(out io.Writer, input *fastly.GetUsageInput) err case "json": return writeUsageJSON(out, resp.Data) default: - return writeUsageTable(out, resp.Data) + text.PrintUsageTbl(out, resp.Data) + return nil } } @@ -105,26 +104,9 @@ func (c *UsageCommand) execByService(out io.Writer, input *fastly.GetUsageInput) case "json": return writeUsageByServiceJSON(out, resp.Data) default: - return writeUsageByServiceTable(out, resp.Data) - } -} - -func writeUsageTable(out io.Writer, data *fastly.RegionsUsage) error { - if data == nil { + text.PrintUsageByServiceTbl(out, resp.Data) return nil } - regions := slices.Sorted(maps.Keys(*data)) - for _, region := range regions { - usage := (*data)[region] - if usage == nil { - continue - } - text.Output(out, "Region: %s", region) - text.Output(out, " Bandwidth: %d", fastly.ToValue(usage.Bandwidth)) - text.Output(out, " Requests: %d", fastly.ToValue(usage.Requests)) - text.Output(out, " Compute Requests: %d", fastly.ToValue(usage.ComputeRequests)) - } - return nil } func writeUsageJSON(out io.Writer, data *fastly.RegionsUsage) error { @@ -134,32 +116,6 @@ func writeUsageJSON(out io.Writer, data *fastly.RegionsUsage) error { return json.NewEncoder(out).Encode(usageToMap(*data)) } -func writeUsageByServiceTable(out io.Writer, data *fastly.ServicesByRegionsUsage) error { - if data == nil { - return nil - } - regions := slices.Sorted(maps.Keys(*data)) - for _, region := range regions { - services := (*data)[region] - if services == nil { - continue - } - text.Output(out, "Region: %s", region) - serviceIDs := slices.Sorted(maps.Keys(*services)) - for _, svcID := range serviceIDs { - usage := (*services)[svcID] - if usage == nil { - continue - } - text.Output(out, " Service: %s", svcID) - text.Output(out, " Bandwidth: %d", fastly.ToValue(usage.Bandwidth)) - text.Output(out, " Requests: %d", fastly.ToValue(usage.Requests)) - text.Output(out, " Compute Requests: %d", fastly.ToValue(usage.ComputeRequests)) - } - } - return nil -} - func writeUsageByServiceJSON(out io.Writer, data *fastly.ServicesByRegionsUsage) error { if data == nil { return json.NewEncoder(out).Encode(map[string]any{}) diff --git a/pkg/commands/stats/usage_test.go b/pkg/commands/stats/usage_test.go index dcdbdc986..b2fc3aa56 100644 --- a/pkg/commands/stats/usage_test.go +++ b/pkg/commands/stats/usage_test.go @@ -29,7 +29,7 @@ func TestUsage(t *testing.T) { name: "success plain", args: args("stats usage"), api: mock.API{GetUsageFn: getUsageOK}, - wantOutput: "Region: usa", + wantOutput: "usa", }, { name: "success json", @@ -41,7 +41,7 @@ func TestUsage(t *testing.T) { name: "success by-service", args: args("stats usage --by-service"), api: mock.API{GetUsageByServiceFn: getUsageByServiceOK}, - wantOutput: "Service: svc123", + wantOutput: "svc123", }, { name: "success by-service json", @@ -59,20 +59,20 @@ func TestUsage(t *testing.T) { name: "nil usage entry table skipped", args: args("stats usage"), api: mock.API{GetUsageFn: getUsageWithNilEntry}, - wantOutput: "Region: europe", + wantOutput: "europe", }, { name: "region filter plain", args: args("stats usage --region=europe"), api: mock.API{GetUsageFn: getUsageMultiRegion}, - wantOutput: "Region: europe", + wantOutput: "europe", wantAbsent: "usa", }, { name: "region filter by-service", args: args("stats usage --by-service --region=europe"), api: mock.API{GetUsageByServiceFn: getUsageByServiceMultiRegion}, - wantOutput: "Region: europe", + wantOutput: "svc456", wantAbsent: "usa", }, { diff --git a/pkg/text/stats.go b/pkg/text/stats.go new file mode 100644 index 000000000..5b0759674 --- /dev/null +++ b/pkg/text/stats.go @@ -0,0 +1,154 @@ +package text + +import ( + "fmt" + "io" + "maps" + "slices" + "strings" + "time" + + "github.com/fastly/go-fastly/v13/fastly" +) + +func PrintUsageTbl(out io.Writer, data *fastly.RegionsUsage) { + tbl := NewTable(out) + tbl.AddHeader("REGION", "BANDWIDTH", "REQUESTS", "COMPUTE REQUESTS") + if data == nil { + tbl.Print() + return + } + for _, region := range slices.Sorted(maps.Keys(*data)) { + u := (*data)[region] + if u == nil { + continue + } + tbl.AddLine(region, fastly.ToValue(u.Bandwidth), fastly.ToValue(u.Requests), fastly.ToValue(u.ComputeRequests)) + } + tbl.Print() +} + +func PrintUsageByServiceTbl(out io.Writer, data *fastly.ServicesByRegionsUsage) { + tbl := NewTable(out) + tbl.AddHeader("REGION", "SERVICE", "BANDWIDTH", "REQUESTS", "COMPUTE REQUESTS") + if data == nil { + tbl.Print() + return + } + for _, region := range slices.Sorted(maps.Keys(*data)) { + services := (*data)[region] + if services == nil { + continue + } + for _, svcID := range slices.Sorted(maps.Keys(*services)) { + u := (*services)[svcID] + if u == nil { + continue + } + tbl.AddLine(region, svcID, fastly.ToValue(u.Bandwidth), fastly.ToValue(u.Requests), fastly.ToValue(u.ComputeRequests)) + } + } + tbl.Print() +} + +func PrintDomainInspectorTbl(out io.Writer, resp *fastly.DomainInspector) { + if resp.Meta != nil { + if resp.Meta.Start != nil { + fmt.Fprintf(out, "Start: %s\n", *resp.Meta.Start) + } + if resp.Meta.End != nil { + fmt.Fprintf(out, "End: %s\n", *resp.Meta.End) + } + fmt.Fprintln(out, "---") + } + + dimKeys := domainDimensionKeys(resp.Data) + header := make([]any, 0, len(dimKeys)+5) + for _, k := range dimKeys { + header = append(header, strings.ToUpper(k)) + } + header = append(header, "TIMESTAMP", "REQUESTS", "BANDWIDTH", "EDGE REQUESTS", "EDGE HIT RATIO") + + tbl := NewTable(out) + tbl.AddHeader(header...) + for _, d := range resp.Data { + for _, v := range d.Values { + row := make([]any, 0, len(dimKeys)+5) + for _, k := range dimKeys { + row = append(row, fastly.ToValue(d.Dimensions[k])) + } + ts := "" + if v.Timestamp != nil { + ts = time.Unix(int64(*v.Timestamp), 0).UTC().String() //nolint:gosec + } + row = append(row, ts, fastly.ToValue(v.Requests), fastly.ToValue(v.Bandwidth), fastly.ToValue(v.EdgeRequests), fmt.Sprintf("%.4f", fastly.ToValue(v.EdgeHitRatio))) + tbl.AddLine(row...) + } + } + tbl.Print() + + if resp.Meta != nil && resp.Meta.NextCursor != nil { + fmt.Fprintf(out, "Next cursor: %s\n", *resp.Meta.NextCursor) + } +} + +func PrintOriginInspectorTbl(out io.Writer, resp *fastly.OriginInspector) { + if resp.Meta != nil { + if resp.Meta.Start != nil { + fmt.Fprintf(out, "Start: %s\n", *resp.Meta.Start) + } + if resp.Meta.End != nil { + fmt.Fprintf(out, "End: %s\n", *resp.Meta.End) + } + fmt.Fprintln(out, "---") + } + + dimKeys := originDimensionKeys(resp.Data) + header := make([]any, 0, len(dimKeys)+5) + for _, k := range dimKeys { + header = append(header, strings.ToUpper(k)) + } + header = append(header, "TIMESTAMP", "RESPONSES", "STATUS 2XX", "STATUS 4XX", "STATUS 5XX") + + tbl := NewTable(out) + tbl.AddHeader(header...) + for _, d := range resp.Data { + for _, v := range d.Values { + row := make([]any, 0, len(dimKeys)+5) + for _, k := range dimKeys { + row = append(row, d.Dimensions[k]) + } + ts := "" + if v.Timestamp != nil { + ts = time.Unix(int64(*v.Timestamp), 0).UTC().String() //nolint:gosec + } + row = append(row, ts, fastly.ToValue(v.Responses), fastly.ToValue(v.Status2xx), fastly.ToValue(v.Status4xx), fastly.ToValue(v.Status5xx)) + tbl.AddLine(row...) + } + } + tbl.Print() + + if resp.Meta != nil && resp.Meta.NextCursor != nil { + fmt.Fprintf(out, "Next cursor: %s\n", *resp.Meta.NextCursor) + } +} + +func domainDimensionKeys(data []*fastly.DomainData) []string { + seen := make(map[string]struct{}) + for _, d := range data { + for k := range d.Dimensions { + seen[k] = struct{}{} + } + } + return slices.Sorted(maps.Keys(seen)) +} + +func originDimensionKeys(data []*fastly.OriginData) []string { + seen := make(map[string]struct{}) + for _, d := range data { + for k := range d.Dimensions { + seen[k] = struct{}{} + } + } + return slices.Sorted(maps.Keys(seen)) +}