Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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))

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 @@ -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"
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
92 changes: 92 additions & 0 deletions pkg/commands/ngwaf/timeseries/list.go
Original file line number Diff line number Diff line change
@@ -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

}
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/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{"*"}
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/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"
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