From 64fe656101a63c070eb7a62c789e89f2a1069327 Mon Sep 17 00:00:00 2001 From: Remco Beckers Date: Fri, 28 Nov 2025 10:14:45 +0100 Subject: [PATCH] Use yaml for dashboard interactions instead of json More user friendly and matches the behavior we have for the `settings` command. --- .gitignore | 1 + cmd/dashboard/dashboard_apply.go | 32 ++++---- cmd/dashboard/dashboard_apply_test.go | 98 +++++++++++------------- cmd/dashboard/dashboard_describe.go | 8 +- cmd/dashboard/dashboard_describe_test.go | 12 +-- cmd/dashboard/dashboard_edit.go | 17 ++-- cmd/dashboard/dashboard_edit_test.go | 36 ++++----- 7 files changed, 97 insertions(+), 107 deletions(-) diff --git a/.gitignore b/.gitignore index 2fa60455..bec5dcfe 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ release-notes.md release-notes.json .localdev/ +stackstate-cli diff --git a/cmd/dashboard/dashboard_apply.go b/cmd/dashboard/dashboard_apply.go index 84b6b18d..76f684ed 100644 --- a/cmd/dashboard/dashboard_apply.go +++ b/cmd/dashboard/dashboard_apply.go @@ -1,7 +1,6 @@ package dashboard import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -11,6 +10,7 @@ import ( "github.com/stackvista/stackstate-cli/generated/stackstate_api" "github.com/stackvista/stackstate-cli/internal/common" "github.com/stackvista/stackstate-cli/internal/di" + "sigs.k8s.io/kustomize/kyaml/yaml" ) // ApplyArgs contains arguments for dashboard apply command @@ -22,12 +22,12 @@ 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.", + Short: "Create or edit a dashboard from YAML", + Long: "Create or edit a dashboard from YAML file.", RunE: cli.CmdRunEWithApi(RunDashboardApplyCommand(args)), } - common.AddRequiredFileFlagVar(cmd, &args.File, "Path to a .json file with the dashboard definition") + common.AddRequiredFileFlagVar(cmd, &args.File, "Path to a .yaml file with the dashboard definition") return cmd } @@ -41,26 +41,26 @@ func RunDashboardApplyCommand(args *ApplyArgs) di.CmdWithApiFn { // 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)) + if ext != ".yaml" { + return common.NewCLIArgParseError(fmt.Errorf("unsupported file type: %s. Only .yaml files are supported", ext)) } - return applyJSONDashboard(cli, api, fileBytes) + return applyYAMLDashboard(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 { +// applyYAMLDashboard processes JSON dashboard file and determines create vs update operation +func applyYAMLDashboard(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)) + if err := yaml.Unmarshal(fileBytes, &dashboardData); err != nil { + return common.NewCLIArgParseError(fmt.Errorf("failed to parse YAML: %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)) + dashboardId := fmt.Sprintf("%.0d", idField.(int)) return updateDashboard(cli, api, dashboardId, dashboardData) } else { // Create new dashboard @@ -71,8 +71,8 @@ func applyJSONDashboard(cli *di.Deps, api *stackstate_api.APIClient, 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)) + if err := yaml.Unmarshal(fileBytes, &writeSchema); err != nil { + return common.NewCLIArgParseError(fmt.Errorf("failed to parse YAML as DashboardWriteSchema: %v", err)) } // Validate required fields @@ -115,10 +115,10 @@ func updateDashboard(cli *di.Deps, api *stackstate_api.APIClient, dashboardId st } if dashboardContent, ok := dashboardData["dashboard"]; ok { // Convert dashboard content to PersesDashboard - dashboardBytes, err := json.Marshal(dashboardContent) + dashboardBytes, err := yaml.Marshal(dashboardContent) if err == nil { var persesDashboard stackstate_api.PersesDashboard - if err := json.Unmarshal(dashboardBytes, &persesDashboard); err == nil { + if err := yaml.Unmarshal(dashboardBytes, &persesDashboard); err == nil { patchSchema.SetDashboard(persesDashboard) } } diff --git a/cmd/dashboard/dashboard_apply_test.go b/cmd/dashboard/dashboard_apply_test.go index 7f558a06..3e9b3aaa 100644 --- a/cmd/dashboard/dashboard_apply_test.go +++ b/cmd/dashboard/dashboard_apply_test.go @@ -41,32 +41,25 @@ func createTestApplyResult() sts.DashboardReadFullSchema { func TestShouldApplyDashboardCreate(t *testing.T) { // Create a temporary file with dashboard JSON - file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.yaml") if err != nil { panic(err) } defer os.Remove(file.Name()) - dashboardJSON := `{ - "name": "applied-dashboard", - "description": "Dashboard created via apply", - "scope": "publicDashboard", - "dashboard": { - "spec": { - "layouts": [ - { - "kind": "Grid", - "spec": { - "items": [] - } - } - ], - "panels": {} - } - } - }` - - _, err = file.WriteString(dashboardJSON) + dashboardYAML := `name: applied-dashboard +description: Dashboard created via apply +scope: publicDashboard +dashboard: + spec: + layouts: + - kind: Grid + spec: + items: [] + panels: {} +` + + _, err = file.WriteString(dashboardYAML) assert.Nil(t, err) file.Close() @@ -86,20 +79,19 @@ func TestShouldApplyDashboardCreate(t *testing.T) { func TestShouldApplyDashboardUpdate(t *testing.T) { // Create a temporary file with dashboard update JSON (includes ID) - file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.yaml") if err != nil { panic(err) } defer os.Remove(file.Name()) - updateJSON := `{ - "id": 1234, - "name": "updated-dashboard", - "description": "Updated dashboard description", - "scope": "privateDashboard" - }` + updateYAML := `id: 1234 +name: updated-dashboard +description: Updated dashboard description +scope: privateDashboard +` - _, err = file.WriteString(updateJSON) + _, err = file.WriteString(updateYAML) assert.Nil(t, err) file.Close() @@ -125,25 +117,22 @@ func TestShouldApplyDashboardUpdate(t *testing.T) { } func TestShouldApplyDashboardWithJson(t *testing.T) { - file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.yaml") if err != nil { panic(err) } defer os.Remove(file.Name()) - dashboardJSON := `{ - "name": "json-output-dashboard", - "description": "Dashboard for JSON output test", - "scope": "publicDashboard", - "dashboard": { - "spec": { - "layouts": [], - "panels": {} - } - } - }` - - _, err = file.WriteString(dashboardJSON) + dashboardYAML := `name: yaml-output-dashboard +description: Dashboard for JSON output test +scope: publicDashboard +dashboard: + spec: + layouts: [] + panels: {} +` + + _, err = file.WriteString(dashboardYAML) assert.Nil(t, err) file.Close() @@ -174,28 +163,30 @@ func TestApplyDashboardInvalidFileType(t *testing.T) { _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", file.Name()) assert.NotNil(t, err) - assert.Contains(t, err.Error(), "unsupported file type: .txt. Only .json files are supported") + assert.Contains(t, err.Error(), "unsupported file type: .txt. Only .yaml files are supported") } func TestApplyDashboardMissingFile(t *testing.T) { cli, cmd := setDashboardApplyCmd(t) - _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", "/nonexistent/file.json") + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", "/nonexistent/file.yaml") assert.NotNil(t, err) assert.Contains(t, err.Error(), "cannot read file") } func TestApplyDashboardInvalidJSON(t *testing.T) { - file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.yaml") if err != nil { panic(err) } defer os.Remove(file.Name()) - invalidJSON := `{"name": "test", "invalid": json}` + invalidYAML := `name: test +invalid yaml +` - _, err = file.WriteString(invalidJSON) + _, err = file.WriteString(invalidYAML) assert.Nil(t, err) file.Close() @@ -204,20 +195,19 @@ func TestApplyDashboardInvalidJSON(t *testing.T) { _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", file.Name()) assert.NotNil(t, err) - assert.Contains(t, err.Error(), "failed to parse JSON") + assert.Contains(t, err.Error(), "failed to parse YAML") } func TestApplyDashboardMissingName(t *testing.T) { - file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.yaml") if err != nil { panic(err) } defer os.Remove(file.Name()) - invalidDashboard := `{ - "description": "Dashboard without name", - "scope": "publicDashboard" - }` + invalidDashboard := `description: Dashboard without name +scope: publicDashboard +` _, err = file.WriteString(invalidDashboard) assert.Nil(t, err) diff --git a/cmd/dashboard/dashboard_describe.go b/cmd/dashboard/dashboard_describe.go index 6a7f08bf..6664a778 100644 --- a/cmd/dashboard/dashboard_describe.go +++ b/cmd/dashboard/dashboard_describe.go @@ -1,7 +1,6 @@ package dashboard import ( - "encoding/json" "fmt" "github.com/spf13/cobra" @@ -10,6 +9,7 @@ import ( "github.com/stackvista/stackstate-cli/internal/common" "github.com/stackvista/stackstate-cli/internal/di" "github.com/stackvista/stackstate-cli/internal/util" + "sigs.k8s.io/kustomize/kyaml/yaml" ) type DescribeArgs struct { @@ -22,7 +22,7 @@ func DashboardDescribeCommand(cli *di.Deps) *cobra.Command { args := &DescribeArgs{} cmd := &cobra.Command{ Use: "describe", - Short: "Describe a dashboard in STY format", + Short: "Describe a dashboard in YAML format", Long: "Describe a dashboard in StackState Templated YAML.", RunE: cli.CmdRunEWithApi(RunDashboardDescribeCommand(args)), } @@ -48,11 +48,11 @@ func RunDashboardDescribeCommand(args *DescribeArgs) di.CmdWithApiFn { return common.NewResponseError(err, resp) } - jsonData, err := json.MarshalIndent(dashboard, "", " ") + yamlData, err := yaml.Marshal(dashboard) if err != nil { return common.NewExecutionError(fmt.Errorf("failed to marshal dashboard: %v", err)) } - data := string(jsonData) + data := string(yamlData) if args.FilePath != "" { if err := util.WriteFile(args.FilePath, []byte(data)); err != nil { diff --git a/cmd/dashboard/dashboard_describe_test.go b/cmd/dashboard/dashboard_describe_test.go index a4b4aaa2..c6de4ca7 100644 --- a/cmd/dashboard/dashboard_describe_test.go +++ b/cmd/dashboard/dashboard_describe_test.go @@ -48,12 +48,12 @@ func TestDashboardDescribe(t *testing.T) { _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1") assert.Nil(t, err) - // Verify that the command printed the dashboard JSON + // Verify that the command printed the dashboard YAML assert.Len(t, *cli.MockPrinter.PrintLnCalls, 1) printedOutput := (*cli.MockPrinter.PrintLnCalls)[0] - assert.Contains(t, printedOutput, `"id": 1`) - assert.Contains(t, printedOutput, `"name": "aDashboard"`) - assert.Contains(t, printedOutput, `"identifier": "urn:custom:dashboard:aDashboard"`) + assert.Contains(t, printedOutput, `id: 1`) + assert.Contains(t, printedOutput, `name: aDashboard`) + assert.Contains(t, printedOutput, `identifier: urn:custom:dashboard:aDashboard`) } func TestDashboardDescribeWithIdentifier(t *testing.T) { @@ -106,8 +106,8 @@ func TestDashboardDescribeToFile(t *testing.T) { // Verify file contents body, err := os.ReadFile(file.Name()) assert.Nil(t, err) - assert.Contains(t, string(body), `"id": 1`) - assert.Contains(t, string(body), `"name": "aDashboard"`) + assert.Contains(t, string(body), `id: 1`) + assert.Contains(t, string(body), `name: aDashboard`) } func TestDashboardDescribeToFileJson(t *testing.T) { diff --git a/cmd/dashboard/dashboard_edit.go b/cmd/dashboard/dashboard_edit.go index 6c4b94aa..bf1a000b 100644 --- a/cmd/dashboard/dashboard_edit.go +++ b/cmd/dashboard/dashboard_edit.go @@ -10,6 +10,7 @@ import ( stscobra "github.com/stackvista/stackstate-cli/internal/cobra" "github.com/stackvista/stackstate-cli/internal/common" "github.com/stackvista/stackstate-cli/internal/di" + "sigs.k8s.io/kustomize/kyaml/yaml" ) const LongDescription = `Edit a dashboard. @@ -60,19 +61,19 @@ func RunDashboardEditCommand(args *EditArgs) di.CmdWithApiFn { } // Convert dashboard to pretty JSON for editing - originalJSON, err := json.MarshalIndent(dashboard, "", " ") + originalYAML, err := yaml.Marshal(dashboard) if err != nil { - return common.NewExecutionError(fmt.Errorf("failed to marshal dashboard to JSON: %v", err)) + return common.NewExecutionError(fmt.Errorf("failed to marshal dashboard to YAML: %v", err)) } // Open editor with the dashboard JSON - editedContent, err := cli.Editor.Edit("dashboard-", ".json", strings.NewReader(string(originalJSON))) + editedContent, err := cli.Editor.Edit("dashboard-", ".yaml", strings.NewReader(string(originalYAML))) if err != nil { return common.NewExecutionError(fmt.Errorf("failed to open editor: %v", err)) } // Check if any changes were made - if strings.Compare(string(originalJSON), string(editedContent)) == 0 { + if strings.Compare(string(originalYAML), string(editedContent)) == 0 { if cli.IsJson() { cli.Printer.PrintJson(map[string]interface{}{"message": "No changes made"}) } else { @@ -81,13 +82,13 @@ func RunDashboardEditCommand(args *EditArgs) di.CmdWithApiFn { return nil } - // Parse the edited JSON + // Parse the edited YAML var editedDashboard map[string]interface{} - if err := json.Unmarshal(editedContent, &editedDashboard); err != nil { - return common.NewExecutionError(fmt.Errorf("failed to parse edited JSON: %v", err)) + if err := yaml.Unmarshal(editedContent, &editedDashboard); err != nil { + return common.NewExecutionError(fmt.Errorf("failed to parse edited YAML: %v", err)) } - // Create patch schema from the edited JSON + // Create patch schema from the edited YAML patchSchema := stackstate_api.NewDashboardPatchSchema() if name, ok := editedDashboard["name"].(string); ok && name != "" { diff --git a/cmd/dashboard/dashboard_edit_test.go b/cmd/dashboard/dashboard_edit_test.go index f5d78810..eac581cf 100644 --- a/cmd/dashboard/dashboard_edit_test.go +++ b/cmd/dashboard/dashboard_edit_test.go @@ -87,14 +87,13 @@ func TestShouldEditDashboard(t *testing.T) { // Replace the ReverseEditor with a MockEditor that returns edited content mockEditor := &MockEditor{ - Content: []byte(`{ - "_type": "DashboardReadFullSchema", - "id": 1234, - "name": "edited-dashboard-name", - "identifier": "urn:custom:dashboard:edit-test", - "description": "Updated description", - "scope": "privateDashboard" - }`), + Content: []byte(`_type: DashboardReadFullSchema +id: 1234 +name: edited-dashboard-name +identifier: urn:custom:dashboard:edit-test +description: Updated description +scope: privateDashboard +`), } cli.Editor = mockEditor @@ -131,10 +130,9 @@ func TestShouldEditDashboardWithIdentifier(t *testing.T) { // Replace editor with mock that returns changes mockEditor := &MockEditor{ - Content: []byte(`{ - "name": "edited-with-identifier", - "description": "Updated via identifier" - }`), + Content: []byte(`name: edited-with-identifier +description: Updated via identifier +`), } cli.Editor = mockEditor @@ -181,10 +179,9 @@ func TestEditDashboardWithJsonOutput(t *testing.T) { cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardResponse.Result = updatedDashboard mockEditor := &MockEditor{ - Content: []byte(`{ - "name": "json-output-test", - "description": "Testing JSON output" - }`), + Content: []byte(`name: json-output-test +description: Testing JSON output +`), } cli.Editor = mockEditor @@ -221,14 +218,14 @@ func TestEditDashboardInvalidJson(t *testing.T) { cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard mockEditor := &MockEditor{ - Content: []byte(`{"invalid": json syntax}`), + Content: []byte(`invalid yaml syntax`), } cli.Editor = mockEditor _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1234") assert.NotNil(t, err) - assert.Contains(t, err.Error(), "failed to parse edited JSON") + assert.Contains(t, err.Error(), "failed to parse edited YAML") // Verify no patch call was made assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls, 0) @@ -273,7 +270,8 @@ func TestEditDashboardUsesReverseEditorByDefault(t *testing.T) { // Use a MockEditor that makes a simple change instead of ReverseEditor // ReverseEditor produces invalid JSON mockEditor := &MockEditor{ - Content: []byte(`{"name": "changed-by-reverse-editor"}`), + Content: []byte(`name: changed-by-reverse-editor +`), } cli.Editor = mockEditor