Skip to content

Commit 1d1d0b3

Browse files
authored
STAC-22568: Adding dashboard command (#109)
1 parent 3034723 commit 1d1d0b3

18 files changed

Lines changed: 2036 additions & 0 deletions

cmd/dashboard.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
"github.com/stackvista/stackstate-cli/cmd/dashboard"
6+
"github.com/stackvista/stackstate-cli/internal/di"
7+
)
8+
9+
func DashboardCommand(cli *di.Deps) *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "dashboard",
12+
Short: "Manage dashboards",
13+
Long: "Manage, test and develop dashboards.",
14+
}
15+
cmd.AddCommand(dashboard.DashboardListCommand(cli))
16+
cmd.AddCommand(dashboard.DashboardDescribeCommand(cli))
17+
cmd.AddCommand(dashboard.DashboardCloneCommand(cli))
18+
cmd.AddCommand(dashboard.DashboardDeleteCommand(cli))
19+
cmd.AddCommand(dashboard.DashboardApplyCommand(cli))
20+
cmd.AddCommand(dashboard.DashboardEditCommand(cli))
21+
22+
return cmd
23+
}

cmd/dashboard/common.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package dashboard
2+
3+
import (
4+
"fmt"
5+
)
6+
7+
// ResolveDashboardIdOrUrn resolves ID or identifier to a string that can be used with the API
8+
// Returns the resolved identifier string or an error if neither is provided
9+
func ResolveDashboardIdOrUrn(id int64, identifier string) (string, error) {
10+
switch {
11+
case id != 0:
12+
return fmt.Sprintf("%d", id), nil
13+
case identifier != "":
14+
return identifier, nil
15+
default:
16+
return "", fmt.Errorf("either --id or --identifier must be provided")
17+
}
18+
}

cmd/dashboard/common_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package dashboard
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestResolveDashboardIdOrUrn(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
id int64
13+
identifier string
14+
expectedResult string
15+
expectedError string
16+
}{
17+
{
18+
name: "Should resolve valid ID",
19+
id: 123,
20+
identifier: "",
21+
expectedResult: "123",
22+
expectedError: "",
23+
},
24+
{
25+
name: "Should resolve valid identifier",
26+
id: 0,
27+
identifier: "urn:custom:dashboard:test",
28+
expectedResult: "urn:custom:dashboard:test",
29+
expectedError: "",
30+
},
31+
{
32+
name: "Should prioritize ID when both are provided",
33+
id: 456,
34+
identifier: "urn:custom:dashboard:test",
35+
expectedResult: "456",
36+
expectedError: "",
37+
},
38+
{
39+
name: "Should return error when neither is provided",
40+
id: 0,
41+
identifier: "",
42+
expectedResult: "",
43+
expectedError: "either --id or --identifier must be provided",
44+
},
45+
{
46+
name: "Should resolve large ID",
47+
id: 9223372036854775807, // max int64
48+
identifier: "",
49+
expectedResult: "9223372036854775807",
50+
expectedError: "",
51+
},
52+
{
53+
name: "Should resolve complex URN identifier",
54+
id: 0,
55+
identifier: "urn:stackpack:kubernetes:dashboard:cluster-overview",
56+
expectedResult: "urn:stackpack:kubernetes:dashboard:cluster-overview",
57+
expectedError: "",
58+
},
59+
{
60+
name: "Should resolve identifier with special characters",
61+
id: 0,
62+
identifier: "urn:custom:dashboard:test-name_with.special-chars",
63+
expectedResult: "urn:custom:dashboard:test-name_with.special-chars",
64+
expectedError: "",
65+
},
66+
{
67+
name: "Should handle empty string identifier as not provided",
68+
id: 0,
69+
identifier: "",
70+
expectedResult: "",
71+
expectedError: "either --id or --identifier must be provided",
72+
},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
result, err := ResolveDashboardIdOrUrn(tt.id, tt.identifier)
78+
79+
assert.Equal(t, tt.expectedResult, result)
80+
81+
if tt.expectedError != "" {
82+
assert.NotNil(t, err)
83+
assert.Contains(t, err.Error(), tt.expectedError)
84+
} else {
85+
assert.Nil(t, err)
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestResolveDashboardIdOrUrnPriority(t *testing.T) {
92+
// Test that ID takes priority over identifier when both are provided
93+
result, err := ResolveDashboardIdOrUrn(999, "urn:custom:dashboard:ignored")
94+
95+
assert.Nil(t, err)
96+
assert.Equal(t, "999", result)
97+
}
98+
99+
func TestResolveDashboardIdOrUrnEdgeCases(t *testing.T) {
100+
// Test negative ID (should still work as it's non-zero)
101+
result, err := ResolveDashboardIdOrUrn(-1, "")
102+
assert.Nil(t, err)
103+
assert.Equal(t, "-1", result)
104+
105+
// Test with whitespace-only identifier (treated as empty)
106+
result, err = ResolveDashboardIdOrUrn(0, " ")
107+
assert.Nil(t, err)
108+
assert.Equal(t, " ", result) // The function doesn't trim whitespace
109+
}

cmd/dashboard/dashboard_apply.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package dashboard
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/spf13/cobra"
11+
"github.com/stackvista/stackstate-cli/generated/stackstate_api"
12+
"github.com/stackvista/stackstate-cli/internal/common"
13+
"github.com/stackvista/stackstate-cli/internal/di"
14+
)
15+
16+
// ApplyArgs contains arguments for dashboard apply command
17+
type ApplyArgs struct {
18+
File string
19+
}
20+
21+
func DashboardApplyCommand(cli *di.Deps) *cobra.Command {
22+
args := &ApplyArgs{}
23+
cmd := &cobra.Command{
24+
Use: "apply",
25+
Short: "Create or edit a dashboard from JSON",
26+
Long: "Create or edit a dashboard from JSON file.",
27+
RunE: cli.CmdRunEWithApi(RunDashboardApplyCommand(args)),
28+
}
29+
30+
common.AddRequiredFileFlagVar(cmd, &args.File, "Path to a .json file with the dashboard definition")
31+
32+
return cmd
33+
}
34+
35+
func RunDashboardApplyCommand(args *ApplyArgs) di.CmdWithApiFn {
36+
return func(cmd *cobra.Command, cli *di.Deps, api *stackstate_api.APIClient, serverInfo *stackstate_api.ServerInfo) common.CLIError {
37+
fileBytes, err := os.ReadFile(args.File)
38+
if err != nil {
39+
return common.NewReadFileError(err, args.File)
40+
}
41+
42+
// Determine file type by extension
43+
ext := strings.ToLower(filepath.Ext(args.File))
44+
if ext != ".json" {
45+
return common.NewCLIArgParseError(fmt.Errorf("unsupported file type: %s. Only .json files are supported", ext))
46+
}
47+
48+
return applyJSONDashboard(cli, api, fileBytes)
49+
}
50+
}
51+
52+
// applyJSONDashboard processes JSON dashboard file and determines create vs update operation
53+
func applyJSONDashboard(cli *di.Deps, api *stackstate_api.APIClient, fileBytes []byte) common.CLIError {
54+
// Parse the JSON to determine if it's a create or update operation
55+
var dashboardData map[string]interface{}
56+
if err := json.Unmarshal(fileBytes, &dashboardData); err != nil {
57+
return common.NewCLIArgParseError(fmt.Errorf("failed to parse JSON: %v", err))
58+
}
59+
60+
// Check if it has an ID field (indicates update operation)
61+
if idField, hasId := dashboardData["id"]; hasId {
62+
// Update existing dashboard
63+
dashboardId := fmt.Sprintf("%.0f", idField.(float64))
64+
return updateDashboard(cli, api, dashboardId, dashboardData)
65+
} else {
66+
// Create new dashboard
67+
return createDashboard(cli, api, fileBytes)
68+
}
69+
}
70+
71+
// createDashboard creates a new dashboard from JSON schema
72+
func createDashboard(cli *di.Deps, api *stackstate_api.APIClient, fileBytes []byte) common.CLIError {
73+
var writeSchema stackstate_api.DashboardWriteSchema
74+
if err := json.Unmarshal(fileBytes, &writeSchema); err != nil {
75+
return common.NewCLIArgParseError(fmt.Errorf("failed to parse JSON as DashboardWriteSchema: %v", err))
76+
}
77+
78+
// Validate required fields
79+
if writeSchema.Name == "" {
80+
return common.NewCLIArgParseError(fmt.Errorf("dashboard name is required"))
81+
}
82+
83+
// Create new dashboard
84+
dashboard, resp, err := api.DashboardsApi.CreateDashboard(cli.Context).DashboardWriteSchema(writeSchema).Execute()
85+
if err != nil {
86+
return common.NewResponseError(err, resp)
87+
}
88+
89+
if cli.IsJson() {
90+
cli.Printer.PrintJson(map[string]interface{}{
91+
"dashboard": dashboard,
92+
})
93+
} else {
94+
cli.Printer.Success(fmt.Sprintf("Dashboard created successfully! ID: %d, Name: %s", dashboard.GetId(), dashboard.GetName()))
95+
}
96+
97+
return nil
98+
}
99+
100+
// updateDashboard patches an existing dashboard with new data
101+
func updateDashboard(cli *di.Deps, api *stackstate_api.APIClient, dashboardId string, dashboardData map[string]interface{}) common.CLIError {
102+
// Create patch schema from the JSON data
103+
patchSchema := stackstate_api.NewDashboardPatchSchema()
104+
105+
if name, ok := dashboardData["name"].(string); ok && name != "" {
106+
patchSchema.SetName(name)
107+
}
108+
if description, ok := dashboardData["description"].(string); ok {
109+
patchSchema.SetDescription(description)
110+
}
111+
if scopeStr, ok := dashboardData["scope"].(string); ok {
112+
if scope, err := stackstate_api.NewDashboardScopeFromValue(scopeStr); err == nil {
113+
patchSchema.SetScope(*scope)
114+
}
115+
}
116+
if dashboardContent, ok := dashboardData["dashboard"]; ok {
117+
// Convert dashboard content to PersesDashboard
118+
dashboardBytes, err := json.Marshal(dashboardContent)
119+
if err == nil {
120+
var persesDashboard stackstate_api.PersesDashboard
121+
if err := json.Unmarshal(dashboardBytes, &persesDashboard); err == nil {
122+
patchSchema.SetDashboard(persesDashboard)
123+
}
124+
}
125+
}
126+
127+
// Update existing dashboard
128+
dashboard, resp, err := api.DashboardsApi.PatchDashboard(cli.Context, dashboardId).DashboardPatchSchema(*patchSchema).Execute()
129+
if err != nil {
130+
return common.NewResponseError(err, resp)
131+
}
132+
133+
if cli.IsJson() {
134+
cli.Printer.PrintJson(map[string]interface{}{
135+
"dashboard": dashboard,
136+
})
137+
} else {
138+
cli.Printer.Success(fmt.Sprintf("Dashboard updated successfully! ID: %d, Name: %s", dashboard.GetId(), dashboard.GetName()))
139+
}
140+
141+
return nil
142+
}

0 commit comments

Comments
 (0)