From 557e72118bf86d01ce3e283bcd4875ab3de71e2b Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Fri, 6 Mar 2026 06:33:10 +0100 Subject: [PATCH] feat: output full asset definitions for list -o yaml/json The list commands for all asset types (dashboards, check-rules, views, synthetic-checks) now fetch and output full asset definitions instead of summary list items when using -o yaml or -o json. YAML output is a multi-document stream suitable for piping to `dash0 apply -f -`. A progress bar is shown on stderr when fetching more than 10 definitions. Closes #67 --- .chloggen/worktree-export.yaml | 29 +++++++ docs/commands.md | 9 ++- go.mod | 7 +- go.sum | 6 +- internal/checkrules/get.go | 7 +- internal/checkrules/integration_test.go | 82 +++++++++++++++++++ internal/checkrules/list.go | 41 +++++++++- internal/color/severity.go | 4 +- internal/color/span_status_code.go | 4 +- internal/dashboards/get.go | 11 +-- internal/dashboards/integration_test.go | 49 ++++++++++- internal/dashboards/list.go | 41 +++++++++- internal/output/formatter.go | 20 +++++ internal/output/formatter_test.go | 47 +++++++++++ internal/output/progress.go | 72 +++++++++++++++++ internal/syntheticchecks/get.go | 10 +-- internal/syntheticchecks/integration_test.go | 85 ++++++++++++++++++++ internal/syntheticchecks/list.go | 44 +++++++++- internal/views/get.go | 10 +-- internal/views/integration_test.go | 85 ++++++++++++++++++++ internal/views/list.go | 44 +++++++++- 21 files changed, 652 insertions(+), 55 deletions(-) create mode 100644 .chloggen/worktree-export.yaml create mode 100644 internal/checkrules/integration_test.go create mode 100644 internal/output/progress.go create mode 100644 internal/syntheticchecks/integration_test.go create mode 100644 internal/views/integration_test.go diff --git a/.chloggen/worktree-export.yaml b/.chloggen/worktree-export.yaml new file mode 100644 index 0000000..5eb5e35 --- /dev/null +++ b/.chloggen/worktree-export.yaml @@ -0,0 +1,29 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern (e.g. dashboards, config, apply) +component: dashboards + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "` list -o yaml` and `-o json` now output full asset definitions instead of summary list items" + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [67] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: | + YAML output is a multi-document stream (separated by `---`) that can be piped directly to `dash0 apply -f -`. + JSON output is an array of full asset definitions. + This applies to all four asset types: dashboards, check-rules, views, and synthetic-checks. + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with "chore" or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Default: '[user]' +change_logs: [] diff --git a/docs/commands.md b/docs/commands.md index d39b0f2..4918b9e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -203,7 +203,8 @@ NAME ID DATASET ORIGIN Production Overview a1b2c3d4-5678-90ab-cdef-1234567890ab default gitops/prod https://app.dash0.com/goto/dashboards?dashboard_id=a1b2c3d4-... ``` -Use `-o json` or `-o yaml` to get the full asset payload, suitable for piping or saving to a file. +Use `-o json` or `-o yaml` to get the full asset definitions, suitable for backup or re-applying with `apply -f -`. +The YAML output is a multi-document stream (documents separated by `---`) so it can be piped directly to `dash0 apply -f -`. Use `-o csv` for a pipe-friendly, machine-readable format with the same columns as `wide`: ```bash @@ -1174,8 +1175,14 @@ dash0 apply -f dashboard.yaml ### Bulk export all assets of one type +The YAML output contains full asset definitions as a multi-document stream, ready to be re-applied: + ```bash +# Export all dashboards dash0 dashboards list -o yaml > all-dashboards.yaml + +# Re-apply them later +dash0 apply -f all-dashboards.yaml ``` ### Send a deployment event diff --git a/go.mod b/go.mod index 9091749..5bc1a24 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,11 @@ require ( github.com/dash0hq/dash0-api-client-go v1.5.1 github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 - github.com/mattn/go-isatty v0.0.20 + github.com/pmezard/go-difflib v1.0.0 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/collector/pdata v1.51.0 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 sigs.k8s.io/yaml v1.6.0 ) @@ -22,14 +23,14 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/oapi-codegen/runtime v1.1.2 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opentelemetry.io/collector/featuregate v1.51.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index e9b9530..f08adc4 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,10 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/checkrules/get.go b/internal/checkrules/get.go index 9912709..fb9fe64 100644 --- a/internal/checkrules/get.go +++ b/internal/checkrules/get.go @@ -52,12 +52,7 @@ func runGet(ctx context.Context, id string, flags *asset.GetFlags) error { }) } - // The API does not return the rule ID in the response body. Restore it - // so that exported YAML can be re-applied (the import API uses the ID - // for upsert). - if rule.Id == nil { - rule.Id = &id - } + enrichCheckRule(rule, id) format, err := output.ParseFormat(flags.Output) if err != nil { diff --git a/internal/checkrules/integration_test.go b/internal/checkrules/integration_test.go new file mode 100644 index 0000000..7937f54 --- /dev/null +++ b/internal/checkrules/integration_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package checkrules + +import ( + "net/http" + "regexp" + "testing" + + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + apiPathCheckRules = "/api/alerting/check-rules" + fixtureListSuccess = "checkrules/list_success.json" + fixtureListEmpty = "checkrules/list_empty.json" + fixtureGetSuccess = "checkrules/get_success.json" + fixtureUnauthorized = "dashboards/error_unauthorized.json" +) + +var checkRuleIDPattern = regexp.MustCompile(`^/api/alerting/check-rules/[a-f0-9-]+$`) + +func TestListCheckRules_JSONFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewCheckRulesCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "2"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // JSON output should contain full check rule definitions + assert.Contains(t, output, `"name"`) + assert.Contains(t, output, `"expression"`) + assert.Contains(t, output, `"Failing check rule 2"`) +} + +func TestListCheckRules_YAMLFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewCheckRulesCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "yaml", "--limit", "2"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // YAML output should contain full check rule definitions + assert.Contains(t, output, "name: Failing check rule 2") + assert.Contains(t, output, "expression:") +} diff --git a/internal/checkrules/list.go b/internal/checkrules/list.go index 9945685..374415f 100644 --- a/internal/checkrules/list.go +++ b/internal/checkrules/list.go @@ -55,7 +55,8 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { return err } - iter := apiClient.ListCheckRulesIter(ctx, client.ResolveDataset(ctx, flags.Dataset)) + dataset := client.ResolveDataset(ctx, flags.Dataset) + iter := apiClient.ListCheckRulesIter(ctx, dataset) var rules []*dash0api.PrometheusAlertRuleApiListItem count := 0 @@ -82,12 +83,48 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { switch format { case output.FormatJSON, output.FormatYAML: - return formatter.Print(rules) + definitions, err := fetchFullCheckRules(ctx, apiClient, rules, dataset) + if err != nil { + return err + } + if format == output.FormatYAML { + return formatter.PrintMultiDocYAML(definitions) + } + return formatter.PrintJSON(definitions) default: return printCheckRuleTable(formatter, rules, format, apiUrl) } } +func fetchFullCheckRules( + ctx context.Context, + apiClient dash0api.Client, + rules []*dash0api.PrometheusAlertRuleApiListItem, + dataset *string, +) ([]interface{}, error) { + progress := output.NewProgress("check rules", len(rules)) + defer progress.Done() + definitions := make([]interface{}, 0, len(rules)) + for i, item := range rules { + progress.Update(i + 1) + rule, err := apiClient.GetCheckRule(ctx, item.Id, dataset) + if err != nil { + return nil, fmt.Errorf("failed to fetch check rule %q: %w", item.Id, err) + } + enrichCheckRule(rule, item.Id) + definitions = append(definitions, rule) + } + return definitions, nil +} + +// enrichCheckRule restores the rule ID so that exported YAML can be re-applied +// (the API does not return the rule ID in the response body). +func enrichCheckRule(rule *dash0api.PrometheusAlertRule, id string) { + if rule.Id == nil { + rule.Id = &id + } +} + func printCheckRuleTable(f *output.Formatter, rules []*dash0api.PrometheusAlertRuleApiListItem, format output.Format, apiUrl string) error { columns := []output.Column{ {Header: internal.HEADER_NAME, Width: 40, Value: func(item interface{}) string { diff --git a/internal/color/severity.go b/internal/color/severity.go index e836dab..0ece37e 100644 --- a/internal/color/severity.go +++ b/internal/color/severity.go @@ -6,7 +6,7 @@ import ( "github.com/dash0hq/dash0-cli/internal/otlp" "github.com/fatih/color" - "github.com/mattn/go-isatty" + "golang.org/x/term" ) var ( @@ -21,7 +21,7 @@ var ( // When color is disabled (via color.NoColor) or stdout is not a TTY, the // severity is returned as plain left-padded text. func SprintSeverity(sev string, width int) string { - if color.NoColor || !isatty.IsTerminal(os.Stdout.Fd()) { + if color.NoColor || !term.IsTerminal(int(os.Stdout.Fd())) { if width > 0 { return fmt.Sprintf("%-*s", width, sev) } diff --git a/internal/color/span_status_code.go b/internal/color/span_status_code.go index b5b5ac1..d36b93d 100644 --- a/internal/color/span_status_code.go +++ b/internal/color/span_status_code.go @@ -5,14 +5,14 @@ import ( "os" "github.com/fatih/color" - "github.com/mattn/go-isatty" + "golang.org/x/term" ) // SprintSpanStatus returns the span status string color-coded and padded to // width visible characters for terminal output. When width is 0, no padding // is applied. func SprintSpanStatus(status string, width int) string { - if color.NoColor || !isatty.IsTerminal(os.Stdout.Fd()) { + if color.NoColor || !term.IsTerminal(int(os.Stdout.Fd())) { if width > 0 { return fmt.Sprintf("%-*s", width, status) } diff --git a/internal/dashboards/get.go b/internal/dashboards/get.go index 45dc781..87096d6 100644 --- a/internal/dashboards/get.go +++ b/internal/dashboards/get.go @@ -5,7 +5,6 @@ import ( "fmt" "os" - dash0api "github.com/dash0hq/dash0-api-client-go" "github.com/dash0hq/dash0-cli/internal" "github.com/dash0hq/dash0-cli/internal/asset" "github.com/dash0hq/dash0-cli/internal/client" @@ -53,15 +52,7 @@ func runGet(ctx context.Context, id string, flags *asset.GetFlags) error { }) } - // The API does not persist dash0Extensions.id for dashboards. Restore the - // ID we used for lookup so that exported YAML can be re-applied (the import - // API uses dash0Extensions.id as the upsert key). - if dashboard.Metadata.Dash0Extensions == nil { - dashboard.Metadata.Dash0Extensions = &dash0api.DashboardMetadataExtensions{} - } - if dashboard.Metadata.Dash0Extensions.Id == nil { - dashboard.Metadata.Dash0Extensions.Id = &id - } + enrichDashboard(dashboard, id) // Format output format, err := output.ParseFormat(flags.Output) diff --git a/internal/dashboards/integration_test.go b/internal/dashboards/integration_test.go index 576b634..6750f23 100644 --- a/internal/dashboards/integration_test.go +++ b/internal/dashboards/integration_test.go @@ -201,9 +201,15 @@ func TestListDashboards_JSONFormat(t *testing.T) { BodyFile: fixtureListSuccess, Validator: testutil.RequireHeaders, }) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) cmd := NewDashboardsCmd() - cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json"}) + // Use --limit 2 to keep the output small enough for the CaptureStdout pipe. + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "2"}) var err error output := testutil.CaptureStdout(t, func() { @@ -211,9 +217,44 @@ func TestListDashboards_JSONFormat(t *testing.T) { }) require.NoError(t, err) - // JSON format should be valid JSON with dashboard data - assert.Contains(t, output, `"id"`) - assert.Contains(t, output, `"0c3893ac-3d26-11ef-943e-eedf0419e619"`) + // JSON output should contain full dashboard definitions + assert.Contains(t, output, `"kind": "Dashboard"`) + assert.Contains(t, output, `"metadata"`) + assert.Contains(t, output, `"spec"`) +} + +func TestListDashboards_YAMLFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathDashboards, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewDashboardsCmd() + // Use --limit 2 to keep the output small enough for the CaptureStdout pipe + // while still verifying multi-document output. + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "yaml", "--limit", "2"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // YAML output should contain full dashboard definitions as multi-document YAML + assert.Contains(t, output, "kind: Dashboard") + assert.Contains(t, output, "metadata:") + assert.Contains(t, output, "spec:") + // Multiple documents should be separated by --- + assert.Contains(t, output, "---") } func TestGetDashboard_Success(t *testing.T) { diff --git a/internal/dashboards/list.go b/internal/dashboards/list.go index 554ae90..b187a42 100644 --- a/internal/dashboards/list.go +++ b/internal/dashboards/list.go @@ -97,7 +97,14 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { switch format { case output.FormatJSON, output.FormatYAML: - return formatter.Print(listItems) + definitions, err := fetchFullDashboards(ctx, apiClient, listItems, dataset) + if err != nil { + return err + } + if format == output.FormatYAML { + return formatter.PrintMultiDocYAML(definitions) + } + return formatter.PrintJSON(definitions) default: // Fetch full dashboard details to get display names dashboards := make([]dashboardListItem, 0, len(listItems)) @@ -114,6 +121,38 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { } } +func fetchFullDashboards( + ctx context.Context, + apiClient dash0api.Client, + listItems []*dash0api.DashboardApiListItem, + dataset *string, +) ([]interface{}, error) { + progress := output.NewProgress("dashboards", len(listItems)) + defer progress.Done() + definitions := make([]interface{}, 0, len(listItems)) + for i, item := range listItems { + progress.Update(i + 1) + dashboard, err := apiClient.GetDashboard(ctx, item.Id, dataset) + if err != nil { + return nil, fmt.Errorf("failed to fetch dashboard %q: %w", item.Id, err) + } + enrichDashboard(dashboard, item.Id) + definitions = append(definitions, dashboard) + } + return definitions, nil +} + +// enrichDashboard restores the dashboard ID in dash0Extensions so that exported +// YAML can be re-applied (the API does not persist dash0Extensions.id). +func enrichDashboard(dashboard *dash0api.DashboardDefinition, id string) { + if dashboard.Metadata.Dash0Extensions == nil { + dashboard.Metadata.Dash0Extensions = &dash0api.DashboardMetadataExtensions{} + } + if dashboard.Metadata.Dash0Extensions.Id == nil { + dashboard.Metadata.Dash0Extensions.Id = &id + } +} + // getDisplayName fetches the full dashboard and extracts spec.display.name func getDisplayName(ctx context.Context, apiClient dash0api.Client, id string, dataset *string) string { dashboard, err := apiClient.GetDashboard(ctx, id, dataset) diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 7b316c3..c79e734 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -91,6 +91,26 @@ func (f *Formatter) PrintYAML(data interface{}) error { return err } +// PrintMultiDocYAML outputs each item as a separate YAML document separated +// by "---". This produces a YAML stream that can be fed back to `apply -f -`. +func (f *Formatter) PrintMultiDocYAML(items []interface{}) error { + for i, item := range items { + if i > 0 { + if _, err := fmt.Fprintln(f.writer, "---"); err != nil { + return err + } + } + out, err := yaml.Marshal(item) + if err != nil { + return err + } + if _, err := f.writer.Write(out); err != nil { + return err + } + } + return nil +} + // Print outputs data in the configured format (JSON or YAML only) // For table format, use the type-specific table printing functions func (f *Formatter) Print(data interface{}) error { diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go index bfacc5e..0533516 100644 --- a/internal/output/formatter_test.go +++ b/internal/output/formatter_test.go @@ -67,6 +67,53 @@ func TestFormatter_PrintYAML(t *testing.T) { assert.Contains(t, buf.String(), "id: \"123\"") } +func TestFormatter_PrintMultiDocYAML(t *testing.T) { + var buf bytes.Buffer + f := NewFormatter(FormatYAML, &buf) + + items := []interface{}{ + map[string]string{"name": "first", "id": "1"}, + map[string]string{"name": "second", "id": "2"}, + } + err := f.PrintMultiDocYAML(items) + assert.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "name: first") + assert.Contains(t, output, "---") + assert.Contains(t, output, "name: second") + + // Verify the documents are separated by --- + docs := strings.Split(output, "---\n") + assert.Len(t, docs, 2) + assert.Contains(t, docs[0], "name: first") + assert.Contains(t, docs[1], "name: second") +} + +func TestFormatter_PrintMultiDocYAML_SingleItem(t *testing.T) { + var buf bytes.Buffer + f := NewFormatter(FormatYAML, &buf) + + items := []interface{}{ + map[string]string{"name": "only"}, + } + err := f.PrintMultiDocYAML(items) + assert.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "name: only") + assert.NotContains(t, output, "---") +} + +func TestFormatter_PrintMultiDocYAML_Empty(t *testing.T) { + var buf bytes.Buffer + f := NewFormatter(FormatYAML, &buf) + + err := f.PrintMultiDocYAML([]interface{}{}) + assert.NoError(t, err) + assert.Empty(t, buf.String()) +} + func TestFormatter_Print(t *testing.T) { data := map[string]string{"name": "test"} diff --git a/internal/output/progress.go b/internal/output/progress.go new file mode 100644 index 0000000..85bf33c --- /dev/null +++ b/internal/output/progress.go @@ -0,0 +1,72 @@ +package output + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +const ( + progressThreshold = 10 + labelWidth = 30 + // pctWidth covers " 100%" at the end of the line. + pctWidth = 5 + minBarWidth = 10 + fallbackWidth = 80 +) + +// Progress displays a progress bar on stderr when the total exceeds the +// threshold and stderr is a terminal. The bar adapts to the terminal width. +// +// Layout: +// +// Fetching 47 dashboards ################ 45% +// |--- label (30 chars) ------->|--- bar (adapts to terminal) -------->|pct| +type Progress struct { + assetType string + total int + active bool + lineWidth int +} + +// NewProgress creates a progress indicator. It only activates when total +// exceeds the threshold and stderr is a TTY. +func NewProgress(assetType string, total int) *Progress { + fd := int(os.Stderr.Fd()) + active := total > progressThreshold && term.IsTerminal(fd) + width := fallbackWidth + if active { + if w, _, err := term.GetSize(fd); err == nil && w > 0 { + width = w + } + } + return &Progress{ + assetType: assetType, + total: total, + active: active, + lineWidth: width, + } +} + +// Update prints the current progress. Call this after each item is fetched. +func (p *Progress) Update(current int) { + if !p.active { + return + } + barWidth := max(p.lineWidth-labelWidth-pctWidth, minBarWidth) + pct := current * 100 / p.total + filled := pct * barWidth / 100 + bar := strings.Repeat("#", filled) + strings.Repeat(" ", barWidth-filled) + label := fmt.Sprintf("Fetching %d %s", p.total, p.assetType) + fmt.Fprintf(os.Stderr, "\r%-*s%s %3d%%", labelWidth, label, bar, pct) +} + +// Done clears the progress line. +func (p *Progress) Done() { + if !p.active { + return + } + fmt.Fprintf(os.Stderr, "\r%s\r", strings.Repeat(" ", p.lineWidth)) +} diff --git a/internal/syntheticchecks/get.go b/internal/syntheticchecks/get.go index 0b27814..74819b4 100644 --- a/internal/syntheticchecks/get.go +++ b/internal/syntheticchecks/get.go @@ -5,7 +5,6 @@ import ( "fmt" "os" - dash0api "github.com/dash0hq/dash0-api-client-go" "github.com/dash0hq/dash0-cli/internal" "github.com/dash0hq/dash0-cli/internal/asset" "github.com/dash0hq/dash0-cli/internal/client" @@ -53,14 +52,7 @@ func runGet(ctx context.Context, id string, flags *asset.GetFlags) error { }) } - // Restore the ID so that exported YAML can be re-applied (the import - // API uses the ID for upsert). - if check.Metadata.Labels == nil { - check.Metadata.Labels = &dash0api.SyntheticCheckLabels{} - } - if check.Metadata.Labels.Dash0Comid == nil { - check.Metadata.Labels.Dash0Comid = &id - } + enrichSyntheticCheck(check, id) format, err := output.ParseFormat(flags.Output) if err != nil { diff --git a/internal/syntheticchecks/integration_test.go b/internal/syntheticchecks/integration_test.go new file mode 100644 index 0000000..ba9d2a1 --- /dev/null +++ b/internal/syntheticchecks/integration_test.go @@ -0,0 +1,85 @@ +//go:build integration + +package syntheticchecks + +import ( + "net/http" + "regexp" + "testing" + + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + apiPathSyntheticChecks = "/api/synthetic-checks" + fixtureListSuccess = "syntheticchecks/list_success.json" + fixtureListEmpty = "syntheticchecks/list_empty.json" + fixtureGetSuccess = "syntheticchecks/get_success.json" + fixtureUnauthorized = "dashboards/error_unauthorized.json" +) + +var syntheticCheckIDPattern = regexp.MustCompile(`^/api/synthetic-checks/[a-f0-9-]+$`) + +func TestListSyntheticChecks_JSONFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSyntheticChecks, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodGet, syntheticCheckIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSyntheticChecksCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "2"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // JSON output should contain full synthetic check definitions + assert.Contains(t, output, `"kind": "Dash0SyntheticCheck"`) + assert.Contains(t, output, `"metadata"`) + assert.Contains(t, output, `"spec"`) +} + +func TestListSyntheticChecks_YAMLFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSyntheticChecks, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodGet, syntheticCheckIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSyntheticChecksCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "yaml", "--limit", "2"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // YAML output should contain full synthetic check definitions as multi-document YAML + assert.Contains(t, output, "kind: Dash0SyntheticCheck") + assert.Contains(t, output, "metadata:") + assert.Contains(t, output, "spec:") + // Multiple documents should be separated by --- + assert.Contains(t, output, "---") +} diff --git a/internal/syntheticchecks/list.go b/internal/syntheticchecks/list.go index bbb74cc..34663f5 100644 --- a/internal/syntheticchecks/list.go +++ b/internal/syntheticchecks/list.go @@ -55,7 +55,8 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { return err } - iter := apiClient.ListSyntheticChecksIter(ctx, client.ResolveDataset(ctx, flags.Dataset)) + dataset := client.ResolveDataset(ctx, flags.Dataset) + iter := apiClient.ListSyntheticChecksIter(ctx, dataset) var checks []*dash0api.SyntheticChecksApiListItem count := 0 @@ -82,12 +83,51 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { switch format { case output.FormatJSON, output.FormatYAML: - return formatter.Print(checks) + definitions, err := fetchFullSyntheticChecks(ctx, apiClient, checks, dataset) + if err != nil { + return err + } + if format == output.FormatYAML { + return formatter.PrintMultiDocYAML(definitions) + } + return formatter.PrintJSON(definitions) default: return printSyntheticCheckTable(formatter, checks, format, apiUrl) } } +func fetchFullSyntheticChecks( + ctx context.Context, + apiClient dash0api.Client, + checks []*dash0api.SyntheticChecksApiListItem, + dataset *string, +) ([]interface{}, error) { + progress := output.NewProgress("synthetic checks", len(checks)) + defer progress.Done() + definitions := make([]interface{}, 0, len(checks)) + for i, item := range checks { + progress.Update(i + 1) + check, err := apiClient.GetSyntheticCheck(ctx, item.Id, dataset) + if err != nil { + return nil, fmt.Errorf("failed to fetch synthetic check %q: %w", item.Id, err) + } + enrichSyntheticCheck(check, item.Id) + definitions = append(definitions, check) + } + return definitions, nil +} + +// enrichSyntheticCheck restores the check ID in labels so that exported YAML +// can be re-applied (the import API uses the ID for upsert). +func enrichSyntheticCheck(check *dash0api.SyntheticCheckDefinition, id string) { + if check.Metadata.Labels == nil { + check.Metadata.Labels = &dash0api.SyntheticCheckLabels{} + } + if check.Metadata.Labels.Dash0Comid == nil { + check.Metadata.Labels.Dash0Comid = &id + } +} + func printSyntheticCheckTable(f *output.Formatter, checks []*dash0api.SyntheticChecksApiListItem, format output.Format, apiUrl string) error { columns := []output.Column{ {Header: internal.HEADER_NAME, Width: 40, Value: func(item interface{}) string { diff --git a/internal/views/get.go b/internal/views/get.go index f06364f..8d288e0 100644 --- a/internal/views/get.go +++ b/internal/views/get.go @@ -5,7 +5,6 @@ import ( "fmt" "os" - dash0api "github.com/dash0hq/dash0-api-client-go" "github.com/dash0hq/dash0-cli/internal" "github.com/dash0hq/dash0-cli/internal/asset" "github.com/dash0hq/dash0-cli/internal/client" @@ -53,14 +52,7 @@ func runGet(ctx context.Context, id string, flags *asset.GetFlags) error { }) } - // Restore the ID so that exported YAML can be re-applied (the import - // API uses the ID for upsert). - if view.Metadata.Labels == nil { - view.Metadata.Labels = &dash0api.ViewLabels{} - } - if view.Metadata.Labels.Dash0Comid == nil { - view.Metadata.Labels.Dash0Comid = &id - } + enrichView(view, id) format, err := output.ParseFormat(flags.Output) if err != nil { diff --git a/internal/views/integration_test.go b/internal/views/integration_test.go new file mode 100644 index 0000000..c849f2c --- /dev/null +++ b/internal/views/integration_test.go @@ -0,0 +1,85 @@ +//go:build integration + +package views + +import ( + "net/http" + "regexp" + "testing" + + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + apiPathViews = "/api/views" + fixtureListSuccess = "views/list_success.json" + fixtureListEmpty = "views/list_empty.json" + fixtureGetSuccess = "views/get_success.json" + fixtureUnauthorized = "dashboards/error_unauthorized.json" +) + +var viewIDPattern = regexp.MustCompile(`^/api/views/[a-f0-9-]+$`) + +func TestListViews_JSONFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathViews, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewViewsCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "2"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // JSON output should contain full view definitions + assert.Contains(t, output, `"kind": "Dash0View"`) + assert.Contains(t, output, `"metadata"`) + assert.Contains(t, output, `"spec"`) +} + +func TestListViews_YAMLFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathViews, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewViewsCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "yaml", "--limit", "2"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // YAML output should contain full view definitions as multi-document YAML + assert.Contains(t, output, "kind: Dash0View") + assert.Contains(t, output, "metadata:") + assert.Contains(t, output, "spec:") + // Multiple documents should be separated by --- + assert.Contains(t, output, "---") +} diff --git a/internal/views/list.go b/internal/views/list.go index 0b30f7a..5087a97 100644 --- a/internal/views/list.go +++ b/internal/views/list.go @@ -55,7 +55,8 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { return err } - iter := apiClient.ListViewsIter(ctx, client.ResolveDataset(ctx, flags.Dataset)) + dataset := client.ResolveDataset(ctx, flags.Dataset) + iter := apiClient.ListViewsIter(ctx, dataset) var views []*dash0api.ViewApiListItem count := 0 @@ -82,12 +83,51 @@ func runList(ctx context.Context, flags *asset.ListFlags) error { switch format { case output.FormatJSON, output.FormatYAML: - return formatter.Print(views) + definitions, err := fetchFullViews(ctx, apiClient, views, dataset) + if err != nil { + return err + } + if format == output.FormatYAML { + return formatter.PrintMultiDocYAML(definitions) + } + return formatter.PrintJSON(definitions) default: return printViewTable(formatter, views, format, apiUrl) } } +func fetchFullViews( + ctx context.Context, + apiClient dash0api.Client, + views []*dash0api.ViewApiListItem, + dataset *string, +) ([]interface{}, error) { + progress := output.NewProgress("views", len(views)) + defer progress.Done() + definitions := make([]interface{}, 0, len(views)) + for i, item := range views { + progress.Update(i + 1) + view, err := apiClient.GetView(ctx, item.Id, dataset) + if err != nil { + return nil, fmt.Errorf("failed to fetch view %q: %w", item.Id, err) + } + enrichView(view, item.Id) + definitions = append(definitions, view) + } + return definitions, nil +} + +// enrichView restores the view ID in labels so that exported YAML can be +// re-applied (the import API uses the ID for upsert). +func enrichView(view *dash0api.ViewDefinition, id string) { + if view.Metadata.Labels == nil { + view.Metadata.Labels = &dash0api.ViewLabels{} + } + if view.Metadata.Labels.Dash0Comid == nil { + view.Metadata.Labels.Dash0Comid = &id + } +} + func printViewTable(f *output.Formatter, views []*dash0api.ViewApiListItem, format output.Format, apiUrl string) error { columns := []output.Column{ {Header: internal.HEADER_NAME, Width: 40, Value: func(item any) string {