Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dc54003
Export dashboards using EPM API endpoints
mrodm Oct 17, 2025
2fa7ec3
Just execute the new method on Serverless
mrodm Oct 17, 2025
66a1770
Select package from current directory as default
mrodm Oct 17, 2025
54be65a
Use same method to find dashboards in Serverless via export API
mrodm Oct 20, 2025
d843e20
Increase number of packages per page
mrodm Oct 20, 2025
7d6211b
Remove Response field in response - not present
mrodm Oct 20, 2025
d7b269b
Add comment
mrodm Oct 20, 2025
6542265
Check success variable in import saved objects response
mrodm Oct 21, 2025
a8b59fb
Apply same changes for edit command when running with Serverless
mrodm Oct 21, 2025
b6dccd2
Remove unused parameters and fields
mrodm Oct 21, 2025
a2b0823
Re-phrase new dashboard option
mrodm Oct 21, 2025
53cdeb3
Re-order function
mrodm Oct 21, 2025
9cb72c3
Rename struct to get response from bulk_assets
mrodm Oct 21, 2025
14db2d7
Make private more structs
mrodm Oct 21, 2025
243539a
Delete comment
mrodm Oct 21, 2025
402339c
Update question to refer to just one package
mrodm Oct 22, 2025
1069e50
Rephrase option to find a new dashboard
mrodm Oct 22, 2025
7775a0a
Set as last option to look for a new dashboard
mrodm Oct 22, 2025
f491e12
Move logic from exportAllDashboards function to FindServerlessDashboards
mrodm Oct 22, 2025
1ab5ec3
Rename function
mrodm Oct 22, 2025
0609cae
Inline functions to promt dashbaords
mrodm Oct 22, 2025
9d4417a
Use the new UX from stack versions 9.0.0 and Serverless
mrodm Oct 22, 2025
9d9c0e2
Fix imports
mrodm Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions cmd/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
Comment on lines +97 to +107
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could be helpful for the user to select the package where they are located if any. But this should not raise an error if the command does not run from a package.

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 {
Expand Down
191 changes: 186 additions & 5 deletions cmd/export_dashboards.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ package cmd
import (
"context"
"fmt"
"strings"

"github.com/elastic/elastic-package/internal/packages"
"github.com/elastic/elastic-package/internal/tui"

"github.com/spf13/cobra"
Expand All @@ -19,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 = "Working on a new dashboard (show all available dashboards)"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title for the option to be selected in order to show all the dashboards from the Serverless instance.

This option is shown the first one in the list.
Example:

 $ elastic-package -C ../integrations-main/packages/nginx export dashboards 
Export Kibana dashboards
Which packages would you like to export dashboards from? (nginx-2.3.2)
> Working on a new dashboard (show all available dashboards)                 
  elastic_package_registry-0.3.1
  fleet_server-1.6.0            
  nginx-2.3.2                   
  synthetics-1.4.2              
                       

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. While trying this I find the first option easy to ignore, it looks like part of the question. Maybe it is better to put it the last on the list? This is common in selectors when there are an "Other" option.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was one of the doubts when setting this new option.

Maybe it is better to put it the last on the list? This is common in selectors when there are an "Other" option.

That will be better, to think about it as an "Other" option. I'll move it to the last one.
Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example of how it is shown now with the latest changes:

 $ elastic-package -C ../integrations-main/packages/nginx export dashboards 
Export Kibana dashboards
Which package would you like to export dashboards from? (nginx-2.3.2)
  fleet_server-1.6.0                                                    
> nginx-2.3.2                                                           
  synthetics-1.4.2                                                      
  The dashboard is not part of a package (show all available dashboards)
                                                                        

enter continue • ctrl+c cancel

)

func exportDashboardsCmd(cmd *cobra.Command, args []string) error {
cmd.Println("Export Kibana dashboards")

Expand Down Expand Up @@ -67,10 +73,26 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error {
fmt.Printf("Warning: %s\n", message)
}

// Just query for dashboards if none were provided as flags
if len(dashboardIDs) == 0 {
dashboardIDs, err = promptDashboardIDs(cmd.Context(), kibanaClient)
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("prompt for dashboard selection failed: %w", err)
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 {
Expand All @@ -88,12 +110,171 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error {
return nil
}

func promptDashboardIDs(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) {
type selectDashboardOptions struct {
ctx context.Context
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(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 := promptDashboardIDsNonServerless(options.ctx, options.kibanaClient)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When/if this deprecated API is removed we can remove this if and use the same approach as in serverless.

Or do you think it would be worth to do this change now? After testing it a bit the UX of the serverless approach is actually pretty good.

Copy link
Contributor Author

@mrodm mrodm Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably this could be used now in other environments too, not just in Serverless.

I did some tests and it could be used in some 8.x stack versions but not all of them (e.g. this won't work in 8.6.0). However to be in the safest side, what about start using this method for instance in 9.0 (or maybe 9.1 or 9.2) @jsoriano ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied a change in 9d4417a to start using this new UX for other environments a part from Serverless, starting in stack versions 9.0.0.

WDYT ?

if err != nil {
return nil, fmt.Errorf("prompt for dashboard selection failed: %w", err)
}
return dashboardIDs, nil
}

installedPackage, err := promptPackagesInstalled(options.ctx, options.kibanaClient, options.defaultPackage)
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 := promptDashboardIDsServerless(options.ctx, options.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(options.ctx, options.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, defaultPackageName string) (string, error) {
installedPackages, err := kibanaClient.FindInstalledPackages(ctx)
if err != nil {
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 = append(options, installedPackages.Strings()...)
defaultOption := ""
for _, ip := range installedPackages {
if ip.Name == defaultPackageName {
// set default package to the one matching the package in the current directory
defaultOption = ip.String()
break
}
}
Comment on lines +194 to +200
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice detail 👍


packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", options, defaultOption)

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 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(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(savedDashboards)
}

func promptDashboardIDs(savedDashboards kibana.DashboardSavedObjects) ([]string, error) {
if len(savedDashboards) == 0 {
return []string{}, nil
}
Expand All @@ -102,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
}
Expand Down
14 changes: 14 additions & 0 deletions internal/kibana/dashboards.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,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)
}
Loading