diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 93998b511..67fb76f82 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -406,6 +406,12 @@ type Interface interface { DeleteAlertDefinition(i *fastly.DeleteAlertDefinitionInput) error TestAlertDefinition(i *fastly.TestAlertDefinitionInput) error ListAlertHistory(i *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) + + ListObservabilityCustomDashboards(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) + CreateObservabilityCustomDashboard(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + GetObservabilityCustomDashboard(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + UpdateObservabilityCustomDashboard(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + DeleteObservabilityCustomDashboard(i *fastly.DeleteObservabilityCustomDashboardInput) error } // RealtimeStatsInterface is the subset of go-fastly's realtime stats API used here. diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index e5c83d8cd..c9d3a53cc 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -68,6 +68,7 @@ compute config config-store config-store-entry +dashboard dictionary dictionary-entry domain diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index a9079d7aa..8c3ab83ec 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -13,6 +13,8 @@ import ( "github.com/fastly/cli/pkg/commands/config" "github.com/fastly/cli/pkg/commands/configstore" "github.com/fastly/cli/pkg/commands/configstoreentry" + "github.com/fastly/cli/pkg/commands/dashboard" + dashboardItem "github.com/fastly/cli/pkg/commands/dashboard/item" "github.com/fastly/cli/pkg/commands/dictionary" "github.com/fastly/cli/pkg/commands/dictionaryentry" "github.com/fastly/cli/pkg/commands/domain" @@ -154,6 +156,17 @@ func Define( configstoreentryDescribe := configstoreentry.NewDescribeCommand(configstoreentryCmdRoot.CmdClause, data) configstoreentryList := configstoreentry.NewListCommand(configstoreentryCmdRoot.CmdClause, data) configstoreentryUpdate := configstoreentry.NewUpdateCommand(configstoreentryCmdRoot.CmdClause, data) + dashboardCmdRoot := dashboard.NewRootCommand(app, data) + dashboardList := dashboard.NewListCommand(dashboardCmdRoot.CmdClause, data) + dashboardCreate := dashboard.NewCreateCommand(dashboardCmdRoot.CmdClause, data) + dashboardDescribe := dashboard.NewDescribeCommand(dashboardCmdRoot.CmdClause, data) + dashboardUpdate := dashboard.NewUpdateCommand(dashboardCmdRoot.CmdClause, data) + dashboardDelete := dashboard.NewDeleteCommand(dashboardCmdRoot.CmdClause, data) + dashboardItemCmdRoot := dashboardItem.NewRootCommand(dashboardCmdRoot.CmdClause, data) + dashboardItemCreate := dashboardItem.NewCreateCommand(dashboardItemCmdRoot.CmdClause, data) + dashboardItemDescribe := dashboardItem.NewDescribeCommand(dashboardItemCmdRoot.CmdClause, data) + dashboardItemUpdate := dashboardItem.NewUpdateCommand(dashboardItemCmdRoot.CmdClause, data) + dashboardItemDelete := dashboardItem.NewDeleteCommand(dashboardItemCmdRoot.CmdClause, data) dictionaryCmdRoot := dictionary.NewRootCommand(app, data) dictionaryCreate := dictionary.NewCreateCommand(dictionaryCmdRoot.CmdClause, data) dictionaryDelete := dictionary.NewDeleteCommand(dictionaryCmdRoot.CmdClause, data) @@ -535,6 +548,17 @@ func Define( configstoreentryDescribe, configstoreentryList, configstoreentryUpdate, + dashboardCmdRoot, + dashboardList, + dashboardCreate, + dashboardDescribe, + dashboardUpdate, + dashboardDelete, + dashboardItemCmdRoot, + dashboardItemCreate, + dashboardItemDescribe, + dashboardItemUpdate, + dashboardItemDelete, dictionaryCmdRoot, dictionaryCreate, dictionaryDelete, diff --git a/pkg/commands/dashboard/common/print.go b/pkg/commands/dashboard/common/print.go new file mode 100644 index 000000000..db07b9b3f --- /dev/null +++ b/pkg/commands/dashboard/common/print.go @@ -0,0 +1,88 @@ +package common + +import ( + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v9/fastly" + + "github.com/fastly/cli/pkg/text" +) + +// PrintSummary displays the information returned from the API in a summarised +// format. +func PrintSummary(out io.Writer, ds []fastly.ObservabilityCustomDashboard) { + t := text.NewTable(out) + t.AddHeader("DASHBOARD ID", "NAME", "DESCRIPTION", "# ITEMS") + for _, d := range ds { + t.AddLine( + d.ID, + d.Name, + d.Description, + len(d.Items), + ) + } + t.Print() +} + +// PrintVerbose displays the information returned from the API in a verbose +// format. +func PrintVerbose(out io.Writer, ds []fastly.ObservabilityCustomDashboard) { + for _, d := range ds { + PrintDashboard(out, 0, &d) + fmt.Fprintf(out, "\n") + } +} + +func PrintDashboard(out io.Writer, indent uint, dashboard *fastly.ObservabilityCustomDashboard) { + indentStep := uint(4) + level := indent + text.Indent(out, level, "Name: %s", dashboard.Name) + text.Indent(out, level, "Description: %s", dashboard.Description) + text.Indent(out, level, "Items:") + + level += indentStep + for i, di := range dashboard.Items { + text.Indent(out, level, "[%d]:", i) + level += indentStep + PrintItem(out, level, &di) + level -= indentStep + } + level -= indentStep + + text.Indent(out, level, "Meta:") + level += indentStep + text.Indent(out, level, "Created at: %s", dashboard.CreatedAt) + text.Indent(out, level, "Updated at: %s", dashboard.UpdatedAt) + text.Indent(out, level, "Created by: %s", dashboard.CreatedBy) + text.Indent(out, level, "Updated by: %s", dashboard.UpdatedBy) +} + +func PrintItem(out io.Writer, indent uint, item *fastly.DashboardItem) { + indentStep := uint(4) + level := indent + if item != nil { + text.Indent(out, level, "ID: %s", item.ID) + text.Indent(out, level, "Title: %s", item.Title) + text.Indent(out, level, "Subtitle: %s", item.Subtitle) + text.Indent(out, level, "Span: %d", item.Span) + + text.Indent(out, level, "Data Source:") + level += indentStep + text.Indent(out, level, "Type: %s", item.DataSource.Type) + text.Indent(out, level, "Metrics: %s", strings.Join(item.DataSource.Config.Metrics, ", ")) + level -= indentStep + + text.Indent(out, level, "Visualization:") + level += indentStep + text.Indent(out, level, "Type: %s", item.Visualization.Type) + text.Indent(out, level, "Plot Type: %s", item.Visualization.Config.PlotType) + if item.Visualization.Config.CalculationMethod != nil { + text.Indent(out, level, "Calculation Method: %s", *item.Visualization.Config.CalculationMethod) + } + if item.Visualization.Config.Format != nil { + text.Indent(out, level, "Format: %s", *item.Visualization.Config.Format) + } + } +} diff --git a/pkg/commands/dashboard/create.go b/pkg/commands/dashboard/create.go new file mode 100644 index 000000000..24d0f1424 --- /dev/null +++ b/pkg/commands/dashboard/create.go @@ -0,0 +1,71 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v9/fastly" + + "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" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, globals *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a custom dashboard").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("name", "A human-readable name for the dashboard").Short('n').Required().StringVar(&c.name) // --name + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("description", "A short description of the dashboard").Action(c.description.Set).StringVar(&c.description.Value) // --description + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + name string + description argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + dashboard, err := c.Globals.APIClient.CreateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, dashboard); ok { + return err + } + + text.Success(out, `Created Custom Dashboard "%s" (id: %s)`, dashboard.Name, dashboard.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateObservabilityCustomDashboardInput { + input := fastly.CreateObservabilityCustomDashboardInput{ + Name: c.name, + Items: []fastly.DashboardItem{}, + } + + if c.description.WasSet { + input.Description = &c.description.Value + } + + return &input +} diff --git a/pkg/commands/dashboard/dashboard_test.go b/pkg/commands/dashboard/dashboard_test.go new file mode 100644 index 000000000..f0ec16554 --- /dev/null +++ b/pkg/commands/dashboard/dashboard_test.go @@ -0,0 +1,238 @@ +package dashboard_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v9/fastly" + + root "github.com/fastly/cli/pkg/commands/dashboard" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + userID = "test-user" +) + +func TestCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate CreateObservabilityCustomDashboard API error", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "--name Testing", + WantError: testutil.Err.Error(), + }, + { + Name: "validate missing --name flag", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate optional --description flag", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return &fastly.ObservabilityCustomDashboard{ + ID: "beepboop", + Name: i.Name, + }, nil + }, + }, + Args: "--name Testing", + WantOutput: `Created Custom Dashboard "Testing" (id: beepboop)`, + }, + { + Name: "validate CreateObservabilityCustomDashboard API success", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return &fastly.ObservabilityCustomDashboard{ + ID: "beepboop", + Name: i.Name, + Description: *i.Description, + }, nil + }, + }, + Args: "--name Testing --description foo", + WantOutput: `Created Custom Dashboard "Testing" (id: beepboop)`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate DeleteObservabilityCustomDashboard API error", + API: mock.API{ + DeleteObservabilityCustomDashboardFn: func(i *fastly.DeleteObservabilityCustomDashboardInput) error { + return testutil.Err + }, + }, + Args: "--id beepboop", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteObservabilityCustomDashboard API success", + API: mock.API{ + DeleteObservabilityCustomDashboardFn: func(i *fastly.DeleteObservabilityCustomDashboardInput) error { + return nil + }, + }, + Args: "--id beepboop", + WantOutput: "Deleted Custom Dashboard beepboop", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate GetObservabilityCustomDashboard API error", + API: mock.API{ + GetObservabilityCustomDashboardFn: func(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "--id beepboop", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetObservabilityCustomDashboard API success", + API: mock.API{ + GetObservabilityCustomDashboardFn: getObservabilityCustomDashboard, + }, + Args: "--id beepboop", + WantOutput: "Name: Testing\nDescription: This is a test dashboard\nItems:\nMeta:\n Created at: 2021-06-15 23:00:00 +0000 UTC\n Updated at: 2021-06-15 23:00:00 +0000 UTC\n Created by: test-user\n Updated by: test-user\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate ListObservabilityCustomDashboards API error", + API: mock.API{ + ListObservabilityCustomDashboardsFn: func(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListObservabilityCustomDashboards API success", + API: mock.API{ + ListObservabilityCustomDashboardsFn: listObservabilityCustomDashboards, + }, + WantOutput: "DASHBOARD ID NAME DESCRIPTION # ITEMS\nbeepboop Testing 1 This is #1 0\nbleepblorp Testing 2 This is #2 0\n", + }, + { + Name: "validate --verbose flag", + API: mock.API{ + ListObservabilityCustomDashboardsFn: listObservabilityCustomDashboards, + }, + Args: "--verbose", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nName: Testing 1\nDescription: This is #1\nItems:\nMeta:\n Created at: 2021-06-15 23:00:00 +0000 UTC\n Updated at: 2021-06-15 23:00:00 +0000 UTC\n Created by: test-user\n Updated by: test-user\n\nName: Testing 2\nDescription: This is #2\nItems:\nMeta:\n Created at: 2021-06-15 23:00:00 +0000 UTC\n Updated at: 2021-06-15 23:00:00 +0000 UTC\n Created by: test-user\n Updated by: test-user\n\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate UpdateObservabilityCustomDashboard API error", + API: mock.API{ + UpdateObservabilityCustomDashboardFn: func(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "--id beepboop", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateObservabilityCustomDashboard API success", + API: mock.API{ + UpdateObservabilityCustomDashboardFn: func(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return &fastly.ObservabilityCustomDashboard{ + ID: *i.ID, + Name: *i.Name, + Description: *i.Description, + }, nil + }, + }, + Args: "--id beepboop --name Foo --description Bleepblorp", + WantOutput: "SUCCESS: Updated Custom Dashboard \"Foo\" (id: beepboop)\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func getObservabilityCustomDashboard(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + t := testutil.Date + + return &fastly.ObservabilityCustomDashboard{ + CreatedAt: t, + CreatedBy: userID, + Description: "This is a test dashboard", + ID: *i.ID, + Items: []fastly.DashboardItem{}, + Name: "Testing", + UpdatedAt: t, + UpdatedBy: userID, + }, nil +} + +func listObservabilityCustomDashboards(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) { + t := testutil.Date + vs := &fastly.ListDashboardsResponse{ + Data: []fastly.ObservabilityCustomDashboard{{ + CreatedAt: t, + CreatedBy: userID, + Description: "This is #1", + ID: "beepboop", + Items: []fastly.DashboardItem{}, + Name: "Testing 1", + UpdatedAt: t, + UpdatedBy: userID, + }, { + CreatedAt: t, + CreatedBy: userID, + Description: "This is #2", + ID: "bleepblorp", + Items: []fastly.DashboardItem{}, + Name: "Testing 2", + UpdatedAt: t, + UpdatedBy: userID, + }}, + Meta: fastly.DashboardMeta{}, + } + + return vs, nil +} diff --git a/pkg/commands/dashboard/delete.go b/pkg/commands/dashboard/delete.go new file mode 100644 index 000000000..f18efaeb7 --- /dev/null +++ b/pkg/commands/dashboard/delete.go @@ -0,0 +1,72 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v9/fastly" + + "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" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, globals *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Delete a custom dashboard").Alias("remove") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("id", "Alphanumeric string identifying a Dashboard").Required().StringVar(&c.dashboardID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + dashboardID string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + err := c.Globals.APIClient.DeleteObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"dashboard_id"` + Deleted bool `json:"deleted"` + }{ + c.dashboardID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, `Deleted Custom Dashboard %s`, fastly.ToValue(input.ID)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteObservabilityCustomDashboardInput { + var input fastly.DeleteObservabilityCustomDashboardInput + + input.ID = &c.dashboardID + + return &input +} diff --git a/pkg/commands/dashboard/describe.go b/pkg/commands/dashboard/describe.go new file mode 100644 index 000000000..6a78074cc --- /dev/null +++ b/pkg/commands/dashboard/describe.go @@ -0,0 +1,63 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v9/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, globals *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show detailed information about a custom dashboard").Alias("get") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("id", "Alphanumeric string identifying a Dashboard").Required().StringVar(&c.dashboardID) + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + dashboardID string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + dashboard, err := c.Globals.APIClient.GetObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, dashboard); ok { + return err + } + + common.PrintDashboard(out, 0, dashboard) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetObservabilityCustomDashboardInput { + var input fastly.GetObservabilityCustomDashboardInput + + input.ID = &c.dashboardID + + return &input +} diff --git a/pkg/commands/dashboard/doc.go b/pkg/commands/dashboard/doc.go new file mode 100644 index 000000000..0c1516259 --- /dev/null +++ b/pkg/commands/dashboard/doc.go @@ -0,0 +1,2 @@ +// Package dashboard contains commands to <...>. +package dashboard diff --git a/pkg/commands/dashboard/item/common.go b/pkg/commands/dashboard/item/common.go new file mode 100644 index 000000000..6089afd81 --- /dev/null +++ b/pkg/commands/dashboard/item/common.go @@ -0,0 +1,9 @@ +package item + +var ( + sourceTypes = []string{"stats.domain", "stats.edge", "stats.origin"} + visualizationTypes = []string{"chart"} + plotTypes = []string{"bar", "donut", "line", "single-metric"} + calculationMethods = []string{"avg", "sum", "min", "max", "latest", "p95"} + formats = []string{"number", "bytes", "percent", "requests", "responses", "seconds", "milliseconds", "ratio", "bitrate"} +) diff --git a/pkg/commands/dashboard/item/create.go b/pkg/commands/dashboard/item/create.go new file mode 100644 index 000000000..6215e860d --- /dev/null +++ b/pkg/commands/dashboard/item/create.go @@ -0,0 +1,121 @@ +package item + +import ( + "io" + + "github.com/fastly/go-fastly/v9/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, globals *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "Alphanumeric string identifying a Dashboard").Required().StringVar(&c.dashboardID) + c.CmdClause.Flag("title", "A human-readable title for the dashboard item").Required().StringVar(&c.title) + c.CmdClause.Flag("subtitle", "A human-readable subtitle for the dashboard item. Often a description of the visualization").Required().StringVar(&c.subtitle) + c.CmdClause.Flag("source-type", "The source of the data to display").Required().HintOptions(sourceTypes...).EnumVar(&c.sourceType, sourceTypes...) + c.CmdClause.Flag("metrics", "The metrics to visualize. Valid options depend on the selected data source (set flag once per Metric)").Required().StringsVar(&c.metrics) + c.CmdClause.Flag("plot-type", "The type of chart to display").Required().HintOptions(plotTypes...).EnumVar(&c.plotType, plotTypes...) + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("visualization-type", `The type of visualization to display. Currently, only "chart" is supported`).Default("chart").HintOptions(visualizationTypes...).EnumVar(&c.vizType, visualizationTypes...) + c.CmdClause.Flag("calculation-method", "The aggregation function to apply to the dataset").Action(c.calculationMethod.Set).HintOptions(calculationMethods...).EnumVar(&c.calculationMethod.Value, calculationMethods...) // --calculation-method + c.CmdClause.Flag("format", "foo").Action(c.format.Set).HintOptions(formats...).EnumVar(&c.format.Value, formats...) // --format + c.CmdClause.Flag("span", `The number of columns for the dashboard item to span. Dashboards are rendered on a 12-column grid on "desktop" screen sizes`).Default("4").Uint8Var(&c.span) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + title string + subtitle string + sourceType string + metrics []string + plotType string + + // optional + vizType string + calculationMethod argparser.OptionalString + format argparser.OptionalString + span uint8 +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(&fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID}) + if err != nil { + return err + } + + input := c.constructInput(d) + d, err = c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, d); ok { + return err + } + + text.Success(out, `Added item to Custom Dashboard "%s" (id: %s)`, d.Name, d.ID) + // Summary isn't useful for a single dashboard, so print verbose by default + common.PrintDashboard(out, 0, d) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(d *fastly.ObservabilityCustomDashboard) *fastly.UpdateObservabilityCustomDashboardInput { + input := fastly.UpdateObservabilityCustomDashboardInput{ + ID: &d.ID, + Name: &d.Name, + Description: &d.Description, + Items: &d.Items, + } + item := fastly.DashboardItem{ + Title: c.title, + Subtitle: c.subtitle, + Span: c.span, + DataSource: fastly.DashboardDataSource{ + Type: fastly.DashboardSourceType(c.sourceType), + Config: fastly.DashboardSourceConfig{ + Metrics: c.metrics, + }, + }, + Visualization: fastly.DashboardVisualization{ + Type: fastly.VisualizationType(c.vizType), + Config: fastly.VisualizationConfig{ + PlotType: fastly.PlotType(c.plotType), + }, + }, + } + if c.calculationMethod.WasSet { + item.Visualization.Config.CalculationMethod = fastly.ToPointer(fastly.CalculationMethod(c.calculationMethod.Value)) + } + if c.format.WasSet { + item.Visualization.Config.Format = fastly.ToPointer(fastly.VisualizationFormat(c.format.Value)) + } + + *input.Items = append(*input.Items, item) + + return &input +} diff --git a/pkg/commands/dashboard/item/delete.go b/pkg/commands/dashboard/item/delete.go new file mode 100644 index 000000000..d0ea0c5a2 --- /dev/null +++ b/pkg/commands/dashboard/item/delete.go @@ -0,0 +1,105 @@ +package item + +import ( + "io" + "slices" + + "github.com/fastly/go-fastly/v9/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, globals *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Delete a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "Alphanumeric string identifying a Dashboard").Required().StringVar(&c.dashboardID) // --dashboard-id + c.CmdClause.Flag("item-id", "Alphanumeric string identifying a Dashboard Item").Required().StringVar(&c.itemID) // --item-id + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + itemID string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(&fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID}) + if err != nil { + return err + } + + success := false + numItems := len(d.Items) + + if slices.ContainsFunc(d.Items, func(di fastly.DashboardItem) bool { + return di.ID == c.itemID + }) { + input := c.constructInput(d) + d, err = c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + success = true + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"item_id"` + Deleted bool `json:"deleted"` + NewState *fastly.ObservabilityCustomDashboard `json:"dashboard_state"` + }{ + c.itemID, + success, + d, + } + _, err := c.WriteJSON(out, o) + return err + } + + if success { + text.Success(out, `Removed %d dashboard item(s) from Custom Dashboard "%s" (dashboardID: %s)`, (numItems - (len(d.Items))), d.Name, d.ID) + } else { + text.Warning(out, "dashboard (%s) has no item with ID (%s)", d.ID, c.itemID) + } + common.PrintDashboard(out, 0, d) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(d *fastly.ObservabilityCustomDashboard) *fastly.UpdateObservabilityCustomDashboardInput { + input := fastly.UpdateObservabilityCustomDashboardInput{ + ID: &d.ID, + Name: &d.Name, + Description: &d.Description, + } + + items := slices.DeleteFunc(d.Items, func(di fastly.DashboardItem) bool { + return di.ID == c.itemID + }) + input.Items = &items + + return &input +} diff --git a/pkg/commands/dashboard/item/describe.go b/pkg/commands/dashboard/item/describe.go new file mode 100644 index 000000000..3b7ad6e43 --- /dev/null +++ b/pkg/commands/dashboard/item/describe.go @@ -0,0 +1,80 @@ +package item + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v9/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, globals *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Describe a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "Alphanumeric string identifying a Dashboard").Required().StringVar(&c.dashboardID) // --dashboard-id + c.CmdClause.Flag("item-id", "Alphanumeric string identifying a Dashboard Item").Required().StringVar(&c.itemID) // --item-id + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + itemID string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(input) + if err != nil { + return err + } + + di, err := getItemFromDashboard(d, c.itemID) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + c.WriteJSON(out, di) + } else { + common.PrintItem(out, 0, di) + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetObservabilityCustomDashboardInput { + return &fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID} +} + +func getItemFromDashboard(d *fastly.ObservabilityCustomDashboard, itemID string) (*fastly.DashboardItem, error) { + for _, di := range d.Items { + if di.ID == itemID { + return &di, nil + } + } + return nil, fmt.Errorf("could not find item with ID (%s) in Dashboard (%s)", itemID, d.ID) +} diff --git a/pkg/commands/dashboard/item/item_test.go b/pkg/commands/dashboard/item/item_test.go new file mode 100644 index 000000000..a233fdc97 --- /dev/null +++ b/pkg/commands/dashboard/item/item_test.go @@ -0,0 +1,308 @@ +package item_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v9/fastly" + + root "github.com/fastly/cli/pkg/commands/dashboard" + sub "github.com/fastly/cli/pkg/commands/dashboard/item" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +var ( + testDate = testutil.Date + userID = "test-user" + dashboardID = "beepboop" + itemID = "bleepblorp" + dashboardName = "Foo" + dashboardDescription = "Testing..." + title = "Title" + subtitle = "Subtitle" + sourceType = "stats.edge" + metrics = "requests" + plotType = "line" + vizType = "chart" + calculationMethod = "latest" + format = "requests" + span = 8 + defaultItem = fastly.DashboardItem{ + DataSource: fastly.DashboardDataSource{ + Config: fastly.DashboardSourceConfig{ + Metrics: []string{metrics}, + }, + Type: fastly.DashboardSourceType(sourceType), + }, + ID: itemID, + Span: uint8(span), + Subtitle: subtitle, + Title: title, + Visualization: fastly.DashboardVisualization{ + Config: fastly.VisualizationConfig{ + CalculationMethod: fastly.ToPointer(fastly.CalculationMethod(calculationMethod)), + Format: fastly.ToPointer(fastly.VisualizationFormat(format)), + PlotType: fastly.PlotType(plotType), + }, + Type: fastly.VisualizationType(vizType), + }, + } + defaultDashboard = func() fastly.ObservabilityCustomDashboard { + return fastly.ObservabilityCustomDashboard{ + CreatedAt: testDate, + CreatedBy: userID, + Description: dashboardDescription, + ID: dashboardID, + Items: []fastly.DashboardItem{defaultItem}, + Name: dashboardName, + UpdatedAt: testDate, + UpdatedBy: userID, + } + } +) + +func TestCreate(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --source-type %s --metrics %s --plot-type %s", dashboardID, title, subtitle, sourceType, metrics, plotType) + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--title %s --subtitle %s --source-type %s --metrics %s --plot-type %s", title, subtitle, sourceType, metrics, plotType), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --title flag", + Args: fmt.Sprintf("--dashboard-id %s --subtitle %s --source-type %s --metrics %s --plot-type %s", dashboardID, subtitle, sourceType, metrics, plotType), + WantError: "error parsing arguments: required flag --title not provided", + }, + { + Name: "validate missing --subtitle flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --source-type %s --metrics %s --plot-type %s", dashboardID, title, sourceType, metrics, plotType), + WantError: "error parsing arguments: required flag --subtitle not provided", + }, + { + Name: "validate missing --source-type flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --metrics %s --plot-type %s", dashboardID, title, subtitle, metrics, plotType), + WantError: "error parsing arguments: required flag --source-type not provided", + }, + { + Name: "validate missing --metrics flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --source-type %s --plot-type %s", dashboardID, title, subtitle, sourceType, plotType), + WantError: "error parsing arguments: required flag --metrics not provided", + }, + { + Name: "validate missing --plot-type flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --source-type %s --metrics %s", dashboardID, title, subtitle, sourceType, metrics), + WantError: "error parsing arguments: required flag --plot-type not provided", + }, + { + Name: "validate multiple --metrics flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --metrics %s", allRequiredFlags, "responses"), + WantOutput: "Metrics: requests, responses", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: allRequiredFlags, + WantOutput: `Added item to Custom Dashboard "Foo"`, + }, + { + Name: "validate all optional flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --visualization-type %s --calculation-method %s --format %s --span %d", allRequiredFlags, vizType, calculationMethod, format, span), + WantOutput: `Added item to Custom Dashboard "Foo"`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestDelete(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --item-id %s", dashboardID, itemID) + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--item-id %s", itemID), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --item-id flag", + Args: fmt.Sprintf("--dashboard-id %s", dashboardID), + WantError: "error parsing arguments: required flag --item-id not provided", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardEmpty, + }, + Args: allRequiredFlags, + WantOutput: `Removed 1 dashboard item(s) from Custom Dashboard "Foo"`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestDescribe(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --item-id %s", dashboardID, itemID) + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--item-id %s", itemID), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --item-id flag", + Args: fmt.Sprintf("--dashboard-id %s", dashboardID), + WantError: "error parsing arguments: required flag --item-id not provided", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardEmpty, + }, + Args: allRequiredFlags, + WantOutput: "ID: bleepblorp\nTitle: Title\nSubtitle: Subtitle\nSpan: 8\nData Source:\n Type: stats.edge\n Metrics: requests\nVisualization:\n Type: chart\n Plot Type: line\n Calculation Method: latest\n Format: requests\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestUpdate(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --item-id %s", dashboardID, itemID) + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--item-id %s", itemID), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --item-id flag", + Args: fmt.Sprintf("--dashboard-id %s", dashboardID), + WantError: "error parsing arguments: required flag --item-id not provided", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: allRequiredFlags, + WantOutput: "Name: Foo\nDescription: Testing...\nItems:\n [0]:\n ID: bleepblorp\n Title: Title\n Subtitle: Subtitle\n Span: 8\n Data Source:\n Type: stats.edge\n Metrics: requests\n Visualization:\n Type: chart\n Plot Type: line\n Calculation Method: latest\n Format: requests\nMeta:\n Created at: 2021-06-15 23:00:00 +0000 UTC\n Updated at: 2021-06-15 23:00:00 +0000 UTC\n Created by: test-user\n Updated by: test-user\n", + }, + { + Name: "validate optional --title flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --title %s", allRequiredFlags, "NewTitle"), + WantOutput: "Title: NewTitle", + }, + { + Name: "validate optional --subtitle flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --subtitle %s", allRequiredFlags, "NewSubtitle"), + WantOutput: "Subtitle: NewSubtitle", + }, + { + Name: "validate optional --span flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --span %d", allRequiredFlags, 12), + WantOutput: "Span: 12", + }, + { + Name: "validate optional --source-type flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --source-type %s", allRequiredFlags, "stats.domain"), + WantOutput: "Type: stats.domain", + }, + { + Name: "validate optional --metrics flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --metrics %s", allRequiredFlags, "status_4xx"), + WantOutput: "Metrics: status_4xx", + }, + { + Name: "validate multiple --metrics flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --metrics %s --metrics %s --metrics %s", allRequiredFlags, "status_2xx", "status_4xx", "status_5xx"), + WantOutput: "Metrics: status_2xx, status_4xx, status_5xx", + }, + { + Name: "validate optional --calculation-method flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --calculation-method %s", allRequiredFlags, "avg"), + WantOutput: "Calculation Method: avg", + }, + { + Name: "validate optional --format flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --format %s", allRequiredFlags, "ratio"), + WantOutput: "Format: ratio", + }, + { + Name: "validate optional --plot-type flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --plot-type %s", allRequiredFlags, "single-metric"), + WantOutput: "Plot Type: single-metric", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func getDashboardOK(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + d := defaultDashboard() + return &d, nil +} + +func updateDashboardOK(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + d := defaultDashboard() + d.Items = *i.Items + return &d, nil +} + +func updateDashboardEmpty(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + d := defaultDashboard() + d.Items = []fastly.DashboardItem{} + return &d, nil +} diff --git a/pkg/commands/dashboard/item/root.go b/pkg/commands/dashboard/item/root.go new file mode 100644 index 000000000..cee4da10c --- /dev/null +++ b/pkg/commands/dashboard/item/root.go @@ -0,0 +1,31 @@ +package item + +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. +// 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 = "item" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Custom Dashboard Items") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dashboard/item/update.go b/pkg/commands/dashboard/item/update.go new file mode 100644 index 000000000..df75ee493 --- /dev/null +++ b/pkg/commands/dashboard/item/update.go @@ -0,0 +1,139 @@ +package item + +import ( + "fmt" + "io" + "slices" + + "github.com/fastly/go-fastly/v9/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, globals *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "Alphanumeric string identifying a Dashboard").Required().StringVar(&c.dashboardID) // --dashboard-id + c.CmdClause.Flag("item-id", "Alphanumeric string identifying a Dashboard Item").Required().StringVar(&c.itemID) // --item-id + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("title", "A human-readable title for the dashboard item").Action(c.title.Set).StringVar(&c.title.Value) // --title + c.CmdClause.Flag("subtitle", "A human-readable subtitle for the dashboard item. Often a description of the visualization").Action(c.subtitle.Set).StringVar(&c.subtitle.Value) // --subtitle + c.CmdClause.Flag("span", `The number of columns for the dashboard item to span. Dashboards are rendered on a 12-column grid on "desktop" screen sizes`).Action(c.span.Set).IntVar(&c.span.Value) // --span + c.CmdClause.Flag("source-type", "The source of the data to display").Action(c.sourceType.Set).HintOptions(sourceTypes...).EnumVar(&c.sourceType.Value, sourceTypes...) // --source-type + c.CmdClause.Flag("metrics", "The metrics to visualize. Valid options depend on the selected data source (set flag once per Metric)").Action(c.metrics.Set).StringsVar(&c.metrics.Value) // --metrics + c.CmdClause.Flag("visualization-type", `The type of visualization to display. Currently, only "chart" is supported`).Action(c.vizType.Set).HintOptions(visualizationTypes...).EnumVar(&c.vizType.Value, visualizationTypes...) // --visualization-type + c.CmdClause.Flag("calculation-method", "The aggregation function to apply to the dataset").Action(c.calculationMethod.Set).HintOptions(calculationMethods...).EnumVar(&c.calculationMethod.Value, calculationMethods...) // --calculation-method + c.CmdClause.Flag("format", "The units to use to format the data").Action(c.format.Set).HintOptions(formats...).EnumVar(&c.format.Value, formats...) // --format + c.CmdClause.Flag("plot-type", "The type of chart to display").Action(c.plotType.Set).HintOptions(plotTypes...).EnumVar(&c.plotType.Value, plotTypes...) // --plot-type + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + itemID string + + // optional + title argparser.OptionalString + subtitle argparser.OptionalString + span argparser.OptionalInt + sourceType argparser.OptionalString + metrics argparser.OptionalStringSlice + plotType argparser.OptionalString + vizType argparser.OptionalString + calculationMethod argparser.OptionalString + format argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(&fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID}) + if err != nil { + return err + } + + input, err := c.constructInput(d) + if err != nil { + return err + } + + d, err = c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, d); ok { + return err + } + + // text.Success(out, `Added %d items to Custom Dashboard "%s" (id: %s)`, len(*input.Items), d.Name, d.ID) + common.PrintDashboard(out, 0, d) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(d *fastly.ObservabilityCustomDashboard) (*fastly.UpdateObservabilityCustomDashboardInput, error) { + var input fastly.UpdateObservabilityCustomDashboardInput + + input.ID = &d.ID + input.Items = &d.Items + idx := slices.IndexFunc(*input.Items, func(di fastly.DashboardItem) bool { + return di.ID == c.itemID + }) + if idx < 0 { + return nil, fmt.Errorf("dashboard (%s) does not contain item with ID %s", d.ID, c.itemID) + } + item := &(*input.Items)[idx] + + if c.title.WasSet { + (*item).Title = c.title.Value + } + if c.subtitle.WasSet { + (*item).Subtitle = c.subtitle.Value + } + if c.span.WasSet { + if span := c.span.Value; span <= 255 && span >= 0 { + (*item).Span = uint8(span) + } else { + return nil, fmt.Errorf("invalid span value %d", span) + } + } + if c.sourceType.WasSet { + (*item).DataSource.Type = fastly.DashboardSourceType(c.sourceType.Value) + } + if c.metrics.WasSet { + (*item).DataSource.Config.Metrics = c.metrics.Value + } + if c.vizType.WasSet { + (*item).Visualization.Type = fastly.VisualizationType(c.vizType.Value) + } + if c.plotType.WasSet { + (*item).Visualization.Config.PlotType = fastly.PlotType(c.plotType.Value) + } + if c.calculationMethod.WasSet { + (*item).Visualization.Config.CalculationMethod = fastly.ToPointer(fastly.CalculationMethod(c.calculationMethod.Value)) + } + if c.format.WasSet { + (*item).Visualization.Config.Format = fastly.ToPointer(fastly.VisualizationFormat(c.format.Value)) + } + + return &input, nil +} diff --git a/pkg/commands/dashboard/list.go b/pkg/commands/dashboard/list.go new file mode 100644 index 000000000..cfe966b91 --- /dev/null +++ b/pkg/commands/dashboard/list.go @@ -0,0 +1,141 @@ +package dashboard + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v9/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, globals *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List custom dashboards") + c.Globals = globals + + // Optional Flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("cursor", "Pagination cursor (Use 'next_cursor' value from list output)").Action(c.cursor.Set).StringVar(&c.cursor.Value) + c.CmdClause.Flag("limit", "Maximum number of items to list").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("order", "Sort by one of the following [asc, desc]").Action(c.order.Set).StringVar(&c.order.Value) + c.CmdClause.Flag("sort", "Sort by one of the following [name, created_at, updated_at]").Action(c.sort.Set).StringVar(&c.sort.Value) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + cursor argparser.OptionalString + limit argparser.OptionalInt + sort argparser.OptionalString + order argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input, err := c.constructInput() + if err != nil { + return err + } + + var dashboards []fastly.ObservabilityCustomDashboard + loadAllPages := c.JSONOutput.Enabled || c.Globals.Flags.NonInteractive || c.Globals.Flags.AutoYes + + for { + o, err := c.Globals.APIClient.ListObservabilityCustomDashboards(input) + if err != nil { + return err + } + + if o != nil { + dashboards = append(dashboards, o.Data...) + + if loadAllPages { + if o.Meta.NextCursor != "" { + input.Cursor = &o.Meta.NextCursor + continue + } + break + } + + if c.Globals.Verbose() { + common.PrintVerbose(out, dashboards) + } else { + common.PrintSummary(out, dashboards) + } + + if o.Meta.NextCursor != "" && text.IsTTY(out) { + text.Break(out) + printNextPage, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNextPage { + dashboards = []fastly.ObservabilityCustomDashboard{} + input.Cursor = &o.Meta.NextCursor + continue + } + } + } + + return nil + } + + if ok, err := c.WriteJSON(out, dashboards); ok { + // No pagination prompt w/ JSON output. + return err + } else { + // Only print output here if we've not already printed JSON. + if c.Globals.Verbose() { + common.PrintVerbose(out, dashboards) + } else { + common.PrintSummary(out, dashboards) + } + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() (*fastly.ListObservabilityCustomDashboardsInput, error) { + var input fastly.ListObservabilityCustomDashboardsInput + + if c.cursor.WasSet { + input.Cursor = &c.cursor.Value + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + var sign string + if c.order.WasSet { + switch c.order.Value { + case "asc": + case "desc": + sign = "-" + default: + err := errors.New("'order' flag must be one of the following [asc, desc]") + c.Globals.ErrLog.Add(err) + return nil, err + } + } + + if c.sort.WasSet { + str := sign + c.sort.Value + input.Sort = &str + } + + return &input, nil +} diff --git a/pkg/commands/dashboard/root.go b/pkg/commands/dashboard/root.go new file mode 100644 index 000000000..3571976d2 --- /dev/null +++ b/pkg/commands/dashboard/root.go @@ -0,0 +1,31 @@ +package dashboard + +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. +// 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 = "dashboard" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Custom Dashboards") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dashboard/update.go b/pkg/commands/dashboard/update.go new file mode 100644 index 000000000..b6ad3b457 --- /dev/null +++ b/pkg/commands/dashboard/update.go @@ -0,0 +1,75 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v9/fastly" + + "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" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, globals *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a custom dashboard") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("id", "Alphanumeric string identifying a Dashboard").Required().StringVar(&c.dashboardID) + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("name", "A human-readable name for the dashboard").Short('n').Action(c.name.Set).StringVar(&c.name.Value) // --name + c.CmdClause.Flag("description", "A short description of the dashboard").Action(c.description.Set).StringVar(&c.description.Value) // --description + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + dashboardID string + name argparser.OptionalString + description argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + dashboard, err := c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, dashboard); ok { + return err + } + + text.Success(out, `Updated Custom Dashboard "%s" (id: %s)`, dashboard.Name, dashboard.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateObservabilityCustomDashboardInput { + var input fastly.UpdateObservabilityCustomDashboardInput + + input.ID = &c.dashboardID + + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.description.WasSet { + input.Description = &c.description.Value + } + + return &input +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 03fb87759..4633c4b95 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -398,6 +398,12 @@ type API struct { DeleteAlertDefinitionFn func(i *fastly.DeleteAlertDefinitionInput) error TestAlertDefinitionFn func(i *fastly.TestAlertDefinitionInput) error ListAlertHistoryFn func(i *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) + + ListObservabilityCustomDashboardsFn func(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) + CreateObservabilityCustomDashboardFn func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + GetObservabilityCustomDashboardFn func(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + UpdateObservabilityCustomDashboardFn func(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + DeleteObservabilityCustomDashboardFn func(i *fastly.DeleteObservabilityCustomDashboardInput) error } // AllDatacenters implements Interface. @@ -2024,3 +2030,28 @@ func (m API) TestAlertDefinition(i *fastly.TestAlertDefinitionInput) error { func (m API) ListAlertHistory(i *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) { return m.ListAlertHistoryFn(i) } + +// CreateObservabilityCustomDashboard implements Interface. +func (m API) CreateObservabilityCustomDashboard(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return m.CreateObservabilityCustomDashboardFn(i) +} + +// DeleteObservabilityCustomDashboard implements Interface. +func (m API) DeleteObservabilityCustomDashboard(i *fastly.DeleteObservabilityCustomDashboardInput) error { + return m.DeleteObservabilityCustomDashboardFn(i) +} + +// GetObservabilityCustomDashboard implements Interface. +func (m API) GetObservabilityCustomDashboard(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return m.GetObservabilityCustomDashboardFn(i) +} + +// ListObservabilityCustomDashboards implements Interface. +func (m API) ListObservabilityCustomDashboards(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) { + return m.ListObservabilityCustomDashboardsFn(i) +} + +// UpdateObservabilityCustomDashboard implements Interface. +func (m API) UpdateObservabilityCustomDashboard(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return m.UpdateObservabilityCustomDashboardFn(i) +}