Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
190 changes: 185 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 = "New dashboard"
)

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,170 @@ 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)
}

// It always shows an option to export dashboards not related to any package.
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 +282,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 @@ -32,6 +32,20 @@ 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)
}
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 method would return all the dashboards available in the Kibana Instance.

According to the documentation, the number of assets that will be exported depend on savedObjects.maxImportExportSize setting (link):

NOTE: The savedObjects.maxImportExportSize configuration setting limits the number of saved objects which may be exported.

By default that setting is 10000 elements (savedObjects.maxImportExportSize setting):

The maximum count of saved objects that can be imported or exported. This setting exists to prevent the Kibana server from running out of memory when handling large numbers of saved objects. It is recommended to only raise this setting if you are confident your server can hold this many objects in memory. Default: 10000

Copy link
Member

Choose a reason for hiding this comment

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

10K sounds like enough 🙂


func (c *Client) exportWithSavedObjectsAPI(ctx context.Context, dashboardIDs []string) ([]common.MapStr, error) {
logger.Debug("Export dashboards using the Kibana Saved Objects Export API")

Expand Down
Loading