From dc5400302271a2fd53c31b1d9da3df01f2960500 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Fri, 17 Oct 2025 19:08:25 +0200 Subject: [PATCH 01/20] Export dashboards using EPM API endpoints --- cmd/export_dashboards.go | 125 ++++++++++++++++++++++++++++++++++- internal/kibana/packages.go | 128 ++++++++++++++++++++++++++++++++++++ internal/packages/assets.go | 10 +-- 3 files changed, 256 insertions(+), 7 deletions(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index b0f446e2ba..1d864ba852 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -7,7 +7,10 @@ package cmd import ( "context" "fmt" + "strings" + "github.com/Masterminds/semver/v3" + "github.com/elastic/elastic-package/internal/packages" "github.com/elastic/elastic-package/internal/tui" "github.com/spf13/cobra" @@ -67,16 +70,52 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { fmt.Printf("Warning: %s\n", message) } - if len(dashboardIDs) == 0 { + if len(dashboardIDs) > 0 { + err = export.Dashboards(cmd.Context(), kibanaClient, dashboardIDs) + if err != nil { + return fmt.Errorf("dashboards export failed: %w", err) + } + } + + stackVersion, err := semver.NewVersion(kibanaVersion.Version()) + if err != nil { + return fmt.Errorf("failed to parse kibana version: %w", err) + } + + if stackVersion.LessThan(semver.MustParse("8.0.0")) { + // This method uses a deprecated API to search for saved objects. + // And this API is not available in Serverless environments. dashboardIDs, err = promptDashboardIDs(cmd.Context(), kibanaClient) if err != nil { return fmt.Errorf("prompt for dashboard selection failed: %w", err) } + } else { + installedPackage, err := promptPackagesInstalled(cmd.Context(), kibanaClient) + if err != nil { + return fmt.Errorf("prompt for package selection failed: %w", err) + } - if len(dashboardIDs) == 0 { - fmt.Println("No dashboards were found in Kibana.") + if installedPackage == "" { + fmt.Println("No installed packages were found in Kibana.") return nil } + + // As it can be installed just one version of a package in Elastic, we can split by '-' to get the name. + // This package name will be used to get the data related to a package (kibana.GetPackage). + installedPackageName, _, found := strings.Cut(installedPackage, "-") + if !found { + return fmt.Errorf("invalid package name: %s", installedPackage) + } + + dashboardIDs, err = promptPackageDashboardIDs(cmd.Context(), kibanaClient, installedPackageName) + if err != nil { + return fmt.Errorf("prompt for package dashboard selection failed: %w", err) + } + } + + if len(dashboardIDs) == 0 { + fmt.Println("No dashboards were found in Kibana.") + return nil } err = export.Dashboards(cmd.Context(), kibanaClient, dashboardIDs) @@ -88,6 +127,86 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { return nil } +func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) (string, error) { + installedPackages, err := kibanaClient.FindInstalledPackages(ctx) + if err != nil { + return "", fmt.Errorf("finding installed packages failed: %w", err) + } + + if len(installedPackages) == 0 { + return "", nil + } + + packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", installedPackages.Strings(), "") + + var selectedOption string + err = tui.AskOne(packagesPrompt, &selectedOption, tui.Required) + if err != nil { + return "", err + } + + return selectedOption, nil +} + +// promptPackageDashboardIDs prompts the user to select dashboards from the given package. +// It requires the package name to fetch the installed package information from Kibana. +func promptPackageDashboardIDs(ctx context.Context, kibanaClient *kibana.Client, packageName string) ([]string, error) { + installedPackage, err := kibanaClient.GetPackage(ctx, packageName) + if err != nil { + return nil, fmt.Errorf("failed to get package status: %w", err) + } + if installedPackage.Status == "not_installed" { + return nil, fmt.Errorf("package %s is not installed", packageName) + } + + // get asset titles from IDs + packageAssets := []packages.Asset{} + for _, asset := range installedPackage.InstallationInfo.InstalledKibanaAssets { + if asset.Type != "dashboard" { + continue + } + + packageAssets = append(packageAssets, packages.Asset{ID: asset.ID, Type: asset.Type}) + } + + assetsResponse, err := kibanaClient.GetDataFromPackageAssetIDs(ctx, packageAssets) + if err != nil { + return nil, fmt.Errorf("failed to get package assets: %w", err) + } + + dashboardIDOptions := []string{} + for _, asset := range assetsResponse { + if asset.Type != "dashboard" { + continue + } + dashboardIDOptions = append(dashboardIDOptions, asset.String()) + } + + if len(dashboardIDOptions) == 0 { + return nil, fmt.Errorf("no dashboards found for package %s", packageName) + } + + dashboardsPrompt := tui.NewMultiSelect("Which dashboards would you like to export?", dashboardIDOptions, []string{}) + dashboardsPrompt.SetPageSize(100) + + var selectedOptions []string + err = tui.AskOne(dashboardsPrompt, &selectedOptions, tui.Required) + if err != nil { + return nil, err + } + + var selectedIDs []string + for _, option := range selectedOptions { + for _, asset := range assetsResponse { + if asset.String() == option { + selectedIDs = append(selectedIDs, asset.ID) + } + } + } + + return selectedIDs, nil +} + func promptDashboardIDs(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) { savedDashboards, err := kibanaClient.FindDashboards(ctx) if err != nil { diff --git a/internal/kibana/packages.go b/internal/kibana/packages.go index 3b8950249e..55469d16f8 100644 --- a/internal/kibana/packages.go +++ b/internal/kibana/packages.go @@ -11,12 +11,60 @@ import ( "fmt" "net/http" "os" + "sort" + "strings" "github.com/elastic/elastic-package/internal/packages" ) +const findInstalledPackagesPerPage = 1 + var ErrNotSupported error = errors.New("not supported") +type findInstalledPackagesResponse struct { + // TODO: Should we remove this Response field? + // Assets are here when old packages API is used (with hyphen, before 8.0). + Response []InstalledPackage `json:"response"` + + // Assets are here when new packages API is used (with slash, since 8.0). + Items []InstalledPackage `json:"items"` + Total int `json:"total"` + SearchAfter []string `json:"searchAfter"` +} + +type InstalledPackages []InstalledPackage +type InstalledPackage struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// String method returns string representation for an installed package. +func (ip *InstalledPackage) String() string { + return fmt.Sprintf("%s-%s", ip.Name, ip.Version) +} + +type PackageAssetResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + Description string `json:"description"` + Title string `json:"title"` + } `json:"attributes"` +} + +func (p *PackageAssetResponse) String() string { + return fmt.Sprintf("%s (ID: %s, Type: %s)", p.Attributes.Title, p.ID, p.Type) +} + +// Strings method returns string representation for a set of installed packages. +func (ips InstalledPackages) Strings() []string { + var entries []string + for _, ip := range ips { + entries = append(entries, ip.String()) + } + return entries +} + // InstallPackage installs the given package in Fleet. func (c *Client) InstallPackage(ctx context.Context, name, version string) ([]packages.Asset, error) { path := c.epmPackageUrl(name, version) @@ -216,3 +264,83 @@ func processResults(action string, statusCode int, respBody []byte) ([]packages. return resp.Items, nil } + +// FindInstalledPackages retrieves the current installed packages (name and version). Response is sorted by name. +func (c *Client) FindInstalledPackages(ctx context.Context) (InstalledPackages, error) { + var installed InstalledPackages + searchAfter := "" + + for { + r, err := c.findInstalledPackagesNextPage(ctx, searchAfter) + if err != nil { + return nil, fmt.Errorf("can't fetch page with results: %w", err) + } + if len(installed) >= r.Total { + break + } + searchAfter = r.SearchAfter[0] + if len(r.Response) > 0 { + installed = append(installed, r.Response...) + } else { + installed = append(installed, r.Items...) + } + } + sort.Slice(installed, func(i, j int) bool { + return sort.StringsAreSorted([]string{strings.ToLower(installed[i].Name), strings.ToLower(installed[j].Name)}) + }) + + return installed, nil +} + +// findInstalledPackagesNextPage retrieves the next set of packages after the given searchAfter value. +func (c *Client) findInstalledPackagesNextPage(ctx context.Context, searchAfter string) (*findInstalledPackagesResponse, error) { + path := fmt.Sprintf("%s/epm/packages/installed?perPage=%d&searchAfter=[\"%s\"]", FleetAPI, findInstalledPackagesPerPage, searchAfter) + statusCode, respBody, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("could not find installed packages; API status code = %d; response body = %s: %w", statusCode, string(respBody), err) + } + + if statusCode != http.StatusOK { + return nil, fmt.Errorf("could not find installed packages; API status code = %d; response body = %s", statusCode, string(respBody)) + } + + var resp findInstalledPackagesResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("could not convert installed packages (response) to JSON: %w", err) + } + return &resp, nil +} + +// GetDataFromPackageAssetIDs retrieves data, such as title and description, for the given a list of asset IDs +// using the "bulk_assets" Elastic Package Manager API endpoint. Response is sorted by title. +func (c *Client) GetDataFromPackageAssetIDs(ctx context.Context, assets []packages.Asset) ([]PackageAssetResponse, error) { + path := fmt.Sprintf("%s/epm/bulk_assets", FleetAPI) + type request struct { + AssetIDs []packages.Asset `json:"assetIds"` + } + reqBody, err := json.Marshal(request{AssetIDs: assets}) + if err != nil { + return nil, fmt.Errorf("could not convert assets (request) to JSON: %w", err) + } + statusCode, respBody, err := c.post(ctx, path, reqBody) + if err != nil { + return nil, fmt.Errorf("could not get assets; API status code = %d; response body = %s: %w", statusCode, string(respBody), err) + } + + if statusCode != http.StatusOK { + return nil, fmt.Errorf("could not get assets; API status code = %d; response body = %s", statusCode, string(respBody)) + } + + type packageAssetsResponse struct { + Items []PackageAssetResponse `json:"items"` + } + + var resp packageAssetsResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return nil, fmt.Errorf("could not convert get assets (response) to JSON: %w", err) + } + sort.Slice(resp.Items, func(i, j int) bool { + return sort.StringsAreSorted([]string{strings.ToLower(resp.Items[i].Attributes.Title), strings.ToLower(resp.Items[j].Attributes.Title)}) + }) + return resp.Items, nil +} diff --git a/internal/packages/assets.go b/internal/packages/assets.go index 01f027e0d5..c1561ad095 100644 --- a/internal/packages/assets.go +++ b/internal/packages/assets.go @@ -50,10 +50,12 @@ var ( // Asset represents a package asset to be loaded into Kibana or Elasticsearch. type Asset struct { - ID string `json:"id"` - Type AssetType `json:"type"` - DataStream string - SourcePath string + ID string `json:"id"` + Type AssetType `json:"type"` + + // These fields are not expected to be part of responses from APIs + DataStream string `json:"-"` + SourcePath string `json:"-"` } // String method returns a string representation of the asset From 2fa7ec3a226a274bec31047baad9e26dcbc55ad4 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Fri, 17 Oct 2025 19:12:07 +0200 Subject: [PATCH 02/20] Just execute the new method on Serverless --- cmd/export_dashboards.go | 66 +++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 38 deletions(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index 1d864ba852..7a5380fe3a 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -9,7 +9,6 @@ import ( "fmt" "strings" - "github.com/Masterminds/semver/v3" "github.com/elastic/elastic-package/internal/packages" "github.com/elastic/elastic-package/internal/tui" @@ -70,46 +69,37 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { fmt.Printf("Warning: %s\n", message) } - if len(dashboardIDs) > 0 { - err = export.Dashboards(cmd.Context(), kibanaClient, dashboardIDs) - if err != nil { - return fmt.Errorf("dashboards export failed: %w", err) - } - } - - stackVersion, err := semver.NewVersion(kibanaVersion.Version()) - if err != nil { - return fmt.Errorf("failed to parse kibana version: %w", err) - } - - if stackVersion.LessThan(semver.MustParse("8.0.0")) { - // This method uses a deprecated API to search for saved objects. - // And this API is not available in Serverless environments. - dashboardIDs, err = promptDashboardIDs(cmd.Context(), kibanaClient) - if err != nil { - return fmt.Errorf("prompt for dashboard selection failed: %w", err) - } - } else { - installedPackage, err := promptPackagesInstalled(cmd.Context(), kibanaClient) - if err != nil { - return fmt.Errorf("prompt for package selection failed: %w", err) - } + // Just query for dashboards if none were provided as flags + if len(dashboardIDs) == 0 { + if kibanaVersion.BuildFlavor != "serverless" { + // This method uses a deprecated API to search for saved objects. + // And this API is not available in Serverless environments. + dashboardIDs, err = promptDashboardIDs(cmd.Context(), kibanaClient) + if err != nil { + return fmt.Errorf("prompt for dashboard selection failed: %w", err) + } + } else { + installedPackage, err := promptPackagesInstalled(cmd.Context(), kibanaClient) + if err != nil { + return fmt.Errorf("prompt for package selection failed: %w", err) + } - if installedPackage == "" { - fmt.Println("No installed packages were found in Kibana.") - return nil - } + if installedPackage == "" { + fmt.Println("No installed packages were found in Kibana.") + return nil + } - // As it can be installed just one version of a package in Elastic, we can split by '-' to get the name. - // This package name will be used to get the data related to a package (kibana.GetPackage). - installedPackageName, _, found := strings.Cut(installedPackage, "-") - if !found { - return fmt.Errorf("invalid package name: %s", installedPackage) - } + // As it can be installed just one version of a package in Elastic, we can split by '-' to get the name. + // This package name will be used to get the data related to a package (kibana.GetPackage). + installedPackageName, _, found := strings.Cut(installedPackage, "-") + if !found { + return fmt.Errorf("invalid package name: %s", installedPackage) + } - dashboardIDs, err = promptPackageDashboardIDs(cmd.Context(), kibanaClient, installedPackageName) - if err != nil { - return fmt.Errorf("prompt for package dashboard selection failed: %w", err) + dashboardIDs, err = promptPackageDashboardIDs(cmd.Context(), kibanaClient, installedPackageName) + if err != nil { + return fmt.Errorf("prompt for package dashboard selection failed: %w", err) + } } } From 66a1770629015e220563f5d66910226a85ee99db Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Fri, 17 Oct 2025 19:48:09 +0200 Subject: [PATCH 03/20] Select package from current directory as default --- cmd/export_dashboards.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index 7a5380fe3a..3a90cfeefd 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -118,6 +118,16 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { } func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) (string, error) { + packageRoot, err := packages.MustFindPackageRoot() + if err != nil { + return "", fmt.Errorf("locating package root failed: %w", err) + } + + m, err := packages.ReadPackageManifestFromPackageRoot(packageRoot) + if err != nil { + return "", fmt.Errorf("reading package manifest failed (path: %s): %w", packageRoot, err) + } + installedPackages, err := kibanaClient.FindInstalledPackages(ctx) if err != nil { return "", fmt.Errorf("finding installed packages failed: %w", err) @@ -127,7 +137,17 @@ func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) ( return "", nil } - packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", installedPackages.Strings(), "") + options := installedPackages.Strings() + defaultPackage := "" + for _, ip := range installedPackages { + if ip.Name == m.Name { + // set default package to the one matching the package in the current directory + defaultPackage = ip.String() + break + } + } + + packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", options, defaultPackage) var selectedOption string err = tui.AskOne(packagesPrompt, &selectedOption, tui.Required) From 54be65abc6188237f30447a292b614deaee5485a Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Mon, 20 Oct 2025 16:19:45 +0200 Subject: [PATCH 04/20] Use same method to find dashboards in Serverless via export API Extract function to select dashboard IDs --- cmd/export_dashboards.go | 94 +++++++++++++++++++++------------ internal/kibana/dashboards.go | 13 +++++ internal/kibana/packages.go | 18 +++---- internal/kibana/savedobjects.go | 67 +++++++++++++++++++++-- internal/kibana/status.go | 2 + 5 files changed, 146 insertions(+), 48 deletions(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index 3a90cfeefd..2631a3e1fb 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -21,10 +21,14 @@ import ( "github.com/elastic/elastic-package/internal/stack" ) -const exportDashboardsLongDescription = `Use this command to export dashboards with referenced objects from the Kibana instance. +const ( + exportDashboardsLongDescription = `Use this command to export dashboards with referenced objects from the Kibana instance. Use this command to download selected dashboards and other associated saved objects from Kibana. This command adjusts the downloaded saved objects according to package naming conventions (prefixes, unique IDs) and writes them locally into folders corresponding to saved object types (dashboard, visualization, map, etc.).` + newDashboardOption = "New dashboard" +) + func exportDashboardsCmd(cmd *cobra.Command, args []string) error { cmd.Println("Export Kibana dashboards") @@ -71,35 +75,9 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { // Just query for dashboards if none were provided as flags if len(dashboardIDs) == 0 { - if kibanaVersion.BuildFlavor != "serverless" { - // This method uses a deprecated API to search for saved objects. - // And this API is not available in Serverless environments. - dashboardIDs, err = promptDashboardIDs(cmd.Context(), kibanaClient) - if err != nil { - return fmt.Errorf("prompt for dashboard selection failed: %w", err) - } - } else { - installedPackage, err := promptPackagesInstalled(cmd.Context(), kibanaClient) - if err != nil { - return fmt.Errorf("prompt for package selection failed: %w", err) - } - - if installedPackage == "" { - fmt.Println("No installed packages were found in Kibana.") - return nil - } - - // As it can be installed just one version of a package in Elastic, we can split by '-' to get the name. - // This package name will be used to get the data related to a package (kibana.GetPackage). - installedPackageName, _, found := strings.Cut(installedPackage, "-") - if !found { - return fmt.Errorf("invalid package name: %s", installedPackage) - } - - dashboardIDs, err = promptPackageDashboardIDs(cmd.Context(), kibanaClient, installedPackageName) - if err != nil { - return fmt.Errorf("prompt for package dashboard selection failed: %w", err) - } + dashboardIDs, err = selectDashboardIds(cmd, kibanaClient, kibanaVersion) + if err != nil { + return fmt.Errorf("selecting dashboard IDs failed: %w", err) } } @@ -117,6 +95,55 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { return nil } +// selectDashboardIds prompts the user to select dashboards to export. It handles +// different flows depending on whether the Kibana instance is a Serverless environment or not. +// In non-Serverless environments, it prompts directly for dashboard selection. +// In Serverless environments, it first prompts to select an installed package or choose +// to export new dashboards, and then prompts for dashboard selection accordingly. +func selectDashboardIds(cmd *cobra.Command, kibanaClient *kibana.Client, kibanaVersion kibana.VersionInfo) ([]string, error) { + if kibanaVersion.BuildFlavor != kibana.ServerlessFlavor { + // This method uses a deprecated API to search for saved objects. + // And this API is not available in Serverless environments. + dashboardIDs, err := promptDashboardIDs(cmd.Context(), kibanaClient) + if err != nil { + return nil, fmt.Errorf("prompt for dashboard selection failed: %w", err) + } + return dashboardIDs, nil + } + + installedPackage, err := promptPackagesInstalled(cmd.Context(), kibanaClient) + if err != nil { + return nil, fmt.Errorf("prompt for package selection failed: %w", err) + } + + if installedPackage == "" { + fmt.Println("No installed packages were found in Kibana.") + return nil, nil + } + + if installedPackage == newDashboardOption { + dashboardIDs, err := promptDashboardIDs(cmd.Context(), kibanaClient) + if err != nil { + return nil, fmt.Errorf("prompt for dashboard selection failed: %w", err) + } + return dashboardIDs, nil + } + + // As it can be installed just one version of a package in Elastic, we can split by '-' to get the name. + // This package name will be used to get the data related to a package (kibana.GetPackage). + installedPackageName, _, found := strings.Cut(installedPackage, "-") + if !found { + return nil, fmt.Errorf("invalid package name: %s", installedPackage) + } + + dashboardIDs, err := promptPackageDashboardIDs(cmd.Context(), kibanaClient, installedPackageName) + if err != nil { + return nil, fmt.Errorf("prompt for package dashboard selection failed: %w", err) + } + + return dashboardIDs, nil +} + func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) (string, error) { packageRoot, err := packages.MustFindPackageRoot() if err != nil { @@ -133,11 +160,10 @@ func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) ( return "", fmt.Errorf("finding installed packages failed: %w", err) } - if len(installedPackages) == 0 { - return "", nil - } + // It always shows an option to export dashboards not related to any package. + options := []string{newDashboardOption} - options := installedPackages.Strings() + options = append(options, installedPackages.Strings()...) defaultPackage := "" for _, ip := range installedPackages { if ip.Name == m.Name { diff --git a/internal/kibana/dashboards.go b/internal/kibana/dashboards.go index 347b97f90f..7f721b7846 100644 --- a/internal/kibana/dashboards.go +++ b/internal/kibana/dashboards.go @@ -32,6 +32,19 @@ func (c *Client) Export(ctx context.Context, dashboardIDs []string) ([]common.Ma return c.exportWithSavedObjectsAPI(ctx, dashboardIDs) } +// exportAllDashboards method exports all dashboards using the Kibana APIs without any export details nor including references. +func (c *Client) exportAllDashboards(ctx context.Context) ([]common.MapStr, error) { + logger.Debug("Export dashboards using the Kibana Saved Objects Export API") + + request := ExportSavedObjectsRequest{ + ExcludeExportDetails: true, + IncludeReferencesDeep: false, + Type: "dashboard", + } + + return c.ExportSavedObjects(ctx, request) +} + func (c *Client) exportWithSavedObjectsAPI(ctx context.Context, dashboardIDs []string) ([]common.MapStr, error) { logger.Debug("Export dashboards using the Kibana Saved Objects Export API") diff --git a/internal/kibana/packages.go b/internal/kibana/packages.go index 55469d16f8..94e618c965 100644 --- a/internal/kibana/packages.go +++ b/internal/kibana/packages.go @@ -43,6 +43,15 @@ func (ip *InstalledPackage) String() string { return fmt.Sprintf("%s-%s", ip.Name, ip.Version) } +// Strings method returns string representation for a set of installed packages. +func (ips InstalledPackages) Strings() []string { + var entries []string + for _, ip := range ips { + entries = append(entries, ip.String()) + } + return entries +} + type PackageAssetResponse struct { ID string `json:"id"` Type string `json:"type"` @@ -56,15 +65,6 @@ func (p *PackageAssetResponse) String() string { return fmt.Sprintf("%s (ID: %s, Type: %s)", p.Attributes.Title, p.ID, p.Type) } -// Strings method returns string representation for a set of installed packages. -func (ips InstalledPackages) Strings() []string { - var entries []string - for _, ip := range ips { - entries = append(entries, ip.String()) - } - return entries -} - // InstallPackage installs the given package in Fleet. func (c *Client) InstallPackage(ctx context.Context, name, version string) ([]packages.Asset, error) { path := c.epmPackageUrl(name, version) diff --git a/internal/kibana/savedobjects.go b/internal/kibana/savedobjects.go index 82bc734e65..53fc22e694 100644 --- a/internal/kibana/savedobjects.go +++ b/internal/kibana/savedobjects.go @@ -61,10 +61,47 @@ func (dso *DashboardSavedObject) String() string { // FindDashboards method returns dashboards available in the Kibana instance. func (c *Client) FindDashboards(ctx context.Context) (DashboardSavedObjects, error) { logger.Debug("Find dashboards using the Saved Objects API") + var foundObjects DashboardSavedObjects + var err error + + if c.versionInfo.BuildFlavor == ServerlessFlavor { + foundObjects, err = c.findDashboardsServerless(ctx) + if err != nil { + return nil, fmt.Errorf("can't find dashboards in serverless mode: %w", err) + } + } else { + foundObjects, err = c.findDashboards(ctx) + if err != nil { + return nil, fmt.Errorf("can't find dashboards: %w", err) + } + } + + sort.Slice(foundObjects, func(i, j int) bool { + return sort.StringsAreSorted([]string{strings.ToLower(foundObjects[i].Title), strings.ToLower(foundObjects[j].Title)}) + }) + return foundObjects, nil +} +func (c *Client) findDashboardsServerless(ctx context.Context) (DashboardSavedObjects, error) { var foundObjects DashboardSavedObjects - page := 1 + allDashboards, err := c.exportAllDashboards(ctx) + if err != nil { + return nil, fmt.Errorf("can't export all dashboards: %w", err) + } + for _, dashboard := range allDashboards { + dashboardSavedObject, err := dashboardSavedObjectFromMapStr(dashboard) + if err != nil { + return nil, fmt.Errorf("can't parse dashboard saved object: %w", err) + } + foundObjects = append(foundObjects, dashboardSavedObject) + } + return foundObjects, nil +} + +func (c *Client) findDashboards(ctx context.Context) (DashboardSavedObjects, error) { + var foundObjects DashboardSavedObjects + page := 1 for { r, err := c.findDashboardsNextPage(ctx, page) if err != nil { @@ -87,9 +124,6 @@ func (c *Client) FindDashboards(ctx context.Context) (DashboardSavedObjects, err page++ } - sort.Slice(foundObjects, func(i, j int) bool { - return sort.StringsAreSorted([]string{strings.ToLower(foundObjects[i].Title), strings.ToLower(foundObjects[j].Title)}) - }) return foundObjects, nil } @@ -112,6 +146,28 @@ func (c *Client) findDashboardsNextPage(ctx context.Context, page int) (*savedOb return &r, nil } +func dashboardSavedObjectFromMapStr(data common.MapStr) (DashboardSavedObject, error) { + dashboardId, ok := data["id"].(string) + if !ok { + return DashboardSavedObject{}, fmt.Errorf("dashboard object does not contain id field") + } + + attributes, ok := data["attributes"].(map[string]any) + if !ok { + return DashboardSavedObject{}, fmt.Errorf("dashboard object does not contain attributes field") + } + + dashboardTitle, ok := attributes["title"].(string) + if !ok { + return DashboardSavedObject{}, fmt.Errorf("dashboard object does not contain attributes.title field") + } + + return DashboardSavedObject{ + ID: dashboardId, + Title: dashboardTitle, + }, nil +} + // SetManagedSavedObject method sets the managed property in a saved object. // For example managed dashboards cannot be edited, and setting managed to false will // allow to edit them. @@ -152,7 +208,8 @@ func (c *Client) SetManagedSavedObject(ctx context.Context, savedObjectType stri type ExportSavedObjectsRequest struct { ExcludeExportDetails bool `json:"excludeExportDetails"` IncludeReferencesDeep bool `json:"includeReferencesDeep"` - Objects []ExportSavedObjectsRequestObject `json:"objects"` + Objects []ExportSavedObjectsRequestObject `json:"objects,omitempty"` + Type string `json:"type,omitempty"` } type ExportSavedObjectsRequestObject struct { diff --git a/internal/kibana/status.go b/internal/kibana/status.go index 3a8ad75e21..fec36b5a2e 100644 --- a/internal/kibana/status.go +++ b/internal/kibana/status.go @@ -19,6 +19,8 @@ type VersionInfo struct { BuildFlavor string `json:"build_flavor"` } +const ServerlessFlavor = "serverless" + func (v VersionInfo) Version() string { if v.BuildSnapshot { return fmt.Sprintf("%s%s", v.Number, SNAPSHOT_SUFFIX) From d843e200f5123820cbe72da3f363548c7d60403c Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Mon, 20 Oct 2025 17:38:22 +0200 Subject: [PATCH 05/20] Increase number of packages per page --- internal/kibana/packages.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/kibana/packages.go b/internal/kibana/packages.go index 94e618c965..29072ddf00 100644 --- a/internal/kibana/packages.go +++ b/internal/kibana/packages.go @@ -17,7 +17,7 @@ import ( "github.com/elastic/elastic-package/internal/packages" ) -const findInstalledPackagesPerPage = 1 +const findInstalledPackagesPerPage = 100 var ErrNotSupported error = errors.New("not supported") From 7d6211b3feb3c0846b5e31b9c6e863c7a0af7160 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Mon, 20 Oct 2025 18:36:33 +0200 Subject: [PATCH 06/20] Remove Response field in response - not present --- internal/kibana/packages.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/internal/kibana/packages.go b/internal/kibana/packages.go index 29072ddf00..74f80da1ab 100644 --- a/internal/kibana/packages.go +++ b/internal/kibana/packages.go @@ -22,11 +22,7 @@ const findInstalledPackagesPerPage = 100 var ErrNotSupported error = errors.New("not supported") type findInstalledPackagesResponse struct { - // TODO: Should we remove this Response field? - // Assets are here when old packages API is used (with hyphen, before 8.0). - Response []InstalledPackage `json:"response"` - - // Assets are here when new packages API is used (with slash, since 8.0). + // Installed packages are listed in Items field. Items []InstalledPackage `json:"items"` Total int `json:"total"` SearchAfter []string `json:"searchAfter"` @@ -279,11 +275,7 @@ func (c *Client) FindInstalledPackages(ctx context.Context) (InstalledPackages, break } searchAfter = r.SearchAfter[0] - if len(r.Response) > 0 { - installed = append(installed, r.Response...) - } else { - installed = append(installed, r.Items...) - } + installed = append(installed, r.Items...) } sort.Slice(installed, func(i, j int) bool { return sort.StringsAreSorted([]string{strings.ToLower(installed[i].Name), strings.ToLower(installed[j].Name)}) From d7b269b1f8d96ebde2832a6a075a6847d024fa2f Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Mon, 20 Oct 2025 18:58:57 +0200 Subject: [PATCH 07/20] Add comment --- internal/kibana/dashboards.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/kibana/dashboards.go b/internal/kibana/dashboards.go index 7f721b7846..70a00ebeef 100644 --- a/internal/kibana/dashboards.go +++ b/internal/kibana/dashboards.go @@ -33,6 +33,7 @@ func (c *Client) Export(ctx context.Context, dashboardIDs []string) ([]common.Ma } // exportAllDashboards method exports all dashboards using the Kibana APIs without any export details nor including references. +// The number of exported dashboards depends on the "savedObjects.maxImportExportSize" setting, that by default is 10000. func (c *Client) exportAllDashboards(ctx context.Context) ([]common.MapStr, error) { logger.Debug("Export dashboards using the Kibana Saved Objects Export API") From 6542265822a3725154d9e07a634b34c1f11e9211 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 10:22:42 +0200 Subject: [PATCH 08/20] Check success variable in import saved objects response --- internal/kibana/savedobjects.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/kibana/savedobjects.go b/internal/kibana/savedobjects.go index 53fc22e694..370dcac443 100644 --- a/internal/kibana/savedobjects.go +++ b/internal/kibana/savedobjects.go @@ -197,11 +197,16 @@ func (c *Client) SetManagedSavedObject(ctx context.Context, savedObjectType stri Overwrite: true, Objects: objects, } - _, err = c.ImportSavedObjects(ctx, importRequest) + resp, err := c.ImportSavedObjects(ctx, importRequest) if err != nil { return fmt.Errorf("failed to import %s %s: %w", savedObjectType, id, err) } + // Even if no error is returned, we need to check if the import was successful. + if !resp.Success { + return fmt.Errorf("importing %s %s was not successful", savedObjectType, id) + } + return nil } From a8b59fbca05cb8efcc270fee3d2c3515791ba1f4 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 10:45:06 +0200 Subject: [PATCH 09/20] Apply same changes for edit command when running with Serverless --- cmd/edit.go | 22 ++++++++- cmd/export_dashboards.go | 84 +++++++++++++++++++++------------ internal/kibana/savedobjects.go | 64 ++++++++++--------------- 3 files changed, 100 insertions(+), 70 deletions(-) diff --git a/cmd/edit.go b/cmd/edit.go index 38fc7d0168..7586d80d27 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -16,6 +16,7 @@ import ( "github.com/elastic/elastic-package/internal/common" "github.com/elastic/elastic-package/internal/install" "github.com/elastic/elastic-package/internal/kibana" + "github.com/elastic/elastic-package/internal/packages" "github.com/elastic/elastic-package/internal/stack" ) @@ -93,9 +94,26 @@ func editDashboardsCmd(cmd *cobra.Command, args []string) error { } if len(dashboardIDs) == 0 { - dashboardIDs, err = promptDashboardIDs(cmd.Context(), kibanaClient) + // Not mandatory to get the package name here, but it would be helpful for users + // to select by default the package where they are located if any. + defaultPackage := "" + packageRoot, err := packages.MustFindPackageRoot() + if err == nil { + m, err := packages.ReadPackageManifestFromPackageRoot(packageRoot) + if err != nil { + return fmt.Errorf("reading package manifest failed (path: %s): %w", packageRoot, err) + } + defaultPackage = m.Name + } + selectOptions := selectDashboardOptions{ + ctx: cmd.Context(), + kibanaClient: kibanaClient, + kibanaVersion: kibanaVersion, + defaultPackage: defaultPackage, + } + dashboardIDs, err = selectDashboardIDs(selectOptions) if err != nil { - return fmt.Errorf("prompt for dashboard selection failed: %w", err) + return fmt.Errorf("selecting dashboard IDs failed: %w", err) } if len(dashboardIDs) == 0 { diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index 2631a3e1fb..37b40a3f6c 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -75,15 +75,30 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { // Just query for dashboards if none were provided as flags if len(dashboardIDs) == 0 { - dashboardIDs, err = selectDashboardIds(cmd, kibanaClient, kibanaVersion) + packageRoot, err := packages.MustFindPackageRoot() + if err != nil { + return fmt.Errorf("locating package root failed: %w", err) + } + m, err := packages.ReadPackageManifestFromPackageRoot(packageRoot) + if err != nil { + return fmt.Errorf("reading package manifest failed (path: %s): %w", packageRoot, err) + } + options := selectDashboardOptions{ + ctx: cmd.Context(), + kibanaClient: kibanaClient, + kibanaVersion: kibanaVersion, + defaultPackage: m.Name, + } + + dashboardIDs, err = selectDashboardIDs(options) if err != nil { return fmt.Errorf("selecting dashboard IDs failed: %w", err) } - } - if len(dashboardIDs) == 0 { - fmt.Println("No dashboards were found in Kibana.") - return nil + if len(dashboardIDs) == 0 { + fmt.Println("No dashboards were found in Kibana.") + return nil + } } err = export.Dashboards(cmd.Context(), kibanaClient, dashboardIDs) @@ -95,23 +110,31 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { return nil } -// selectDashboardIds prompts the user to select dashboards to export. It handles +type selectDashboardOptions struct { + ctx context.Context + cmd *cobra.Command + kibanaClient *kibana.Client + kibanaVersion kibana.VersionInfo + defaultPackage string +} + +// selectDashboardIDs prompts the user to select dashboards to export. It handles // different flows depending on whether the Kibana instance is a Serverless environment or not. // In non-Serverless environments, it prompts directly for dashboard selection. // In Serverless environments, it first prompts to select an installed package or choose // to export new dashboards, and then prompts for dashboard selection accordingly. -func selectDashboardIds(cmd *cobra.Command, kibanaClient *kibana.Client, kibanaVersion kibana.VersionInfo) ([]string, error) { - if kibanaVersion.BuildFlavor != kibana.ServerlessFlavor { +func selectDashboardIDs(options selectDashboardOptions) ([]string, error) { + if options.kibanaVersion.BuildFlavor != kibana.ServerlessFlavor { // This method uses a deprecated API to search for saved objects. // And this API is not available in Serverless environments. - dashboardIDs, err := promptDashboardIDs(cmd.Context(), kibanaClient) + dashboardIDs, err := promptDashboardIDsNonServerless(options.ctx, options.kibanaClient) if err != nil { return nil, fmt.Errorf("prompt for dashboard selection failed: %w", err) } return dashboardIDs, nil } - installedPackage, err := promptPackagesInstalled(cmd.Context(), kibanaClient) + installedPackage, err := promptPackagesInstalled(options.ctx, options.kibanaClient, options.defaultPackage) if err != nil { return nil, fmt.Errorf("prompt for package selection failed: %w", err) } @@ -122,7 +145,7 @@ func selectDashboardIds(cmd *cobra.Command, kibanaClient *kibana.Client, kibanaV } if installedPackage == newDashboardOption { - dashboardIDs, err := promptDashboardIDs(cmd.Context(), kibanaClient) + dashboardIDs, err := promptDashboardIDsServerless(options.ctx, options.kibanaClient) if err != nil { return nil, fmt.Errorf("prompt for dashboard selection failed: %w", err) } @@ -136,7 +159,7 @@ func selectDashboardIds(cmd *cobra.Command, kibanaClient *kibana.Client, kibanaV return nil, fmt.Errorf("invalid package name: %s", installedPackage) } - dashboardIDs, err := promptPackageDashboardIDs(cmd.Context(), kibanaClient, installedPackageName) + dashboardIDs, err := promptPackageDashboardIDs(options.ctx, options.kibanaClient, installedPackageName) if err != nil { return nil, fmt.Errorf("prompt for package dashboard selection failed: %w", err) } @@ -144,17 +167,7 @@ func selectDashboardIds(cmd *cobra.Command, kibanaClient *kibana.Client, kibanaV return dashboardIDs, nil } -func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) (string, error) { - packageRoot, err := packages.MustFindPackageRoot() - if err != nil { - return "", fmt.Errorf("locating package root failed: %w", err) - } - - m, err := packages.ReadPackageManifestFromPackageRoot(packageRoot) - if err != nil { - return "", fmt.Errorf("reading package manifest failed (path: %s): %w", packageRoot, err) - } - +func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client, defaultPackageName string) (string, error) { installedPackages, err := kibanaClient.FindInstalledPackages(ctx) if err != nil { return "", fmt.Errorf("finding installed packages failed: %w", err) @@ -164,16 +177,16 @@ func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) ( options := []string{newDashboardOption} options = append(options, installedPackages.Strings()...) - defaultPackage := "" + defaultOption := "" for _, ip := range installedPackages { - if ip.Name == m.Name { + if ip.Name == defaultPackageName { // set default package to the one matching the package in the current directory - defaultPackage = ip.String() + defaultOption = ip.String() break } } - packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", options, defaultPackage) + packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", options, defaultOption) var selectedOption string err = tui.AskOne(packagesPrompt, &selectedOption, tui.Required) @@ -243,12 +256,25 @@ func promptPackageDashboardIDs(ctx context.Context, kibanaClient *kibana.Client, return selectedIDs, nil } -func promptDashboardIDs(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) { +func promptDashboardIDsServerless(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) { + savedDashboards, err := kibanaClient.FindServerlessDashboards(ctx) + if err != nil { + return nil, fmt.Errorf("finding dashboards failed: %w", err) + } + + return promptDashboardIDs(ctx, savedDashboards) +} + +func promptDashboardIDsNonServerless(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) { savedDashboards, err := kibanaClient.FindDashboards(ctx) if err != nil { return nil, fmt.Errorf("finding dashboards failed: %w", err) } + return promptDashboardIDs(ctx, savedDashboards) +} + +func promptDashboardIDs(ctx context.Context, savedDashboards kibana.DashboardSavedObjects) ([]string, error) { if len(savedDashboards) == 0 { return []string{}, nil } @@ -257,7 +283,7 @@ func promptDashboardIDs(ctx context.Context, kibanaClient *kibana.Client) ([]str dashboardsPrompt.SetPageSize(100) var selectedOptions []string - err = tui.AskOne(dashboardsPrompt, &selectedOptions, tui.Required) + err := tui.AskOne(dashboardsPrompt, &selectedOptions, tui.Required) if err != nil { return nil, err } diff --git a/internal/kibana/savedobjects.go b/internal/kibana/savedobjects.go index 370dcac443..d00ef561ed 100644 --- a/internal/kibana/savedobjects.go +++ b/internal/kibana/savedobjects.go @@ -61,45 +61,6 @@ func (dso *DashboardSavedObject) String() string { // FindDashboards method returns dashboards available in the Kibana instance. func (c *Client) FindDashboards(ctx context.Context) (DashboardSavedObjects, error) { logger.Debug("Find dashboards using the Saved Objects API") - var foundObjects DashboardSavedObjects - var err error - - if c.versionInfo.BuildFlavor == ServerlessFlavor { - foundObjects, err = c.findDashboardsServerless(ctx) - if err != nil { - return nil, fmt.Errorf("can't find dashboards in serverless mode: %w", err) - } - } else { - foundObjects, err = c.findDashboards(ctx) - if err != nil { - return nil, fmt.Errorf("can't find dashboards: %w", err) - } - } - - sort.Slice(foundObjects, func(i, j int) bool { - return sort.StringsAreSorted([]string{strings.ToLower(foundObjects[i].Title), strings.ToLower(foundObjects[j].Title)}) - }) - return foundObjects, nil -} - -func (c *Client) findDashboardsServerless(ctx context.Context) (DashboardSavedObjects, error) { - var foundObjects DashboardSavedObjects - allDashboards, err := c.exportAllDashboards(ctx) - if err != nil { - return nil, fmt.Errorf("can't export all dashboards: %w", err) - } - for _, dashboard := range allDashboards { - dashboardSavedObject, err := dashboardSavedObjectFromMapStr(dashboard) - if err != nil { - return nil, fmt.Errorf("can't parse dashboard saved object: %w", err) - } - foundObjects = append(foundObjects, dashboardSavedObject) - } - - return foundObjects, nil -} - -func (c *Client) findDashboards(ctx context.Context) (DashboardSavedObjects, error) { var foundObjects DashboardSavedObjects page := 1 for { @@ -124,6 +85,9 @@ func (c *Client) findDashboards(ctx context.Context) (DashboardSavedObjects, err page++ } + sort.Slice(foundObjects, func(i, j int) bool { + return sort.StringsAreSorted([]string{strings.ToLower(foundObjects[i].Title), strings.ToLower(foundObjects[j].Title)}) + }) return foundObjects, nil } @@ -146,6 +110,28 @@ func (c *Client) findDashboardsNextPage(ctx context.Context, page int) (*savedOb return &r, nil } +// FindServerlessDashboards method returns dashboards available in the Kibana instance. +func (c *Client) FindServerlessDashboards(ctx context.Context) (DashboardSavedObjects, error) { + logger.Debug("Find dashboards using the Export Objects API") + var foundObjects DashboardSavedObjects + allDashboards, err := c.exportAllDashboards(ctx) + if err != nil { + return nil, fmt.Errorf("can't export all dashboards: %w", err) + } + for _, dashboard := range allDashboards { + dashboardSavedObject, err := dashboardSavedObjectFromMapStr(dashboard) + if err != nil { + return nil, fmt.Errorf("can't parse dashboard saved object: %w", err) + } + foundObjects = append(foundObjects, dashboardSavedObject) + } + + sort.Slice(foundObjects, func(i, j int) bool { + return sort.StringsAreSorted([]string{strings.ToLower(foundObjects[i].Title), strings.ToLower(foundObjects[j].Title)}) + }) + return foundObjects, nil +} + func dashboardSavedObjectFromMapStr(data common.MapStr) (DashboardSavedObject, error) { dashboardId, ok := data["id"].(string) if !ok { From b6dccd21edf79902b836229442056f7a7fc291e8 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 12:17:52 +0200 Subject: [PATCH 10/20] Remove unused parameters and fields --- cmd/export_dashboards.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index 37b40a3f6c..c1004e31f6 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -112,7 +112,6 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error { type selectDashboardOptions struct { ctx context.Context - cmd *cobra.Command kibanaClient *kibana.Client kibanaVersion kibana.VersionInfo defaultPackage string @@ -262,7 +261,7 @@ func promptDashboardIDsServerless(ctx context.Context, kibanaClient *kibana.Clie return nil, fmt.Errorf("finding dashboards failed: %w", err) } - return promptDashboardIDs(ctx, savedDashboards) + return promptDashboardIDs(savedDashboards) } func promptDashboardIDsNonServerless(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) { @@ -271,10 +270,10 @@ func promptDashboardIDsNonServerless(ctx context.Context, kibanaClient *kibana.C return nil, fmt.Errorf("finding dashboards failed: %w", err) } - return promptDashboardIDs(ctx, savedDashboards) + return promptDashboardIDs(savedDashboards) } -func promptDashboardIDs(ctx context.Context, savedDashboards kibana.DashboardSavedObjects) ([]string, error) { +func promptDashboardIDs(savedDashboards kibana.DashboardSavedObjects) ([]string, error) { if len(savedDashboards) == 0 { return []string{}, nil } From a2b0823a43e8f69aaebd71693108eaeb87141340 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 14:10:05 +0200 Subject: [PATCH 11/20] Re-phrase new dashboard option --- cmd/export_dashboards.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index c1004e31f6..026b7677c9 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -26,7 +26,7 @@ const ( Use this command to download selected dashboards and other associated saved objects from Kibana. This command adjusts the downloaded saved objects according to package naming conventions (prefixes, unique IDs) and writes them locally into folders corresponding to saved object types (dashboard, visualization, map, etc.).` - newDashboardOption = "New dashboard" + newDashboardOption = "Working on a new dashboard (show all available dashboards)" ) func exportDashboardsCmd(cmd *cobra.Command, args []string) error { @@ -172,7 +172,8 @@ func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client, d return "", fmt.Errorf("finding installed packages failed: %w", err) } - // It always shows an option to export dashboards not related to any package. + // First option is always to list all available dashboards even if they are not related + // to any package. This is helpful in case the user is working on a new dashboard. options := []string{newDashboardOption} options = append(options, installedPackages.Strings()...) From 53cdeb3f87fb870d88574d97ca073de522f15f9c Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 14:10:24 +0200 Subject: [PATCH 12/20] Re-order function --- internal/kibana/dashboards.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/internal/kibana/dashboards.go b/internal/kibana/dashboards.go index 70a00ebeef..2dce04bb8a 100644 --- a/internal/kibana/dashboards.go +++ b/internal/kibana/dashboards.go @@ -32,20 +32,6 @@ func (c *Client) Export(ctx context.Context, dashboardIDs []string) ([]common.Ma return c.exportWithSavedObjectsAPI(ctx, dashboardIDs) } -// exportAllDashboards method exports all dashboards using the Kibana APIs without any export details nor including references. -// The number of exported dashboards depends on the "savedObjects.maxImportExportSize" setting, that by default is 10000. -func (c *Client) exportAllDashboards(ctx context.Context) ([]common.MapStr, error) { - logger.Debug("Export dashboards using the Kibana Saved Objects Export API") - - request := ExportSavedObjectsRequest{ - ExcludeExportDetails: true, - IncludeReferencesDeep: false, - Type: "dashboard", - } - - return c.ExportSavedObjects(ctx, request) -} - func (c *Client) exportWithSavedObjectsAPI(ctx context.Context, dashboardIDs []string) ([]common.MapStr, error) { logger.Debug("Export dashboards using the Kibana Saved Objects Export API") @@ -107,3 +93,17 @@ func (c *Client) exportWithDashboardsAPI(ctx context.Context, dashboardIDs []str } return exported.Objects, nil } + +// exportAllDashboards method exports all dashboards using the Kibana APIs without any export details nor including references. +// The number of exported dashboards depends on the "savedObjects.maxImportExportSize" setting, that by default is 10000. +func (c *Client) exportAllDashboards(ctx context.Context) ([]common.MapStr, error) { + logger.Debug("Export dashboards using the Kibana Saved Objects Export API") + + request := ExportSavedObjectsRequest{ + ExcludeExportDetails: true, + IncludeReferencesDeep: false, + Type: "dashboard", + } + + return c.ExportSavedObjects(ctx, request) +} From 9cb72c3cf7deddc3459acf8e5d90c8e4f93fef0c Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 14:15:24 +0200 Subject: [PATCH 13/20] Rename struct to get response from bulk_assets --- internal/kibana/packages.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/kibana/packages.go b/internal/kibana/packages.go index 74f80da1ab..118fc5dd09 100644 --- a/internal/kibana/packages.go +++ b/internal/kibana/packages.go @@ -48,16 +48,15 @@ func (ips InstalledPackages) Strings() []string { return entries } -type PackageAssetResponse struct { +type bulkAssetItemResponse struct { ID string `json:"id"` Type string `json:"type"` Attributes struct { - Description string `json:"description"` - Title string `json:"title"` + Title string `json:"title"` } `json:"attributes"` } -func (p *PackageAssetResponse) String() string { +func (p *bulkAssetItemResponse) String() string { return fmt.Sprintf("%s (ID: %s, Type: %s)", p.Attributes.Title, p.ID, p.Type) } @@ -305,7 +304,7 @@ func (c *Client) findInstalledPackagesNextPage(ctx context.Context, searchAfter // GetDataFromPackageAssetIDs retrieves data, such as title and description, for the given a list of asset IDs // using the "bulk_assets" Elastic Package Manager API endpoint. Response is sorted by title. -func (c *Client) GetDataFromPackageAssetIDs(ctx context.Context, assets []packages.Asset) ([]PackageAssetResponse, error) { +func (c *Client) GetDataFromPackageAssetIDs(ctx context.Context, assets []packages.Asset) ([]bulkAssetItemResponse, error) { path := fmt.Sprintf("%s/epm/bulk_assets", FleetAPI) type request struct { AssetIDs []packages.Asset `json:"assetIds"` @@ -324,7 +323,7 @@ func (c *Client) GetDataFromPackageAssetIDs(ctx context.Context, assets []packag } type packageAssetsResponse struct { - Items []PackageAssetResponse `json:"items"` + Items []bulkAssetItemResponse `json:"items"` } var resp packageAssetsResponse From 14db2d74a57d6b17a41a45e9fe0deb33451a9ad6 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 16:45:57 +0200 Subject: [PATCH 14/20] Make private more structs --- internal/kibana/packages.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/kibana/packages.go b/internal/kibana/packages.go index 118fc5dd09..6e5434a44b 100644 --- a/internal/kibana/packages.go +++ b/internal/kibana/packages.go @@ -23,24 +23,24 @@ var ErrNotSupported error = errors.New("not supported") type findInstalledPackagesResponse struct { // Installed packages are listed in Items field. - Items []InstalledPackage `json:"items"` + Items []installedPackage `json:"items"` Total int `json:"total"` SearchAfter []string `json:"searchAfter"` } -type InstalledPackages []InstalledPackage -type InstalledPackage struct { +type installedPackages []installedPackage +type installedPackage struct { Name string `json:"name"` Version string `json:"version"` } // String method returns string representation for an installed package. -func (ip *InstalledPackage) String() string { +func (ip *installedPackage) String() string { return fmt.Sprintf("%s-%s", ip.Name, ip.Version) } // Strings method returns string representation for a set of installed packages. -func (ips InstalledPackages) Strings() []string { +func (ips installedPackages) Strings() []string { var entries []string for _, ip := range ips { entries = append(entries, ip.String()) @@ -261,8 +261,8 @@ func processResults(action string, statusCode int, respBody []byte) ([]packages. } // FindInstalledPackages retrieves the current installed packages (name and version). Response is sorted by name. -func (c *Client) FindInstalledPackages(ctx context.Context) (InstalledPackages, error) { - var installed InstalledPackages +func (c *Client) FindInstalledPackages(ctx context.Context) (installedPackages, error) { + var installed installedPackages searchAfter := "" for { From 243539aefa57c1c5a71f1c9014af2c1421f2e0d2 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Tue, 21 Oct 2025 17:50:11 +0200 Subject: [PATCH 15/20] Delete comment --- internal/kibana/packages.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/kibana/packages.go b/internal/kibana/packages.go index 6e5434a44b..26af579a18 100644 --- a/internal/kibana/packages.go +++ b/internal/kibana/packages.go @@ -22,7 +22,6 @@ const findInstalledPackagesPerPage = 100 var ErrNotSupported error = errors.New("not supported") type findInstalledPackagesResponse struct { - // Installed packages are listed in Items field. Items []installedPackage `json:"items"` Total int `json:"total"` SearchAfter []string `json:"searchAfter"` @@ -302,7 +301,7 @@ func (c *Client) findInstalledPackagesNextPage(ctx context.Context, searchAfter return &resp, nil } -// GetDataFromPackageAssetIDs retrieves data, such as title and description, for the given a list of asset IDs +// GetDataFromPackageAssetIDs retrieves data, such as the title, for the given a list of asset IDs // using the "bulk_assets" Elastic Package Manager API endpoint. Response is sorted by title. func (c *Client) GetDataFromPackageAssetIDs(ctx context.Context, assets []packages.Asset) ([]bulkAssetItemResponse, error) { path := fmt.Sprintf("%s/epm/bulk_assets", FleetAPI) From 402339c61ba89d85ebf9c8fe20deec199bd2840c Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Wed, 22 Oct 2025 11:31:18 +0200 Subject: [PATCH 16/20] Update question to refer to just one package Co-authored-by: Jaime Soriano Pastor --- cmd/export_dashboards.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index 026b7677c9..234f45a71f 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -186,7 +186,7 @@ func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client, d } } - packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", options, defaultOption) + packagesPrompt := tui.NewSelect("Which package would you like to export dashboards from?", options, defaultOption) var selectedOption string err = tui.AskOne(packagesPrompt, &selectedOption, tui.Required) From 1069e50be9fcdf035c6317ca4b8c7f0bbb924cce Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Wed, 22 Oct 2025 11:31:59 +0200 Subject: [PATCH 17/20] Rephrase option to find a new dashboard Co-authored-by: Jaime Soriano Pastor --- cmd/export_dashboards.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index 234f45a71f..c40cd4e67c 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -26,7 +26,7 @@ const ( Use this command to download selected dashboards and other associated saved objects from Kibana. This command adjusts the downloaded saved objects according to package naming conventions (prefixes, unique IDs) and writes them locally into folders corresponding to saved object types (dashboard, visualization, map, etc.).` - newDashboardOption = "Working on a new dashboard (show all available dashboards)" + newDashboardOption = "The dashboard is not part of a package (show all available dashboards)" ) func exportDashboardsCmd(cmd *cobra.Command, args []string) error { From 7775a0a3ccd94e064df12433b8ab82f4e7a56dd9 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Wed, 22 Oct 2025 11:38:16 +0200 Subject: [PATCH 18/20] Set as last option to look for a new dashboard --- cmd/export_dashboards.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index c40cd4e67c..ae484ed40f 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -172,10 +172,7 @@ func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client, d return "", fmt.Errorf("finding installed packages failed: %w", err) } - // First option is always to list all available dashboards even if they are not related - // to any package. This is helpful in case the user is working on a new dashboard. - options := []string{newDashboardOption} - + options := []string{} options = append(options, installedPackages.Strings()...) defaultOption := "" for _, ip := range installedPackages { @@ -185,6 +182,9 @@ func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client, d break } } + // Latest option is always to list all available dashboards even if they are not related + // to any package. This is helpful in case the user is working on a new dashboard. + options = append(options, newDashboardOption) packagesPrompt := tui.NewSelect("Which package would you like to export dashboards from?", options, defaultOption) From f491e12c6fab078a577d3b269ab859509643f4c5 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Wed, 22 Oct 2025 11:46:07 +0200 Subject: [PATCH 19/20] Move logic from exportAllDashboards function to FindServerlessDashboards --- internal/kibana/dashboards.go | 14 -------------- internal/kibana/savedobjects.go | 12 ++++++++++-- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/internal/kibana/dashboards.go b/internal/kibana/dashboards.go index 2dce04bb8a..347b97f90f 100644 --- a/internal/kibana/dashboards.go +++ b/internal/kibana/dashboards.go @@ -93,17 +93,3 @@ func (c *Client) exportWithDashboardsAPI(ctx context.Context, dashboardIDs []str } return exported.Objects, nil } - -// exportAllDashboards method exports all dashboards using the Kibana APIs without any export details nor including references. -// The number of exported dashboards depends on the "savedObjects.maxImportExportSize" setting, that by default is 10000. -func (c *Client) exportAllDashboards(ctx context.Context) ([]common.MapStr, error) { - logger.Debug("Export dashboards using the Kibana Saved Objects Export API") - - request := ExportSavedObjectsRequest{ - ExcludeExportDetails: true, - IncludeReferencesDeep: false, - Type: "dashboard", - } - - return c.ExportSavedObjects(ctx, request) -} diff --git a/internal/kibana/savedobjects.go b/internal/kibana/savedobjects.go index d00ef561ed..e6e8566601 100644 --- a/internal/kibana/savedobjects.go +++ b/internal/kibana/savedobjects.go @@ -111,13 +111,21 @@ func (c *Client) findDashboardsNextPage(ctx context.Context, page int) (*savedOb } // FindServerlessDashboards method returns dashboards available in the Kibana instance. +// These dashboards are retrieved by exporting all dashboards using the Kibana iSaved Objects APIs without any export details nor including references. +// The number of exported dashboards depends on the "savedObjects.maxImportExportSize" setting, that by default is 10000. func (c *Client) FindServerlessDashboards(ctx context.Context) (DashboardSavedObjects, error) { logger.Debug("Find dashboards using the Export Objects API") - var foundObjects DashboardSavedObjects - allDashboards, err := c.exportAllDashboards(ctx) + request := ExportSavedObjectsRequest{ + ExcludeExportDetails: true, + IncludeReferencesDeep: false, + Type: "dashboard", + } + + allDashboards, err := c.ExportSavedObjects(ctx, request) if err != nil { return nil, fmt.Errorf("can't export all dashboards: %w", err) } + var foundObjects DashboardSavedObjects for _, dashboard := range allDashboards { dashboardSavedObject, err := dashboardSavedObjectFromMapStr(dashboard) if err != nil { From 1ab5ec38e584a567c29e7b1c06fdb72c06fbb3e1 Mon Sep 17 00:00:00 2001 From: Mario Rodriguez Molins Date: Wed, 22 Oct 2025 11:46:47 +0200 Subject: [PATCH 20/20] Rename function --- cmd/export_dashboards.go | 2 +- internal/kibana/savedobjects.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/export_dashboards.go b/cmd/export_dashboards.go index ae484ed40f..762208ff0f 100644 --- a/cmd/export_dashboards.go +++ b/cmd/export_dashboards.go @@ -257,7 +257,7 @@ func promptPackageDashboardIDs(ctx context.Context, kibanaClient *kibana.Client, } func promptDashboardIDsServerless(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) { - savedDashboards, err := kibanaClient.FindServerlessDashboards(ctx) + savedDashboards, err := kibanaClient.FindDashboardsWithExport(ctx) if err != nil { return nil, fmt.Errorf("finding dashboards failed: %w", err) } diff --git a/internal/kibana/savedobjects.go b/internal/kibana/savedobjects.go index e6e8566601..c4c0c84a9a 100644 --- a/internal/kibana/savedobjects.go +++ b/internal/kibana/savedobjects.go @@ -110,10 +110,10 @@ func (c *Client) findDashboardsNextPage(ctx context.Context, page int) (*savedOb return &r, nil } -// FindServerlessDashboards method returns dashboards available in the Kibana instance. +// FindDashboardsWithExport method returns dashboards available in the Kibana instance. // These dashboards are retrieved by exporting all dashboards using the Kibana iSaved Objects APIs without any export details nor including references. // The number of exported dashboards depends on the "savedObjects.maxImportExportSize" setting, that by default is 10000. -func (c *Client) FindServerlessDashboards(ctx context.Context) (DashboardSavedObjects, error) { +func (c *Client) FindDashboardsWithExport(ctx context.Context) (DashboardSavedObjects, error) { logger.Debug("Find dashboards using the Export Objects API") request := ExportSavedObjectsRequest{ ExcludeExportDetails: true,