Skip to content
Merged
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
23 changes: 23 additions & 0 deletions cmd/dashboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cmd

import (
"github.com/spf13/cobra"
"github.com/stackvista/stackstate-cli/cmd/dashboard"
"github.com/stackvista/stackstate-cli/internal/di"
)

func DashboardCommand(cli *di.Deps) *cobra.Command {
cmd := &cobra.Command{
Use: "dashboard",
Short: "Manage dashboards",
Long: "Manage, test and develop dashboards.",
}
cmd.AddCommand(dashboard.DashboardListCommand(cli))
cmd.AddCommand(dashboard.DashboardDescribeCommand(cli))
cmd.AddCommand(dashboard.DashboardCloneCommand(cli))
cmd.AddCommand(dashboard.DashboardDeleteCommand(cli))
cmd.AddCommand(dashboard.DashboardApplyCommand(cli))
cmd.AddCommand(dashboard.DashboardEditCommand(cli))

return cmd
}
18 changes: 18 additions & 0 deletions cmd/dashboard/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dashboard

import (
"fmt"
)

// ResolveDashboardIdOrUrn resolves ID or identifier to a string that can be used with the API
// Returns the resolved identifier string or an error if neither is provided
func ResolveDashboardIdOrUrn(id int64, identifier string) (string, error) {
switch {
case id != 0:
return fmt.Sprintf("%d", id), nil
case identifier != "":
return identifier, nil
default:
return "", fmt.Errorf("either --id or --identifier must be provided")
}
}
109 changes: 109 additions & 0 deletions cmd/dashboard/common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package dashboard

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestResolveDashboardIdOrUrn(t *testing.T) {
tests := []struct {
name string
id int64
identifier string
expectedResult string
expectedError string
}{
{
name: "Should resolve valid ID",
id: 123,
identifier: "",
expectedResult: "123",
expectedError: "",
},
{
name: "Should resolve valid identifier",
id: 0,
identifier: "urn:custom:dashboard:test",
expectedResult: "urn:custom:dashboard:test",
expectedError: "",
},
{
name: "Should prioritize ID when both are provided",
id: 456,
identifier: "urn:custom:dashboard:test",
expectedResult: "456",
expectedError: "",
},
{
name: "Should return error when neither is provided",
id: 0,
identifier: "",
expectedResult: "",
expectedError: "either --id or --identifier must be provided",
},
{
name: "Should resolve large ID",
id: 9223372036854775807, // max int64
identifier: "",
expectedResult: "9223372036854775807",
expectedError: "",
},
{
name: "Should resolve complex URN identifier",
id: 0,
identifier: "urn:stackpack:kubernetes:dashboard:cluster-overview",
expectedResult: "urn:stackpack:kubernetes:dashboard:cluster-overview",
expectedError: "",
},
{
name: "Should resolve identifier with special characters",
id: 0,
identifier: "urn:custom:dashboard:test-name_with.special-chars",
expectedResult: "urn:custom:dashboard:test-name_with.special-chars",
expectedError: "",
},
{
name: "Should handle empty string identifier as not provided",
id: 0,
identifier: "",
expectedResult: "",
expectedError: "either --id or --identifier must be provided",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ResolveDashboardIdOrUrn(tt.id, tt.identifier)

assert.Equal(t, tt.expectedResult, result)

if tt.expectedError != "" {
assert.NotNil(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.Nil(t, err)
}
})
}
}

func TestResolveDashboardIdOrUrnPriority(t *testing.T) {
// Test that ID takes priority over identifier when both are provided
result, err := ResolveDashboardIdOrUrn(999, "urn:custom:dashboard:ignored")

assert.Nil(t, err)
assert.Equal(t, "999", result)
}

func TestResolveDashboardIdOrUrnEdgeCases(t *testing.T) {
// Test negative ID (should still work as it's non-zero)
result, err := ResolveDashboardIdOrUrn(-1, "")
assert.Nil(t, err)
assert.Equal(t, "-1", result)

// Test with whitespace-only identifier (treated as empty)
result, err = ResolveDashboardIdOrUrn(0, " ")
assert.Nil(t, err)
assert.Equal(t, " ", result) // The function doesn't trim whitespace
}
142 changes: 142 additions & 0 deletions cmd/dashboard/dashboard_apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package dashboard

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"
"github.com/stackvista/stackstate-cli/generated/stackstate_api"
"github.com/stackvista/stackstate-cli/internal/common"
"github.com/stackvista/stackstate-cli/internal/di"
)

// ApplyArgs contains arguments for dashboard apply command
type ApplyArgs struct {
File string
}

func DashboardApplyCommand(cli *di.Deps) *cobra.Command {
args := &ApplyArgs{}
cmd := &cobra.Command{
Use: "apply",
Short: "Create or edit a dashboard from JSON",
Long: "Create or edit a dashboard from JSON file.",
RunE: cli.CmdRunEWithApi(RunDashboardApplyCommand(args)),
}

common.AddRequiredFileFlagVar(cmd, &args.File, "Path to a .json file with the dashboard definition")

return cmd
}

func RunDashboardApplyCommand(args *ApplyArgs) di.CmdWithApiFn {
return func(cmd *cobra.Command, cli *di.Deps, api *stackstate_api.APIClient, serverInfo *stackstate_api.ServerInfo) common.CLIError {
fileBytes, err := os.ReadFile(args.File)
if err != nil {
return common.NewReadFileError(err, args.File)
}

// Determine file type by extension
ext := strings.ToLower(filepath.Ext(args.File))
if ext != ".json" {
return common.NewCLIArgParseError(fmt.Errorf("unsupported file type: %s. Only .json files are supported", ext))
}

return applyJSONDashboard(cli, api, fileBytes)
}
}

// applyJSONDashboard processes JSON dashboard file and determines create vs update operation
func applyJSONDashboard(cli *di.Deps, api *stackstate_api.APIClient, fileBytes []byte) common.CLIError {
// Parse the JSON to determine if it's a create or update operation
var dashboardData map[string]interface{}
if err := json.Unmarshal(fileBytes, &dashboardData); err != nil {
return common.NewCLIArgParseError(fmt.Errorf("failed to parse JSON: %v", err))
}

// Check if it has an ID field (indicates update operation)
if idField, hasId := dashboardData["id"]; hasId {
// Update existing dashboard
dashboardId := fmt.Sprintf("%.0f", idField.(float64))
return updateDashboard(cli, api, dashboardId, dashboardData)
} else {
// Create new dashboard
return createDashboard(cli, api, fileBytes)
}
}

// createDashboard creates a new dashboard from JSON schema
func createDashboard(cli *di.Deps, api *stackstate_api.APIClient, fileBytes []byte) common.CLIError {
var writeSchema stackstate_api.DashboardWriteSchema
if err := json.Unmarshal(fileBytes, &writeSchema); err != nil {
return common.NewCLIArgParseError(fmt.Errorf("failed to parse JSON as DashboardWriteSchema: %v", err))
}

// Validate required fields
if writeSchema.Name == "" {
return common.NewCLIArgParseError(fmt.Errorf("dashboard name is required"))
}

// Create new dashboard
dashboard, resp, err := api.DashboardsApi.CreateDashboard(cli.Context).DashboardWriteSchema(writeSchema).Execute()
if err != nil {
return common.NewResponseError(err, resp)
}

if cli.IsJson() {
cli.Printer.PrintJson(map[string]interface{}{
"dashboard": dashboard,
})
} else {
cli.Printer.Success(fmt.Sprintf("Dashboard created successfully! ID: %d, Name: %s", dashboard.GetId(), dashboard.GetName()))
}

return nil
}

// updateDashboard patches an existing dashboard with new data
func updateDashboard(cli *di.Deps, api *stackstate_api.APIClient, dashboardId string, dashboardData map[string]interface{}) common.CLIError {
// Create patch schema from the JSON data
patchSchema := stackstate_api.NewDashboardPatchSchema()

if name, ok := dashboardData["name"].(string); ok && name != "" {
patchSchema.SetName(name)
}
if description, ok := dashboardData["description"].(string); ok {
patchSchema.SetDescription(description)
}
if scopeStr, ok := dashboardData["scope"].(string); ok {
if scope, err := stackstate_api.NewDashboardScopeFromValue(scopeStr); err == nil {
patchSchema.SetScope(*scope)
}
}
if dashboardContent, ok := dashboardData["dashboard"]; ok {
// Convert dashboard content to PersesDashboard
dashboardBytes, err := json.Marshal(dashboardContent)
if err == nil {
var persesDashboard stackstate_api.PersesDashboard
if err := json.Unmarshal(dashboardBytes, &persesDashboard); err == nil {
patchSchema.SetDashboard(persesDashboard)
}
}
}

// Update existing dashboard
dashboard, resp, err := api.DashboardsApi.PatchDashboard(cli.Context, dashboardId).DashboardPatchSchema(*patchSchema).Execute()
if err != nil {
return common.NewResponseError(err, resp)
}

if cli.IsJson() {
cli.Printer.PrintJson(map[string]interface{}{
"dashboard": dashboard,
})
} else {
cli.Printer.Success(fmt.Sprintf("Dashboard updated successfully! ID: %d, Name: %s", dashboard.GetId(), dashboard.GetName()))
}

return nil
}
Loading
Loading