Skip to content

Commit a0e9da3

Browse files
committed
STAC-22568: Adding dashboard command
1 parent 3034723 commit a0e9da3

18 files changed

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

0 commit comments

Comments
 (0)