Skip to content
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- fix(auth): accept SSO JWT audiences with or without a trailing slash when validating the Fastly API endpoint. ([#1837](https://github.com/fastly/cli/pull/1837))

### Enhancements:
- feat(ngwaf/timeseries): add support for account and workspace times series commands ([#1823](https://github.com/fastly/cli/pull/1823))

### Dependencies:

Expand All @@ -32,7 +33,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))

Expand Down
10 changes: 10 additions & 0 deletions pkg/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -99,6 +100,7 @@ import (
wssignallistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/signallist"
wsstringlistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/stringlist"
"github.com/fastly/cli/pkg/commands/ngwaf/workspace/threshold"
wstimeseries "github.com/fastly/cli/pkg/commands/ngwaf/workspace/timeseries"
"github.com/fastly/cli/pkg/commands/ngwaf/workspace/virtualpatch"
wswildcardlistlist "github.com/fastly/cli/pkg/commands/ngwaf/workspace/wildcardlist"
"github.com/fastly/cli/pkg/commands/objectstorage"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -1410,6 +1416,8 @@ func Define( // nolint:revive // function-length
ngwafStringListGet,
ngwafStringListList,
ngwafStringListUpdate,
ngwafTimeseriesRoot,
ngwafTimeseriesList,
ngwafWildcardListCreate,
ngwafWildcardListDelete,
ngwafWildcardListGet,
Expand All @@ -1421,6 +1429,8 @@ func Define( // nolint:revive // function-length
ngwafWorkspaceCountryListGet,
ngwafWorkspaceCountryListList,
ngwafWorkspaceCountryListUpdate,
ngwafWorkspaceTimeseriesRoot,
ngwafWorkspaceTimeseriesGet,
ngwafWorkspaceCustomSignalRoot,
ngwafWorkspaceCustomSignalCreate,
ngwafWorkspaceCustomSignalDelete,
Expand Down
2 changes: 2 additions & 0 deletions pkg/commands/ngwaf/timeseries/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package timeseries contains commands to list account level time series metrics.
package timeseries
91 changes: 91 additions & 0 deletions pkg/commands/ngwaf/timeseries/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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/v16/fastly"
ts "github.com/fastly/go-fastly/v16/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
}
36 changes: 36 additions & 0 deletions pkg/commands/ngwaf/timeseries/root.go
Original file line number Diff line number Diff line change
@@ -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/v16/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{"*"}
150 changes: 150 additions & 0 deletions pkg/commands/ngwaf/timeseries/timeseries_test.go
Original file line number Diff line number Diff line change
@@ -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/v16/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"
2 changes: 2 additions & 0 deletions pkg/commands/ngwaf/workspace/timeseries/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package timeseries contains commands to retrieve NGWAF workspace-level time series metrics.
package timeseries
Loading
Loading