From 801a23d125037b390d876b7ff67c533ef70ae1a4 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Tue, 16 Jun 2026 13:28:01 -0400 Subject: [PATCH 1/3] feat(timeseries): add support for account and workspace level time series commands --- pkg/commands/commands.go | 10 ++ pkg/commands/ngwaf/timeseries/doc.go | 2 + pkg/commands/ngwaf/timeseries/list.go | 92 +++++++++++ pkg/commands/ngwaf/timeseries/root.go | 36 +++++ .../ngwaf/timeseries/timeseries_test.go | 150 ++++++++++++++++++ .../ngwaf/workspace/timeseries/doc.go | 2 + .../ngwaf/workspace/timeseries/get.go | 98 ++++++++++++ .../ngwaf/workspace/timeseries/root.go | 29 ++++ .../workspace/timeseries/timeseries_test.go | 97 +++++++++++ pkg/text/timeseries.go | 87 ++++++++++ 10 files changed, 603 insertions(+) create mode 100644 pkg/commands/ngwaf/timeseries/doc.go create mode 100644 pkg/commands/ngwaf/timeseries/list.go create mode 100644 pkg/commands/ngwaf/timeseries/root.go create mode 100644 pkg/commands/ngwaf/timeseries/timeseries_test.go create mode 100644 pkg/commands/ngwaf/workspace/timeseries/doc.go create mode 100644 pkg/commands/ngwaf/workspace/timeseries/get.go create mode 100644 pkg/commands/ngwaf/workspace/timeseries/root.go create mode 100644 pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go create mode 100644 pkg/text/timeseries.go diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index 1caf7f22e..4aa0e0133 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -80,6 +80,7 @@ import ( "github.com/fastly/cli/pkg/commands/ngwaf/rule" "github.com/fastly/cli/pkg/commands/ngwaf/signallist" "github.com/fastly/cli/pkg/commands/ngwaf/stringlist" + "github.com/fastly/cli/pkg/commands/ngwaf/timeseries" "github.com/fastly/cli/pkg/commands/ngwaf/wildcardlist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert" @@ -93,6 +94,7 @@ import ( workspaceAlertWebhook "github.com/fastly/cli/pkg/commands/ngwaf/workspace/alert/webhook" wscountrylist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/countrylist" wscustomsignal "github.com/fastly/cli/pkg/commands/ngwaf/workspace/customsignal" + wstimeseries "github.com/fastly/cli/pkg/commands/ngwaf/workspace/timeseries" wsiplist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/iplist" "github.com/fastly/cli/pkg/commands/ngwaf/workspace/redaction" workspaceRule "github.com/fastly/cli/pkg/commands/ngwaf/workspace/rule" @@ -367,6 +369,8 @@ func Define( // nolint:revive // function-length ngwafStringListGet := stringlist.NewGetCommand(ngwafStringListRoot.CmdClause, data) ngwafStringListList := stringlist.NewListCommand(ngwafStringListRoot.CmdClause, data) ngwafStringListUpdate := stringlist.NewUpdateCommand(ngwafStringListRoot.CmdClause, data) + ngwafTimeseriesRoot := timeseries.NewRootCommand(ngwafRoot.CmdClause, data) + ngwafTimeseriesList := timeseries.NewListCommand(ngwafTimeseriesRoot.CmdClause, data) ngwafWildcardListRoot := wildcardlist.NewRootCommand(ngwafRoot.CmdClause, data) ngwafWildcardListCreate := wildcardlist.NewCreateCommand(ngwafWildcardListRoot.CmdClause, data) ngwafWildcardListDelete := wildcardlist.NewDeleteCommand(ngwafWildcardListRoot.CmdClause, data) @@ -379,6 +383,8 @@ func Define( // nolint:revive // function-length ngwafWorkspaceCountryListGet := wscountrylist.NewGetCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) ngwafWorkspaceCountryListList := wscountrylist.NewListCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) ngwafWorkspaceCountryListUpdate := wscountrylist.NewUpdateCommand(ngwafWorkspaceCountryListRoot.CmdClause, data) + ngwafWorkspaceTimeseriesRoot := wstimeseries.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) + ngwafWorkspaceTimeseriesGet := wstimeseries.NewGetCommand(ngwafWorkspaceTimeseriesRoot.CmdClause, data) ngwafWorkspaceCustomSignalRoot := wscustomsignal.NewRootCommand(ngwafWorkspaceRoot.CmdClause, data) ngwafWorkspaceCustomSignalCreate := wscustomsignal.NewCreateCommand(ngwafWorkspaceCustomSignalRoot.CmdClause, data) ngwafWorkspaceCustomSignalDelete := wscustomsignal.NewDeleteCommand(ngwafWorkspaceCustomSignalRoot.CmdClause, data) @@ -1410,6 +1416,8 @@ func Define( // nolint:revive // function-length ngwafStringListGet, ngwafStringListList, ngwafStringListUpdate, + ngwafTimeseriesRoot, + ngwafTimeseriesList, ngwafWildcardListCreate, ngwafWildcardListDelete, ngwafWildcardListGet, @@ -1421,6 +1429,8 @@ func Define( // nolint:revive // function-length ngwafWorkspaceCountryListGet, ngwafWorkspaceCountryListList, ngwafWorkspaceCountryListUpdate, + ngwafWorkspaceTimeseriesRoot, + ngwafWorkspaceTimeseriesGet, ngwafWorkspaceCustomSignalRoot, ngwafWorkspaceCustomSignalCreate, ngwafWorkspaceCustomSignalDelete, diff --git a/pkg/commands/ngwaf/timeseries/doc.go b/pkg/commands/ngwaf/timeseries/doc.go new file mode 100644 index 000000000..81b31db65 --- /dev/null +++ b/pkg/commands/ngwaf/timeseries/doc.go @@ -0,0 +1,2 @@ +// Package timeseries contains commands to list account level time series metrics. +package timeseries diff --git a/pkg/commands/ngwaf/timeseries/list.go b/pkg/commands/ngwaf/timeseries/list.go new file mode 100644 index 000000000..fa29d7ba2 --- /dev/null +++ b/pkg/commands/ngwaf/timeseries/list.go @@ -0,0 +1,92 @@ +package timeseries + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v15/fastly" + ts "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/timeseries" +) + +// ListCommand calls the Fastly API to list an account-level time series metrics. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + from string + metrics string + + // Optional. + dimensions argparser.OptionalString + granularity argparser.OptionalInt + to argparser.OptionalString +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List account-level time series metrics") + + // Required. + c.CmdClause.Flag("from", "The start of a date-time range, expressed in RFC 3339 format").Required().StringVar(&c.from) + c.CmdClause.Flag("metrics", "Comma-separated list of metrics to be included in the timeseries. Metrics can be XSS, SQLI, HTTP404, requests_total, requests_attack, requests_total_blocked, or any custom metric").Required().StringVar(&c.metrics) + + // Optional. + c.CmdClause.Flag("dimensions", "Comma separated list of grouping dimensions to be included in the timeseries. Allowed values are workspaces and time. (Default value is time)").Action(c.dimensions.Set).StringVar(&c.dimensions.Value) + c.CmdClause.Flag("granularity", "Level of detail of the sample size in seconds. (Default value is 86400)").Action(c.granularity.Set).IntVar(&c.granularity.Value) + c.CmdClause.Flag("to", "The end of a date-time range, expressed in RFC 3339 format").Action(c.to.Set).StringVar(&c.to.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := ts.ListInput{ + From: &c.from, + Metrics: &c.metrics, + } + + if c.dimensions.WasSet { + input.Dimensions = &c.dimensions.Value + } + if c.granularity.WasSet { + input.Granularity = &c.granularity.Value + } + if c.to.WasSet { + input.To = &c.to.Value + } + + result, err := ts.List(context.TODO(), fc, &input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, result); ok { + return err + } + + text.PrintTimeseries(out, result) + return nil + +} diff --git a/pkg/commands/ngwaf/timeseries/root.go b/pkg/commands/ngwaf/timeseries/root.go new file mode 100644 index 000000000..067c32ee1 --- /dev/null +++ b/pkg/commands/ngwaf/timeseries/root.go @@ -0,0 +1,36 @@ +package timeseries + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/scope" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "time-series" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage NGWAF") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} + +var ScopeTypes = []string{string(scope.ScopeTypeAccount), string(scope.ScopeTypeWorkspace)} + +var DefaultAccountScope = []string{"*"} diff --git a/pkg/commands/ngwaf/timeseries/timeseries_test.go b/pkg/commands/ngwaf/timeseries/timeseries_test.go new file mode 100644 index 000000000..74de1a16f --- /dev/null +++ b/pkg/commands/ngwaf/timeseries/timeseries_test.go @@ -0,0 +1,150 @@ +package timeseries_test + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + sub "github.com/fastly/cli/pkg/commands/ngwaf/timeseries" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + gots "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/timeseries" +) + +var timeseriesResponse = gots.Timeseries{ + Data: []gots.DataPoint{ + { + Dimensions: gots.Dimensions{Time: "2026-06-15T11:00:00Z"}, + Values: []map[string]any{{"XSS": float64(0), "SQLI": float64(0)}}, + }, + { + Dimensions: gots.Dimensions{Time: "2026-06-15T12:00:00Z"}, + Values: []map[string]any{{"XSS": float64(1), "SQLI": float64(0)}}, + }, + }, + Meta: gots.MetaTimeseries{Total: 2}, +} + +func TestTimeseriesList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --from flag", + Args: "--metrics XSS", + WantError: "error parsing arguments: required flag --from not provided", + }, + { + Name: "validate missing --metrics flag", + Args: "--from 2026-06-15T11:00:00Z", + WantError: "error parsing arguments: required flag --metrics not provided", + }, + { + Name: "validate bad --from value", + Args: "--from not-a-valid-date --metrics XSS", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "The request is not valid ('from' is required and must be a valid RFC3339 date).", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate internal server error", + Args: "--from 2026-06-15T11:00:00Z --metrics XSS", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success", + Args: "--from 2026-06-15T11:00:00Z --metrics XSS,SQLI", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(timeseriesResponse))), + }, + }, + }, + WantOutput: listTimeseriesString, + }, + { + Name: "validate optional --json flag", + Args: "--from 2026-06-15T11:00:00Z --metrics XSS,SQLI --json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(timeseriesResponse))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(timeseriesResponse), + }, + { + Name: "validate --verbose and --json are mutually exclusive", + Args: "--from 2026-06-15T11:00:00Z --metrics XSS --verbose --json", + WantError: "invalid flag combination, --verbose and --json", + }, + { + Name: "validate API success with zero results", + Args: "--from 2026-06-15T11:00:00Z --metrics XSS", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(gots.Timeseries{ + Data: []gots.DataPoint{}, + Meta: gots.MetaTimeseries{Total: 0}, + }))), + }, + }, + }, + WantOutput: "Total: 0\n", + }, + { + Name: "validate optional flags --to --granularity --dimensions", + Args: "--from 2026-06-15T11:00:00Z --metrics XSS,SQLI --to 2026-06-15T12:00:00Z --granularity 60 --dimensions workspaces,time", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(timeseriesResponse))), + }, + }, + }, + WantOutput: listTimeseriesString, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +var listTimeseriesString = strings.TrimSpace(` +Time Workspace SQLI XSS +2026-06-15T11:00:00Z 0 0 +2026-06-15T12:00:00Z 0 1 + +Total: 2 +`) + "\n" diff --git a/pkg/commands/ngwaf/workspace/timeseries/doc.go b/pkg/commands/ngwaf/workspace/timeseries/doc.go new file mode 100644 index 000000000..1be43e1d9 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/timeseries/doc.go @@ -0,0 +1,2 @@ +// Package timeseries contains commands to retrieve NGWAF workspace-level time series metrics. +package timeseries diff --git a/pkg/commands/ngwaf/workspace/timeseries/get.go b/pkg/commands/ngwaf/workspace/timeseries/get.go new file mode 100644 index 000000000..b11b1f36c --- /dev/null +++ b/pkg/commands/ngwaf/workspace/timeseries/get.go @@ -0,0 +1,98 @@ +package timeseries + +import ( + "context" + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v15/fastly" + gots "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/timeseries" +) + +// GetCommand calls the Fastly API to get workspace-level time series metrics. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + from string + metrics string + workspaceID argparser.OptionalWorkspaceID + + // Optional. + to argparser.OptionalString + granularity argparser.OptionalInt +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("get", "Get workspace-level time series metrics") + + // Required. + c.CmdClause.Flag("from", "The start of a date-time range, expressed in RFC 3339 format").Required().StringVar(&c.from) + c.CmdClause.Flag("metrics", "Comma-separated list of metrics to be included in the timeseries. Metrics can be XSS, SQLI, HTTP404, requests_total, requests_attack, requests_total_blocked, or any custom metric").Required().StringVar(&c.metrics) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagNGWAFWorkspaceID, + Description: argparser.FlagNGWAFWorkspaceIDDesc, + Dst: &c.workspaceID.Value, + Action: c.workspaceID.Set, + }) + + // Optional. + c.CmdClause.Flag("to", "The end of a date-time range, expressed in RFC 3339 format").Action(c.to.Set).StringVar(&c.to.Value) + c.CmdClause.Flag("granularity", "Level of detail of the sample size in seconds (Default value is 86400)").Action(c.granularity.Set).IntVar(&c.granularity.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if err := c.workspaceID.Parse(); err != nil { + return err + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := gots.GetInput{ + Start: &c.from, + Metrics: &c.metrics, + WorkspaceID: &c.workspaceID.Value, + } + + if c.to.WasSet { + input.End = &c.to.Value + } + if c.granularity.WasSet { + input.Granularity = &c.granularity.Value + } + + result, err := gots.Get(context.TODO(), fc, &input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, result); ok { + return err + } + + text.PrintWorkspaceTimeseries(out, result) + return nil +} diff --git a/pkg/commands/ngwaf/workspace/timeseries/root.go b/pkg/commands/ngwaf/workspace/timeseries/root.go new file mode 100644 index 000000000..827b7858e --- /dev/null +++ b/pkg/commands/ngwaf/workspace/timeseries/root.go @@ -0,0 +1,29 @@ +package timeseries + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +type RootCommand struct { + argparser.Base +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "time-series" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage workspace time series metrics") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go b/pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go new file mode 100644 index 000000000..de4812b86 --- /dev/null +++ b/pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go @@ -0,0 +1,97 @@ +package timeseries_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/ngwaf" + workspace "github.com/fastly/cli/pkg/commands/ngwaf/workspace" + sub "github.com/fastly/cli/pkg/commands/ngwaf/workspace/timeseries" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + gots "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/timeseries" +) + +const ( + workspaceID = "workspaceID" + from = "2024-01-01T00:00:00Z" + metrics = "requests_total" +) + +var timeseriesResponse = gots.TimeSeries{ + Data: []map[string]any{}, + Meta: gots.MetaTimeSeries{ + Total: 0, + }, +} + +func TestWorkspaceTimeseriesGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --from flag", + Args: fmt.Sprintf("--metrics %s --workspace-id %s", metrics, workspaceID), + WantError: "error parsing arguments: required flag --from not provided", + }, + { + Name: "validate missing --metrics flag", + Args: fmt.Sprintf("--from %s --workspace-id %s", from, workspaceID), + WantError: "error parsing arguments: required flag --metrics not provided", + }, + { + Name: "validate missing --workspace-id flag", + Args: fmt.Sprintf("--from %s --metrics %s", from, metrics), + WantError: "error reading workspace ID: no workspace ID found", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--from %s --metrics %s --workspace-id %s", from, metrics, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero results)", + Args: fmt.Sprintf("--from %s --metrics %s --workspace-id %s", from, metrics, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(timeseriesResponse))), + }, + }, + }, + WantOutput: zeroTimeseriesString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--from %s --metrics %s --workspace-id %s --json", from, metrics, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(timeseriesResponse))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(timeseriesResponse), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "get"}, scenarios) +} + +var zeroTimeseriesString = strings.TrimSpace(` +Total: 0 +`) + "\n" diff --git a/pkg/text/timeseries.go b/pkg/text/timeseries.go new file mode 100644 index 000000000..2c2f5f095 --- /dev/null +++ b/pkg/text/timeseries.go @@ -0,0 +1,87 @@ +package text + +import ( + "fmt" + "io" + "sort" + + "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/timeseries" + wsts "github.com/fastly/go-fastly/v15/fastly/ngwaf/v1/workspaces/timeseries" +) + +// PrintTimeseries displays timeseries data points in a table. +func PrintTimeseries(out io.Writer, t *timeseries.Timeseries) { + if len(t.Data) == 0 { + fmt.Fprintf(out, "Total: 0\n") + return + } + + // Collect sorted metric keys from the first data point. This allows us to dynamically adjust the data column label. + var metricKeys []string + if len(t.Data[0].Values) > 0 { + for k := range t.Data[0].Values[0] { + metricKeys = append(metricKeys, k) + } + sort.Strings(metricKeys) + } + + headers := append([]string{"Time", "Workspace"}, metricKeys...) + tbl := NewTable(out) + tbl.AddHeader(toAny(headers)...) + + for _, dp := range t.Data { + row := []any{dp.Dimensions.Time, dp.Dimensions.Workspace} + if len(dp.Values) > 0 { + for _, k := range metricKeys { + row = append(row, dp.Values[0][k]) + } + } + tbl.AddLine(row...) + } + tbl.Print() + + fmt.Fprintf(out, "\nTotal: %d\n", t.Meta.Total) +} + +func toAny(ss []string) []any { + out := make([]any, len(ss)) + for i, s := range ss { + out[i] = s + } + return out +} + +// PrintWorkspaceTimeseries displays workspace timeseries data points in a table. +func PrintWorkspaceTimeseries(out io.Writer, t *wsts.TimeSeries) { + if t.Meta.Total == 0 { + fmt.Fprintf(out, "Total: 0\n") + return + } + + var metricKeys []string + seen := map[string]bool{} + for _, dp := range t.Data { + for k := range dp { + if k != "timestamp" && !seen[k] { + seen[k] = true + metricKeys = append(metricKeys, k) + } + } + } + sort.Strings(metricKeys) + + headers := append([]string{"Timestamp"}, metricKeys...) + tbl := NewTable(out) + tbl.AddHeader(toAny(headers)...) + + for _, dp := range t.Data { + row := []any{dp["timestamp"]} + for _, k := range metricKeys { + row = append(row, dp[k]) + } + tbl.AddLine(row...) + } + tbl.Print() + + fmt.Fprintf(out, "\nTotal: %d\n", t.Meta.Total) +} From 0f67dc3aff2db3f518b8f6be03bf588bea95285f Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Mon, 22 Jun 2026 09:57:18 -0400 Subject: [PATCH 2/3] update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed78195e5..1aef0cf7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Bug Fixes: ### Enhancements: +- feat(ngwaf/timeseries): add support for account and workspace times series commands ([#1823](https://github.com/fastly/cli/pull/1823)) - feat(kvstoreentry/delete): Add support for multiple-key deletion using a key prefix. ([#XXX](https://github.com/fastly/cli/pull/XXX)) @@ -18,7 +19,7 @@ ### Bug Fixes: -- fix(docs): corrected stale and missing API reference links in usage.json metadata([#1803](https://github.com/fastly/cli/pull/1803)) +- fix(docs): corrected stale and missing API reference links in usage.json metadata ([#1803](https://github.com/fastly/cli/pull/1803)) - fix(compute): `serve --watch` no longer rebuilds on attribute-only (Chmod) filesystem events, preventing an endless rebuild loop when another process changes a watched file's metadata such as its access time ([#1808](https://github.com/fastly/cli/pull/1808)) - fix(docs): expand and correct API reference links for `fastly service` subcommands in usage.json metadata ([#1810](https://github.com/fastly/cli/pull/1810)) From ded7d2d7ac7ee7cc6622f2cc2172f5a6fcdbffb5 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Mon, 22 Jun 2026 14:55:53 -0400 Subject: [PATCH 3/3] (ngwaf/workspace/timeseries): updated test to include resp shape --- .../workspace/timeseries/timeseries_test.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go b/pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go index de4812b86..c49ee945e 100644 --- a/pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go +++ b/pkg/commands/ngwaf/workspace/timeseries/timeseries_test.go @@ -29,6 +29,16 @@ var timeseriesResponse = gots.TimeSeries{ }, } +var timeseriesResponseSample = gots.TimeSeries{ + Data: []map[string]any{ + {"timestamp": "2026-06-22T18:34:00Z", "HTTP404": float64(1), "requests_total": float64(1)}, + {"timestamp": "2026-06-22T18:35:00Z", "HTTP404": float64(0), "requests_total": float64(3)}, + }, + Meta: gots.MetaTimeSeries{ + Total: 2, + }, +} + func TestWorkspaceTimeseriesGet(t *testing.T) { scenarios := []testutil.CLIScenario{ { @@ -87,6 +97,34 @@ func TestWorkspaceTimeseriesGet(t *testing.T) { }, WantOutput: fstfmt.EncodeJSON(timeseriesResponse), }, + { + Name: "validate API success (sample results)", + Args: fmt.Sprintf("--from %s --metrics %s --workspace-id %s", from, metrics, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(timeseriesResponseSample))), + }, + }, + }, + WantOutput: sampleTimeseriesString, + }, + { + Name: "validate optional --json flag (sample results)", + Args: fmt.Sprintf("--from %s --metrics %s --workspace-id %s --json", from, metrics, workspaceID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(timeseriesResponseSample))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(timeseriesResponseSample), + }, } testutil.RunCLIScenarios(t, []string{root.CommandName, workspace.CommandName, sub.CommandName, "get"}, scenarios) @@ -95,3 +133,11 @@ func TestWorkspaceTimeseriesGet(t *testing.T) { var zeroTimeseriesString = strings.TrimSpace(` Total: 0 `) + "\n" + +var sampleTimeseriesString = strings.TrimSpace(` +Timestamp HTTP404 requests_total +2026-06-22T18:34:00Z 1 1 +2026-06-22T18:35:00Z 0 3 + +Total: 2 +`) + "\n"