Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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              
                       

)

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

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