From 597c0cefc0c3a04e2d2f7a5f1dcc770fb74ed00e Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Thu, 12 Mar 2026 00:30:07 +0100 Subject: [PATCH 1/7] add dashboard inspection commands --- internal/cli/dashboard.go | 162 +++++++++++++++++++++++ internal/client/dashboards.go | 33 +++++ internal/client/types.go | 33 +++++ internal/formatter/dashboard.go | 228 ++++++++++++++++++++++++++++++++ 4 files changed, 456 insertions(+) create mode 100644 internal/cli/dashboard.go create mode 100644 internal/client/dashboards.go create mode 100644 internal/formatter/dashboard.go diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go new file mode 100644 index 0000000..2093e8f --- /dev/null +++ b/internal/cli/dashboard.go @@ -0,0 +1,162 @@ +package cli + +import ( + "os" + "strconv" + + "github.com/andreagrandi/mb-cli/internal/client" + "github.com/andreagrandi/mb-cli/internal/formatter" + "github.com/spf13/cobra" +) + +var dashboardCmd = &cobra.Command{ + Use: "dashboard", + Short: "Dashboard commands", +} + +var dashboardListCmd = &cobra.Command{ + Use: "list", + Short: "List dashboards", + Args: cobra.NoArgs, + RunE: runDashboardList, +} + +var dashboardGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get dashboard details", + Args: cobra.ExactArgs(1), + RunE: runDashboardGet, +} + +var dashboardCardsCmd = &cobra.Command{ + Use: "cards ", + Short: "List cards in a dashboard", + Args: cobra.ExactArgs(1), + RunE: runDashboardCards, +} + +type dashboardListRow struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Archived bool `json:"archived"` +} + +type dashboardCardRow struct { + DashcardID int `json:"dashcard_id"` + Tab string `json:"tab"` + CardID string `json:"card_id,omitempty"` + Name string `json:"name,omitempty"` + QueryType string `json:"query_type,omitempty"` + Display string `json:"display,omitempty"` +} + +func init() { + rootCmd.AddCommand(dashboardCmd) + + dashboardCmd.AddCommand(dashboardListCmd) + dashboardCmd.AddCommand(dashboardGetCmd) + dashboardCmd.AddCommand(dashboardCardsCmd) +} + +func runDashboardList(cmd *cobra.Command, args []string) error { + c, err := newClient(cmd) + if err != nil { + return err + } + + dashboards, err := c.ListDashboards() + if err != nil { + return err + } + + rows := make([]dashboardListRow, 0, len(dashboards)) + for _, dashboard := range dashboards { + rows = append(rows, dashboardListRow{ + ID: dashboard.ID, + Name: dashboard.Name, + Description: dashboard.Description, + Archived: dashboard.Archived, + }) + } + + return formatter.Output(cmd, rows) +} + +func runDashboardGet(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + + c, err := newClient(cmd) + if err != nil { + return err + } + + dashboard, err := c.GetDashboard(id) + if err != nil { + return err + } + + format, _ := cmd.Flags().GetString("format") + if format == "json" { + return formatter.Output(cmd, dashboard) + } + + return formatter.FormatDashboardTable(dashboard, os.Stdout) +} + +func runDashboardCards(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + + c, err := newClient(cmd) + if err != nil { + return err + } + + dashboard, err := c.GetDashboard(id) + if err != nil { + return err + } + + return formatter.Output(cmd, buildDashboardCardRows(dashboard)) +} + +func buildDashboardCardRows(dashboard *client.Dashboard) []dashboardCardRow { + if dashboard == nil { + return nil + } + + tabNames := make(map[int]string, len(dashboard.Tabs)) + for _, tab := range dashboard.Tabs { + tabNames[tab.ID] = tab.Name + } + + rows := make([]dashboardCardRow, 0, len(dashboard.DashCards)) + for _, dashCard := range dashboard.DashCards { + row := dashboardCardRow{ + DashcardID: dashCard.ID, + Tab: "Ungrouped", + } + if dashCard.TabID != nil { + if name, ok := tabNames[*dashCard.TabID]; ok { + row.Tab = name + } + } + if dashCard.CardID != nil { + row.CardID = strconv.Itoa(*dashCard.CardID) + } + if dashCard.Card != nil { + row.Name = dashCard.Card.Name + row.QueryType = dashCard.Card.QueryType + row.Display = dashCard.Card.Display + } + rows = append(rows, row) + } + + return rows +} diff --git a/internal/client/dashboards.go b/internal/client/dashboards.go new file mode 100644 index 0000000..aabb6d1 --- /dev/null +++ b/internal/client/dashboards.go @@ -0,0 +1,33 @@ +package client + +import "fmt" + +// ListDashboards retrieves all dashboards. +func (c *Client) ListDashboards() ([]Dashboard, error) { + resp, err := c.Get("/api/dashboard/", nil) + if err != nil { + return nil, err + } + + var dashboards []Dashboard + if err := c.DecodeJSON(resp, &dashboards); err != nil { + return nil, err + } + + return dashboards, nil +} + +// GetDashboard retrieves a single dashboard by ID. +func (c *Client) GetDashboard(id int) (*Dashboard, error) { + resp, err := c.Get(fmt.Sprintf("/api/dashboard/%d", id), nil) + if err != nil { + return nil, err + } + + var dashboard Dashboard + if err := c.DecodeJSON(resp, &dashboard); err != nil { + return nil, err + } + + return &dashboard, nil +} diff --git a/internal/client/types.go b/internal/client/types.go index 028c030..5f88e01 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -120,6 +120,39 @@ type Card struct { Archived bool `json:"archived"` } +// Dashboard represents a Metabase dashboard. +type Dashboard struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DashCards []DashCard `json:"dashcards,omitempty"` + Parameters []DashParameter `json:"parameters,omitempty"` + Tabs []DashTab `json:"tabs,omitempty"` + Archived bool `json:"archived"` +} + +// DashCard represents a card placed on a dashboard. +type DashCard struct { + ID int `json:"id"` + CardID *int `json:"card_id,omitempty"` + Card *Card `json:"card,omitempty"` + TabID *int `json:"dashboard_tab_id,omitempty"` +} + +// DashParameter represents a dashboard filter parameter. +type DashParameter struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Type string `json:"type"` +} + +// DashTab represents a dashboard tab. +type DashTab struct { + ID int `json:"id"` + Name string `json:"name"` +} + // SearchResult represents an item returned by the Metabase search API. type SearchResult struct { ID int `json:"id"` diff --git a/internal/formatter/dashboard.go b/internal/formatter/dashboard.go new file mode 100644 index 0000000..506fdaf --- /dev/null +++ b/internal/formatter/dashboard.go @@ -0,0 +1,228 @@ +package formatter + +import ( + "fmt" + "io" + "sort" + "text/tabwriter" + + "github.com/andreagrandi/mb-cli/internal/client" +) + +const ungroupedDashboardTab = "Ungrouped" + +// FormatDashboardTable renders a dashboard in a human-readable table layout. +func FormatDashboardTable(dashboard *client.Dashboard, writer io.Writer) error { + if dashboard == nil { + _, err := fmt.Fprintln(writer, "No data") + return err + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintf(tw, "id\t%d\n", dashboard.ID) + fmt.Fprintf(tw, "name\t%s\n", dashboard.Name) + fmt.Fprintf(tw, "description\t%s\n", dashboard.Description) + fmt.Fprintf(tw, "archived\t%t\n", dashboard.Archived) + if err := tw.Flush(); err != nil { + return err + } + + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + + if err := formatDashboardTabs(dashboard.Tabs, writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + + if err := formatDashboardParameters(dashboard.Parameters, writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + + return formatDashboardCardsByTab(dashboard, writer) +} + +func formatDashboardTabs(tabs []client.DashTab, writer io.Writer) error { + if _, err := fmt.Fprintln(writer, "Tabs"); err != nil { + return err + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if len(tabs) == 0 { + if _, err := fmt.Fprintln(tw, "name\tUngrouped"); err != nil { + return err + } + return tw.Flush() + } + + if _, err := fmt.Fprintln(tw, "id\tname"); err != nil { + return err + } + for _, tab := range tabs { + if _, err := fmt.Fprintf(tw, "%d\t%s\n", tab.ID, tab.Name); err != nil { + return err + } + } + + return tw.Flush() +} + +func formatDashboardParameters(params []client.DashParameter, writer io.Writer) error { + if _, err := fmt.Fprintln(writer, "Parameters"); err != nil { + return err + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "id\tname\tslug\ttype"); err != nil { + return err + } + if len(params) == 0 { + if _, err := fmt.Fprintln(tw, "-\t-\t-\t-"); err != nil { + return err + } + return tw.Flush() + } + + for _, param := range params { + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", param.ID, param.Name, param.Slug, param.Type); err != nil { + return err + } + } + + return tw.Flush() +} + +func formatDashboardCardsByTab(dashboard *client.Dashboard, writer io.Writer) error { + if _, err := fmt.Fprintln(writer, "Cards"); err != nil { + return err + } + + tabNames := make(map[int]string, len(dashboard.Tabs)) + orderedTabs := make([]string, 0, len(dashboard.Tabs)+1) + for _, tab := range dashboard.Tabs { + tabNames[tab.ID] = tab.Name + orderedTabs = append(orderedTabs, tab.Name) + } + + grouped := make(map[string][]client.DashCard) + for _, dashCard := range dashboard.DashCards { + groupName := ungroupedDashboardTab + if dashCard.TabID != nil { + if name, ok := tabNames[*dashCard.TabID]; ok { + groupName = name + } + } + grouped[groupName] = append(grouped[groupName], dashCard) + } + + if _, ok := grouped[ungroupedDashboardTab]; ok || len(orderedTabs) == 0 { + orderedTabs = append(orderedTabs, ungroupedDashboardTab) + } + + seen := make(map[string]bool, len(orderedTabs)) + for _, tabName := range orderedTabs { + if seen[tabName] { + continue + } + seen[tabName] = true + cards := grouped[tabName] + if len(cards) == 0 { + continue + } + + if _, err := fmt.Fprintf(writer, "[%s]\n", tabName); err != nil { + return err + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tname\tquery_type\tdisplay"); err != nil { + return err + } + for _, dashCard := range cards { + cardID := "" + if dashCard.CardID != nil { + cardID = fmt.Sprintf("%d", *dashCard.CardID) + } + + name := "" + queryType := "" + display := "" + if dashCard.Card != nil { + name = dashCard.Card.Name + queryType = dashCard.Card.QueryType + display = dashCard.Card.Display + } + + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", dashCard.ID, cardID, name, queryType, display); err != nil { + return err + } + } + if err := tw.Flush(); err != nil { + return err + } + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + } + + if len(grouped) == 0 { + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tname\tquery_type\tdisplay"); err != nil { + return err + } + if _, err := fmt.Fprintln(tw, "-\t-\t-\t-\t-"); err != nil { + return err + } + return tw.Flush() + } + + leftovers := make([]string, 0, len(grouped)) + for tabName := range grouped { + if !seen[tabName] { + leftovers = append(leftovers, tabName) + } + } + sort.Strings(leftovers) + for _, tabName := range leftovers { + if _, err := fmt.Fprintf(writer, "[%s]\n", tabName); err != nil { + return err + } + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tname\tquery_type\tdisplay"); err != nil { + return err + } + for _, dashCard := range grouped[tabName] { + cardID := "" + if dashCard.CardID != nil { + cardID = fmt.Sprintf("%d", *dashCard.CardID) + } + + name := "" + queryType := "" + display := "" + if dashCard.Card != nil { + name = dashCard.Card.Name + queryType = dashCard.Card.QueryType + display = dashCard.Card.Display + } + + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", dashCard.ID, cardID, name, queryType, display); err != nil { + return err + } + } + if err := tw.Flush(); err != nil { + return err + } + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + } + + return nil +} From 4d9f2d2dbd455dabf97bcf84cf5a99c7d0f93fd7 Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Thu, 12 Mar 2026 00:32:09 +0100 Subject: [PATCH 2/7] expand card get with full query metadata --- internal/cli/card.go | 33 ++++++++++++++++++++++++++++++++- internal/client/cards.go | 10 ++++++++-- internal/client/types.go | 39 +++++++++++++++++++++++++++------------ 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/internal/cli/card.go b/internal/cli/card.go index 1f623fd..0ca2d01 100644 --- a/internal/cli/card.go +++ b/internal/cli/card.go @@ -4,6 +4,7 @@ import ( "os" "strconv" + "github.com/andreagrandi/mb-cli/internal/client" "github.com/andreagrandi/mb-cli/internal/formatter" "github.com/spf13/cobra" ) @@ -34,6 +35,17 @@ var cardRunCmd = &cobra.Command{ RunE: runCardRun, } +type cardSummary struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DatabaseID int `json:"database_id"` + Display string `json:"display"` + QueryType string `json:"query_type,omitempty"` + CollectionID *int `json:"collection_id,omitempty"` + Archived bool `json:"archived"` +} + func init() { rootCmd.AddCommand(cardCmd) @@ -41,6 +53,7 @@ func init() { cardCmd.AddCommand(cardGetCmd) cardCmd.AddCommand(cardRunCmd) + cardGetCmd.Flags().Bool("full", false, "Include the full query definition and card metadata") cardRunCmd.Flags().String("fields", "", "Comma-separated list of columns to include in output") } @@ -74,7 +87,12 @@ func runCardGet(cmd *cobra.Command, args []string) error { return err } - return formatter.Output(cmd, card) + full, _ := cmd.Flags().GetBool("full") + if full { + return formatter.Output(cmd, card) + } + + return formatter.Output(cmd, summarizeCard(card)) } func runCardRun(cmd *cobra.Command, args []string) error { @@ -104,3 +122,16 @@ func runCardRun(cmd *cobra.Command, args []string) error { columns, rows := formatter.FilterColumns(columns, result.Data.Rows, fields) return formatter.FormatQueryResults(format, columns, rows, os.Stdout) } + +func summarizeCard(card *client.Card) cardSummary { + return cardSummary{ + ID: card.ID, + Name: card.Name, + Description: card.Description, + DatabaseID: card.DatabaseID, + Display: card.Display, + QueryType: card.QueryType, + CollectionID: card.CollectionID, + Archived: card.Archived, + } +} diff --git a/internal/client/cards.go b/internal/client/cards.go index 2d8bba1..d4c341e 100644 --- a/internal/client/cards.go +++ b/internal/client/cards.go @@ -1,6 +1,9 @@ package client -import "fmt" +import ( + "fmt" + "net/url" +) // ListCards retrieves all saved questions (cards). func (c *Client) ListCards() ([]Card, error) { @@ -19,7 +22,10 @@ func (c *Client) ListCards() ([]Card, error) { // GetCard retrieves a single card by ID. func (c *Client) GetCard(id int) (*Card, error) { - resp, err := c.Get(fmt.Sprintf("/api/card/%d", id), nil) + params := url.Values{} + params.Set("legacy-mbql", "true") + + resp, err := c.Get(fmt.Sprintf("/api/card/%d", id), params) if err != nil { return nil, err } diff --git a/internal/client/types.go b/internal/client/types.go index 5f88e01..5cd0bad 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -98,26 +98,41 @@ type DatasetQuery struct { // NativeQuery represents the native SQL query part of a dataset query. type NativeQuery struct { - Query string `json:"query"` + Query string `json:"query"` + TemplateTags map[string]TemplateTag `json:"template-tags,omitempty"` +} + +// TemplateTag represents a native query template tag. +type TemplateTag struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DisplayName string `json:"display-name,omitempty"` + Type string `json:"type,omitempty"` + WidgetType string `json:"widget-type,omitempty"` + Required bool `json:"required,omitempty"` } // StructuredQuery represents an MBQL structured query. type StructuredQuery struct { - SourceTable int `json:"source-table"` - Filter []any `json:"filter,omitempty"` - Limit int `json:"limit,omitempty"` + SourceTable int `json:"source-table"` + SourceCardID *int `json:"source-card,omitempty"` + Filter []any `json:"filter,omitempty"` + Limit int `json:"limit,omitempty"` } // Card represents a Metabase saved question (card). type Card struct { - ID int `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - DatabaseID int `json:"database_id"` - Display string `json:"display"` - QueryType string `json:"query_type,omitempty"` - CollectionID *int `json:"collection_id,omitempty"` - Archived bool `json:"archived"` + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DatabaseID int `json:"database_id"` + Display string `json:"display"` + QueryType string `json:"query_type,omitempty"` + CollectionID *int `json:"collection_id,omitempty"` + Archived bool `json:"archived"` + DatasetQuery *DatasetQuery `json:"dataset_query,omitempty"` + ResultMetadata []Field `json:"result_metadata,omitempty"` + VisualizationSettings map[string]any `json:"visualization_settings,omitempty"` } // Dashboard represents a Metabase dashboard. From 745da0ef078f13f36294be9e656889c37548a6e8 Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Thu, 12 Mar 2026 00:35:54 +0100 Subject: [PATCH 3/7] add dashboard parameter value lookup commands --- internal/cli/dashboard.go | 150 ++++++++++++++++++++++++++ internal/client/dashboards.go | 35 +++++- internal/client/types.go | 63 ++++++++++- internal/formatter/dashboard.go | 186 +++++++++++++++++++++++--------- 4 files changed, 381 insertions(+), 53 deletions(-) diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go index 2093e8f..2a88a71 100644 --- a/internal/cli/dashboard.go +++ b/internal/cli/dashboard.go @@ -1,8 +1,11 @@ package cli import ( + "encoding/json" + "fmt" "os" "strconv" + "strings" "github.com/andreagrandi/mb-cli/internal/client" "github.com/andreagrandi/mb-cli/internal/formatter" @@ -35,6 +38,25 @@ var dashboardCardsCmd = &cobra.Command{ RunE: runDashboardCards, } +var dashboardParamsCmd = &cobra.Command{ + Use: "params", + Short: "Dashboard parameter commands", +} + +var dashboardParamsValuesCmd = &cobra.Command{ + Use: "values ", + Short: "List valid values for a dashboard parameter", + Args: cobra.ExactArgs(2), + RunE: runDashboardParamValues, +} + +var dashboardParamsSearchCmd = &cobra.Command{ + Use: "search ", + Short: "Search valid values for a dashboard parameter", + Args: cobra.ExactArgs(3), + RunE: runDashboardParamSearch, +} + type dashboardListRow struct { ID int `json:"id"` Name string `json:"name"` @@ -51,12 +73,34 @@ type dashboardCardRow struct { Display string `json:"display,omitempty"` } +type dashboardParamLookupResult struct { + DashboardID int `json:"dashboard_id"` + RequestedKey string `json:"requested_key"` + ResolvedKey string `json:"resolved_key"` + Parameter *client.DashParameter `json:"parameter,omitempty"` + MappedCards []dashboardParamMappingRow `json:"mapped_cards,omitempty"` + HasMoreValues bool `json:"has_more_values"` + Values []client.ParameterValue `json:"values"` + Query string `json:"query,omitempty"` +} + +type dashboardParamMappingRow struct { + DashcardID int `json:"dashcard_id"` + CardID string `json:"card_id,omitempty"` + CardName string `json:"card_name,omitempty"` + Target string `json:"target,omitempty"` +} + func init() { rootCmd.AddCommand(dashboardCmd) dashboardCmd.AddCommand(dashboardListCmd) dashboardCmd.AddCommand(dashboardGetCmd) dashboardCmd.AddCommand(dashboardCardsCmd) + dashboardCmd.AddCommand(dashboardParamsCmd) + + dashboardParamsCmd.AddCommand(dashboardParamsValuesCmd) + dashboardParamsCmd.AddCommand(dashboardParamsSearchCmd) } func runDashboardList(cmd *cobra.Command, args []string) error { @@ -160,3 +204,109 @@ func buildDashboardCardRows(dashboard *client.Dashboard) []dashboardCardRow { return rows } + +func runDashboardParamValues(cmd *cobra.Command, args []string) error { + return runDashboardParamLookup(cmd, args[0], args[1], "", false) +} + +func runDashboardParamSearch(cmd *cobra.Command, args []string) error { + return runDashboardParamLookup(cmd, args[0], args[1], args[2], true) +} + +func runDashboardParamLookup(cmd *cobra.Command, dashboardArg string, requestedKey string, query string, search bool) error { + dashboardID, err := strconv.Atoi(dashboardArg) + if err != nil { + return err + } + + c, err := newClient(cmd) + if err != nil { + return err + } + + dashboard, err := c.GetDashboard(dashboardID) + if err != nil { + return err + } + + parameter, resolvedKey := resolveDashboardParameter(dashboard, requestedKey) + + var values *client.ParameterValues + if search { + values, err = c.SearchDashboardParamValues(dashboardID, resolvedKey, query) + } else { + values, err = c.GetDashboardParamValues(dashboardID, resolvedKey) + } + if err != nil { + return err + } + + format, _ := cmd.Flags().GetString("format") + if format == "json" { + return formatter.Output(cmd, dashboardParamLookupResult{ + DashboardID: dashboardID, + RequestedKey: requestedKey, + ResolvedKey: resolvedKey, + Parameter: parameter, + MappedCards: buildDashboardParamMappingRows(dashboard, parameter), + HasMoreValues: values.HasMoreValues, + Values: values.Values, + Query: query, + }) + } + + return formatter.FormatDashboardParameterValuesTable(dashboard, parameter, values, os.Stdout) +} + +func resolveDashboardParameter(dashboard *client.Dashboard, input string) (*client.DashParameter, string) { + for i := range dashboard.Parameters { + parameter := &dashboard.Parameters[i] + if parameter.ID == input || parameter.Slug == input || strings.EqualFold(parameter.Name, input) { + return parameter, parameter.ID + } + } + + return nil, input +} + +func buildDashboardParamMappingRows(dashboard *client.Dashboard, parameter *client.DashParameter) []dashboardParamMappingRow { + if dashboard == nil || parameter == nil { + return nil + } + + rows := make([]dashboardParamMappingRow, 0) + for _, dashCard := range dashboard.DashCards { + for _, mapping := range dashCard.ParameterMappings { + if mapping.ParameterID != parameter.ID { + continue + } + + row := dashboardParamMappingRow{ + DashcardID: dashCard.ID, + Target: stringifyDashboardTarget(mapping.Target), + } + if dashCard.CardID != nil { + row.CardID = strconv.Itoa(*dashCard.CardID) + } + if dashCard.Card != nil { + row.CardName = dashCard.Card.Name + } + rows = append(rows, row) + } + } + + return rows +} + +func stringifyDashboardTarget(target []any) string { + if len(target) == 0 { + return "" + } + + data, err := json.Marshal(target) + if err != nil { + return fmt.Sprintf("%v", target) + } + + return string(data) +} diff --git a/internal/client/dashboards.go b/internal/client/dashboards.go index aabb6d1..be0d151 100644 --- a/internal/client/dashboards.go +++ b/internal/client/dashboards.go @@ -1,6 +1,9 @@ package client -import "fmt" +import ( + "fmt" + "net/url" +) // ListDashboards retrieves all dashboards. func (c *Client) ListDashboards() ([]Dashboard, error) { @@ -31,3 +34,33 @@ func (c *Client) GetDashboard(id int) (*Dashboard, error) { return &dashboard, nil } + +// GetDashboardParamValues retrieves valid values for a dashboard parameter. +func (c *Client) GetDashboardParamValues(dashboardID int, paramKey string) (*ParameterValues, error) { + resp, err := c.Get(fmt.Sprintf("/api/dashboard/%d/params/%s/values", dashboardID, url.PathEscape(paramKey)), nil) + if err != nil { + return nil, err + } + + var values ParameterValues + if err := c.DecodeJSON(resp, &values); err != nil { + return nil, err + } + + return &values, nil +} + +// SearchDashboardParamValues searches dashboard parameter values. +func (c *Client) SearchDashboardParamValues(dashboardID int, paramKey string, query string) (*ParameterValues, error) { + resp, err := c.Get(fmt.Sprintf("/api/dashboard/%d/params/%s/search/%s", dashboardID, url.PathEscape(paramKey), url.PathEscape(query)), nil) + if err != nil { + return nil, err + } + + var values ParameterValues + if err := c.DecodeJSON(resp, &values); err != nil { + return nil, err + } + + return &values, nil +} diff --git a/internal/client/types.go b/internal/client/types.go index 5cd0bad..1941187 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -1,5 +1,10 @@ package client +import ( + "encoding/json" + "fmt" +) + // Database represents a Metabase database. type Database struct { ID int `json:"id"` @@ -148,10 +153,11 @@ type Dashboard struct { // DashCard represents a card placed on a dashboard. type DashCard struct { - ID int `json:"id"` - CardID *int `json:"card_id,omitempty"` - Card *Card `json:"card,omitempty"` - TabID *int `json:"dashboard_tab_id,omitempty"` + ID int `json:"id"` + CardID *int `json:"card_id,omitempty"` + Card *Card `json:"card,omitempty"` + TabID *int `json:"dashboard_tab_id,omitempty"` + ParameterMappings []DashParameterMapping `json:"parameter_mappings,omitempty"` } // DashParameter represents a dashboard filter parameter. @@ -168,6 +174,55 @@ type DashTab struct { Name string `json:"name"` } +// DashParameterMapping describes how a dashboard parameter maps to a card target. +type DashParameterMapping struct { + CardID int `json:"card_id,omitempty"` + ParameterID string `json:"parameter_id"` + Target []any `json:"target,omitempty"` +} + +// ParameterValues represents valid values for a dashboard parameter. +type ParameterValues struct { + Values []ParameterValue `json:"values"` + HasMoreValues bool `json:"has_more_values"` +} + +// ParameterValue represents a value and optional display label for a parameter. +type ParameterValue struct { + Value any `json:"value"` + Label string `json:"label,omitempty"` +} + +// UnmarshalJSON supports Metabase parameter value tuples: [value] or [value, label]. +func (p *ParameterValue) UnmarshalJSON(data []byte) error { + var tuple []any + if err := json.Unmarshal(data, &tuple); err == nil { + switch len(tuple) { + case 0: + p.Value = nil + p.Label = "" + return nil + case 1: + p.Value = tuple[0] + p.Label = "" + return nil + default: + p.Value = tuple[0] + p.Label = fmt.Sprintf("%v", tuple[1]) + return nil + } + } + + var scalar any + if err := json.Unmarshal(data, &scalar); err != nil { + return err + } + + p.Value = scalar + p.Label = "" + return nil +} + // SearchResult represents an item returned by the Metabase search API. type SearchResult struct { ID int `json:"id"` diff --git a/internal/formatter/dashboard.go b/internal/formatter/dashboard.go index 506fdaf..dd9048b 100644 --- a/internal/formatter/dashboard.go +++ b/internal/formatter/dashboard.go @@ -1,6 +1,7 @@ package formatter import ( + "encoding/json" "fmt" "io" "sort" @@ -139,31 +140,7 @@ func formatDashboardCardsByTab(dashboard *client.Dashboard, writer io.Writer) er if _, err := fmt.Fprintf(writer, "[%s]\n", tabName); err != nil { return err } - - tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) - if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tname\tquery_type\tdisplay"); err != nil { - return err - } - for _, dashCard := range cards { - cardID := "" - if dashCard.CardID != nil { - cardID = fmt.Sprintf("%d", *dashCard.CardID) - } - - name := "" - queryType := "" - display := "" - if dashCard.Card != nil { - name = dashCard.Card.Name - queryType = dashCard.Card.QueryType - display = dashCard.Card.Display - } - - if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", dashCard.ID, cardID, name, queryType, display); err != nil { - return err - } - } - if err := tw.Flush(); err != nil { + if err := renderDashboardCardsTable(cards, writer); err != nil { return err } if _, err := fmt.Fprintln(writer); err != nil { @@ -172,14 +149,7 @@ func formatDashboardCardsByTab(dashboard *client.Dashboard, writer io.Writer) er } if len(grouped) == 0 { - tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) - if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tname\tquery_type\tdisplay"); err != nil { - return err - } - if _, err := fmt.Fprintln(tw, "-\t-\t-\t-\t-"); err != nil { - return err - } - return tw.Flush() + return renderDashboardCardsTable(nil, writer) } leftovers := make([]string, 0, len(grouped)) @@ -193,36 +163,156 @@ func formatDashboardCardsByTab(dashboard *client.Dashboard, writer io.Writer) er if _, err := fmt.Fprintf(writer, "[%s]\n", tabName); err != nil { return err } - tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) - if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tname\tquery_type\tdisplay"); err != nil { + if err := renderDashboardCardsTable(grouped[tabName], writer); err != nil { return err } - for _, dashCard := range grouped[tabName] { + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + } + + return nil +} + +// FormatDashboardParameterValuesTable renders parameter metadata, mappings, and values. +func FormatDashboardParameterValuesTable(dashboard *client.Dashboard, parameter *client.DashParameter, values *client.ParameterValues, writer io.Writer) error { + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintf(tw, "dashboard_id\t%d\n", dashboard.ID) + if parameter != nil { + fmt.Fprintf(tw, "parameter_id\t%s\n", parameter.ID) + fmt.Fprintf(tw, "parameter_name\t%s\n", parameter.Name) + fmt.Fprintf(tw, "parameter_slug\t%s\n", parameter.Slug) + fmt.Fprintf(tw, "parameter_type\t%s\n", parameter.Type) + } + fmt.Fprintf(tw, "has_more_values\t%t\n", values.HasMoreValues) + if err := tw.Flush(); err != nil { + return err + } + + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + + if err := formatDashboardParameterMappings(dashboard, parameter, writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + + if _, err := fmt.Fprintln(writer, "Values"); err != nil { + return err + } + tw = tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "value\tlabel"); err != nil { + return err + } + if len(values.Values) == 0 { + if _, err := fmt.Fprintln(tw, "-\t-"); err != nil { + return err + } + return tw.Flush() + } + for _, value := range values.Values { + if _, err := fmt.Fprintf(tw, "%s\t%s\n", stringify(value.Value), value.Label); err != nil { + return err + } + } + return tw.Flush() +} + +func formatDashboardParameterMappings(dashboard *client.Dashboard, parameter *client.DashParameter, writer io.Writer) error { + if _, err := fmt.Fprintln(writer, "Mapped Cards"); err != nil { + return err + } + + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tcard_name\ttarget"); err != nil { + return err + } + if parameter == nil { + if _, err := fmt.Fprintln(tw, "-\t-\t-\t-"); err != nil { + return err + } + return tw.Flush() + } + + found := false + for _, dashCard := range dashboard.DashCards { + for _, mapping := range dashCard.ParameterMappings { + if mapping.ParameterID != parameter.ID { + continue + } + + found = true cardID := "" if dashCard.CardID != nil { cardID = fmt.Sprintf("%d", *dashCard.CardID) } - - name := "" - queryType := "" - display := "" + cardName := "" if dashCard.Card != nil { - name = dashCard.Card.Name - queryType = dashCard.Card.QueryType - display = dashCard.Card.Display + cardName = dashCard.Card.Name } - - if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", dashCard.ID, cardID, name, queryType, display); err != nil { + target := stringifyMappingTarget(mapping.Target) + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", dashCard.ID, cardID, cardName, target); err != nil { return err } } - if err := tw.Flush(); err != nil { + } + + if !found { + if _, err := fmt.Fprintln(tw, "-\t-\t-\t-"); err != nil { return err } - if _, err := fmt.Fprintln(writer); err != nil { + } + + return tw.Flush() +} + +func renderDashboardCardsTable(cards []client.DashCard, writer io.Writer) error { + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "dashcard_id\tcard_id\tname\tquery_type\tdisplay"); err != nil { + return err + } + if len(cards) == 0 { + if _, err := fmt.Fprintln(tw, "-\t-\t-\t-\t-"); err != nil { return err } + return tw.Flush() } - return nil + for _, dashCard := range cards { + cardID := "" + if dashCard.CardID != nil { + cardID = fmt.Sprintf("%d", *dashCard.CardID) + } + + name := "" + queryType := "" + display := "" + if dashCard.Card != nil { + name = dashCard.Card.Name + queryType = dashCard.Card.QueryType + display = dashCard.Card.Display + } + + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", dashCard.ID, cardID, name, queryType, display); err != nil { + return err + } + } + + return tw.Flush() +} + +func stringifyMappingTarget(target []any) string { + if len(target) == 0 { + return "" + } + + data, err := json.Marshal(target) + if err != nil { + return fmt.Sprintf("%v", target) + } + + return string(data) } From 3430298bb15e77200fe30bc7ee7d7e8ac5fa065b Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Thu, 12 Mar 2026 00:39:50 +0100 Subject: [PATCH 4/7] add parameterized card and dashboard execution --- internal/cli/card.go | 62 +++++++++++++++--- internal/cli/dashboard.go | 43 +++++++++++++ internal/client/cards.go | 28 ++++++++ internal/client/dashboards.go | 19 ++++++ internal/client/parameters.go | 116 ++++++++++++++++++++++++++++++++++ internal/client/types.go | 8 +++ 6 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 internal/client/parameters.go diff --git a/internal/cli/card.go b/internal/cli/card.go index 0ca2d01..bf13a34 100644 --- a/internal/cli/card.go +++ b/internal/cli/card.go @@ -1,8 +1,10 @@ package cli import ( + "fmt" "os" "strconv" + "strings" "github.com/andreagrandi/mb-cli/internal/client" "github.com/andreagrandi/mb-cli/internal/formatter" @@ -55,6 +57,7 @@ func init() { cardGetCmd.Flags().Bool("full", false, "Include the full query definition and card metadata") cardRunCmd.Flags().String("fields", "", "Comma-separated list of columns to include in output") + cardRunCmd.Flags().StringSlice("param", nil, "Parameter in key=value format (repeatable)") } func runCardList(cmd *cobra.Command, args []string) error { @@ -106,21 +109,17 @@ func runCardRun(cmd *cobra.Command, args []string) error { return err } - result, err := c.RunCard(id) + params, err := parseNamedParams(cmd) if err != nil { return err } - format, _ := cmd.Flags().GetString("format") - fields, _ := cmd.Flags().GetString("fields") - - columns := make([]string, len(result.Data.Columns)) - for i, col := range result.Data.Columns { - columns[i] = col.Name + result, err := c.RunCardWithParams(id, params) + if err != nil { + return wrapParameterizedRunError(err) } - columns, rows := formatter.FilterColumns(columns, result.Data.Rows, fields) - return formatter.FormatQueryResults(format, columns, rows, os.Stdout) + return formatQueryResultOutput(cmd, result) } func summarizeCard(card *client.Card) cardSummary { @@ -135,3 +134,48 @@ func summarizeCard(card *client.Card) cardSummary { Archived: card.Archived, } } + +func parseNamedParams(cmd *cobra.Command) (map[string]string, error) { + values, err := cmd.Flags().GetStringSlice("param") + if err != nil { + return nil, err + } + if len(values) == 0 { + return nil, nil + } + + params := make(map[string]string, len(values)) + for _, value := range values { + parts := strings.SplitN(value, "=", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { + return nil, fmt.Errorf("invalid parameter %q: expected key=value", value) + } + params[strings.TrimSpace(parts[0])] = parts[1] + } + + return params, nil +} + +func formatQueryResultOutput(cmd *cobra.Command, result *client.QueryResult) error { + format, _ := cmd.Flags().GetString("format") + fields, _ := cmd.Flags().GetString("fields") + + columns := make([]string, len(result.Data.Columns)) + for i, col := range result.Data.Columns { + columns[i] = col.Name + } + + columns, rows := formatter.FilterColumns(columns, result.Data.Rows, fields) + return formatter.FormatQueryResults(format, columns, rows, os.Stdout) +} + +func wrapParameterizedRunError(err error) error { + message := err.Error() + if strings.Contains(message, "API request failed with status 400") { + return fmt.Errorf("parameterized query failed: check parameter keys and values (%w)", err) + } + if strings.Contains(message, "API request failed with status 404") { + return fmt.Errorf("query target was not found (%w)", err) + } + return err +} diff --git a/internal/cli/dashboard.go b/internal/cli/dashboard.go index 2a88a71..eb51252 100644 --- a/internal/cli/dashboard.go +++ b/internal/cli/dashboard.go @@ -38,6 +38,13 @@ var dashboardCardsCmd = &cobra.Command{ RunE: runDashboardCards, } +var dashboardRunCardCmd = &cobra.Command{ + Use: "run-card ", + Short: "Execute a dashboard card with parameters", + Args: cobra.ExactArgs(3), + RunE: runDashboardRunCard, +} + var dashboardParamsCmd = &cobra.Command{ Use: "params", Short: "Dashboard parameter commands", @@ -97,10 +104,14 @@ func init() { dashboardCmd.AddCommand(dashboardListCmd) dashboardCmd.AddCommand(dashboardGetCmd) dashboardCmd.AddCommand(dashboardCardsCmd) + dashboardCmd.AddCommand(dashboardRunCardCmd) dashboardCmd.AddCommand(dashboardParamsCmd) dashboardParamsCmd.AddCommand(dashboardParamsValuesCmd) dashboardParamsCmd.AddCommand(dashboardParamsSearchCmd) + + dashboardRunCardCmd.Flags().String("fields", "", "Comma-separated list of columns to include in output") + dashboardRunCardCmd.Flags().StringSlice("param", nil, "Parameter in key=value format (repeatable)") } func runDashboardList(cmd *cobra.Command, args []string) error { @@ -213,6 +224,38 @@ func runDashboardParamSearch(cmd *cobra.Command, args []string) error { return runDashboardParamLookup(cmd, args[0], args[1], args[2], true) } +func runDashboardRunCard(cmd *cobra.Command, args []string) error { + dashboardID, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + dashcardID, err := strconv.Atoi(args[1]) + if err != nil { + return err + } + cardID, err := strconv.Atoi(args[2]) + if err != nil { + return err + } + + params, err := parseNamedParams(cmd) + if err != nil { + return err + } + + c, err := newClient(cmd) + if err != nil { + return err + } + + result, err := c.RunDashboardCard(dashboardID, dashcardID, cardID, params) + if err != nil { + return wrapParameterizedRunError(err) + } + + return formatQueryResultOutput(cmd, result) +} + func runDashboardParamLookup(cmd *cobra.Command, dashboardArg string, requestedKey string, query string, search bool) error { dashboardID, err := strconv.Atoi(dashboardArg) if err != nil { diff --git a/internal/client/cards.go b/internal/client/cards.go index d4c341e..db645fb 100644 --- a/internal/client/cards.go +++ b/internal/client/cards.go @@ -2,6 +2,7 @@ package client import ( "fmt" + "net/http" "net/url" ) @@ -45,6 +46,33 @@ func (c *Client) RunCard(id int) (*QueryResult, error) { return nil, err } + return c.decodeCardQueryResult(resp) +} + +// RunCardWithParams executes a saved question with parameter values. +func (c *Client) RunCardWithParams(id int, params map[string]string) (*QueryResult, error) { + if len(params) == 0 { + return c.RunCard(id) + } + + card, err := c.GetCard(id) + if err != nil { + return nil, err + } + + body := map[string]any{ + "parameters": buildCardQueryParameters(card, params), + } + + resp, err := c.Post(fmt.Sprintf("/api/card/%d/query", id), body) + if err != nil { + return nil, err + } + + return c.decodeCardQueryResult(resp) +} + +func (c *Client) decodeCardQueryResult(resp *http.Response) (*QueryResult, error) { var result QueryResult if err := c.DecodeJSON(resp, &result); err != nil { return nil, err diff --git a/internal/client/dashboards.go b/internal/client/dashboards.go index be0d151..167a84a 100644 --- a/internal/client/dashboards.go +++ b/internal/client/dashboards.go @@ -64,3 +64,22 @@ func (c *Client) SearchDashboardParamValues(dashboardID int, paramKey string, qu return &values, nil } + +// RunDashboardCard executes a dashboard card with parameter values. +func (c *Client) RunDashboardCard(dashboardID, dashcardID, cardID int, params map[string]string) (*QueryResult, error) { + dashboard, err := c.GetDashboard(dashboardID) + if err != nil { + return nil, err + } + + body := map[string]any{ + "parameters": buildDashboardQueryParameters(dashboard, params), + } + + resp, err := c.Post(fmt.Sprintf("/api/dashboard/%d/dashcard/%d/card/%d/query", dashboardID, dashcardID, cardID), body) + if err != nil { + return nil, err + } + + return c.decodeCardQueryResult(resp) +} diff --git a/internal/client/parameters.go b/internal/client/parameters.go new file mode 100644 index 0000000..b5485bb --- /dev/null +++ b/internal/client/parameters.go @@ -0,0 +1,116 @@ +package client + +import ( + "encoding/json" + "strconv" + "strings" +) + +func buildCardQueryParameters(card *Card, params map[string]string) []QueryParameter { + if len(params) == 0 { + return nil + } + + resolved := make([]QueryParameter, 0, len(params)) + tags := map[string]TemplateTag(nil) + if card != nil && card.DatasetQuery != nil && card.DatasetQuery.Native != nil { + tags = card.DatasetQuery.Native.TemplateTags + } + + for key, rawValue := range params { + param := QueryParameter{ + ID: key, + Value: coerceQueryParameterValue(rawValue), + } + if _, tag, ok := resolveTemplateTag(tags, key); ok && tag.ID != "" { + param.ID = tag.ID + } + resolved = append(resolved, param) + } + + return resolved +} + +func buildDashboardQueryParameters(dashboard *Dashboard, params map[string]string) []QueryParameter { + if len(params) == 0 { + return nil + } + + resolved := make([]QueryParameter, 0, len(params)) + for key, rawValue := range params { + param := QueryParameter{ + ID: key, + Value: coerceQueryParameterValue(rawValue), + } + if dashboardParam, ok := resolveDashboardParameterValue(dashboard, key); ok { + param.ID = dashboardParam.ID + param.Type = dashboardParam.Type + } + resolved = append(resolved, param) + } + + return resolved +} + +func resolveTemplateTag(tags map[string]TemplateTag, input string) (string, TemplateTag, bool) { + if len(tags) == 0 { + return "", TemplateTag{}, false + } + if tag, ok := tags[input]; ok { + return input, tag, true + } + + for key, tag := range tags { + if tag.ID == input || strings.EqualFold(tag.Name, input) || strings.EqualFold(tag.DisplayName, input) { + return key, tag, true + } + } + + return "", TemplateTag{}, false +} + +func resolveDashboardParameterValue(dashboard *Dashboard, input string) (*DashParameter, bool) { + if dashboard == nil { + return nil, false + } + for i := range dashboard.Parameters { + parameter := &dashboard.Parameters[i] + if parameter.ID == input || parameter.Slug == input || strings.EqualFold(parameter.Name, input) { + return parameter, true + } + } + + return nil, false +} + +func coerceQueryParameterValue(raw string) any { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + + if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, `"`) { + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil { + return decoded + } + } + + switch strings.ToLower(trimmed) { + case "true": + return true + case "false": + return false + case "null": + return nil + } + + if i, err := strconv.Atoi(trimmed); err == nil { + return i + } + if f, err := strconv.ParseFloat(trimmed, 64); err == nil { + return f + } + + return raw +} diff --git a/internal/client/types.go b/internal/client/types.go index 1941187..6c451b2 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -193,6 +193,14 @@ type ParameterValue struct { Label string `json:"label,omitempty"` } +// QueryParameter represents a parameter passed to a card or dashboard query. +type QueryParameter struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Target []any `json:"target,omitempty"` + Value any `json:"value"` +} + // UnmarshalJSON supports Metabase parameter value tuples: [value] or [value, label]. func (p *ParameterValue) UnmarshalJSON(data []byte) error { var tuple []any From 4f86427d06fd24630da287783cd91f6f4cfdc2db Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Thu, 12 Mar 2026 00:44:50 +0100 Subject: [PATCH 5/7] add dashboard dependency analysis command --- internal/cli/dashboard_analyze.go | 553 ++++++++++++++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 internal/cli/dashboard_analyze.go diff --git a/internal/cli/dashboard_analyze.go b/internal/cli/dashboard_analyze.go new file mode 100644 index 0000000..2a59918 --- /dev/null +++ b/internal/cli/dashboard_analyze.go @@ -0,0 +1,553 @@ +package cli + +import ( + "fmt" + "io" + "os" + "regexp" + "sort" + "strconv" + "strings" + "text/tabwriter" + + "github.com/andreagrandi/mb-cli/internal/client" + "github.com/andreagrandi/mb-cli/internal/formatter" + "github.com/spf13/cobra" +) + +var ( + assumptionQueryPattern = regexp.MustCompile(`(?i)\b(sample|placeholder|example|demo|assum(?:e|ed|ption)|estimate|estimated)\b`) + hardcodedQueryPattern = regexp.MustCompile(`(?i)\b(where|and|or)\b[^;\n]*(=|>=|<=|>|<)\s*('[^']+'|\d{2,})`) +) + +var dashboardAnalyzeCmd = &cobra.Command{ + Use: "analyze ", + Short: "Summarize dashboard structure and dependencies", + Args: cobra.ExactArgs(1), + RunE: runDashboardAnalyze, +} + +type dashboardAnalysis struct { + DashboardID int `json:"dashboard_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tabs []dashboardAnalysisTab `json:"tabs"` + Dashcards []dashboardAnalysisDashcard `json:"dashcards"` + Parameters []dashboardAnalysisParameter `json:"parameters"` + BaseCards []dashboardAnalysisBaseCard `json:"base_cards"` + FlaggedCards []dashboardAnalysisFlagged `json:"flagged_cards,omitempty"` + TotalDashcards int `json:"total_dashcards"` + TotalParameters int `json:"total_parameters"` +} + +type dashboardAnalysisTab struct { + ID int `json:"id,omitempty"` + Name string `json:"name"` + DashcardIDs []int `json:"dashcard_ids,omitempty"` + CardIDs []int `json:"card_ids,omitempty"` +} + +type dashboardAnalysisDashcard struct { + DashcardID int `json:"dashcard_id"` + Tab string `json:"tab"` + CardID *int `json:"card_id,omitempty"` + Name string `json:"name,omitempty"` + QueryType string `json:"query_type,omitempty"` + Display string `json:"display,omitempty"` + ParameterIDs []string `json:"parameter_ids,omitempty"` + SourceCardID *int `json:"source_card_id,omitempty"` + SourceCardChain []int `json:"source_card_chain,omitempty"` + BaseCardID *int `json:"base_card_id,omitempty"` + Flags []string `json:"flags,omitempty"` +} + +type dashboardAnalysisParameter struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Type string `json:"type"` + MappedCards []dashboardParamMappingRow `json:"mapped_cards,omitempty"` +} + +type dashboardAnalysisBaseCard struct { + CardID int `json:"card_id"` + Name string `json:"name,omitempty"` + QueryType string `json:"query_type,omitempty"` + ReferencedByDashcards []int `json:"referenced_by_dashcards,omitempty"` + Flags []string `json:"flags,omitempty"` +} + +type dashboardAnalysisFlagged struct { + CardID int `json:"card_id"` + Name string `json:"name,omitempty"` + Flags []string `json:"flags"` +} + +func init() { + dashboardCmd.AddCommand(dashboardAnalyzeCmd) +} + +func runDashboardAnalyze(cmd *cobra.Command, args []string) error { + id, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + + c, err := newClient(cmd) + if err != nil { + return err + } + + analysis, err := analyzeDashboard(c, id) + if err != nil { + return err + } + + format, _ := cmd.Flags().GetString("format") + if format == "json" { + return formatter.Output(cmd, analysis) + } + + return formatDashboardAnalysisTable(os.Stdout, analysis) +} + +func analyzeDashboard(c *client.Client, id int) (*dashboardAnalysis, error) { + dashboard, err := c.GetDashboard(id) + if err != nil { + return nil, err + } + + analysis := &dashboardAnalysis{ + DashboardID: dashboard.ID, + Name: dashboard.Name, + Description: dashboard.Description, + Parameters: make([]dashboardAnalysisParameter, 0, len(dashboard.Parameters)), + Dashcards: make([]dashboardAnalysisDashcard, 0, len(dashboard.DashCards)), + Tabs: buildDashboardAnalysisTabs(dashboard), + TotalDashcards: len(dashboard.DashCards), + TotalParameters: len(dashboard.Parameters), + } + + for _, parameter := range dashboard.Parameters { + analysis.Parameters = append(analysis.Parameters, dashboardAnalysisParameter{ + ID: parameter.ID, + Name: parameter.Name, + Slug: parameter.Slug, + Type: parameter.Type, + MappedCards: buildDashboardParamMappingRows(dashboard, ¶meter), + }) + } + + tabNames := make(map[int]string, len(dashboard.Tabs)) + for _, tab := range dashboard.Tabs { + tabNames[tab.ID] = tab.Name + } + + cardCache := make(map[int]*client.Card) + baseCards := make(map[int]*dashboardAnalysisBaseCard) + flaggedCards := make(map[int]*dashboardAnalysisFlagged) + + for _, dashCard := range dashboard.DashCards { + entry := dashboardAnalysisDashcard{ + DashcardID: dashCard.ID, + Tab: "Ungrouped", + CardID: dashCard.CardID, + } + if dashCard.TabID != nil { + if tabName, ok := tabNames[*dashCard.TabID]; ok { + entry.Tab = tabName + } + } + if dashCard.Card != nil { + entry.Name = dashCard.Card.Name + entry.QueryType = dashCard.Card.QueryType + entry.Display = dashCard.Card.Display + } + entry.ParameterIDs = collectDashcardParameterIDs(dashCard) + + if dashCard.CardID != nil { + fullCard, chain, baseCard, err := traceCardLineage(c, cardCache, *dashCard.CardID) + if err != nil { + return nil, err + } + if fullCard != nil { + if entry.Name == "" { + entry.Name = fullCard.Name + } + if entry.QueryType == "" { + entry.QueryType = fullCard.QueryType + } + if entry.Display == "" { + entry.Display = fullCard.Display + } + entry.Flags = analyzeCardFlags(fullCard) + if len(entry.Flags) > 0 { + flaggedCards[fullCard.ID] = &dashboardAnalysisFlagged{CardID: fullCard.ID, Name: fullCard.Name, Flags: entry.Flags} + } + if sourceCardID := extractSourceCardID(fullCard); sourceCardID != nil { + entry.SourceCardID = sourceCardID + } + } + if len(chain) > 0 { + entry.SourceCardChain = chain + } + if baseCard != nil { + baseCardID := baseCard.ID + entry.BaseCardID = &baseCardID + baseEntry, ok := baseCards[baseCard.ID] + if !ok { + baseEntry = &dashboardAnalysisBaseCard{ + CardID: baseCard.ID, + Name: baseCard.Name, + QueryType: baseCard.QueryType, + Flags: analyzeCardFlags(baseCard), + } + baseCards[baseCard.ID] = baseEntry + } + baseEntry.ReferencedByDashcards = append(baseEntry.ReferencedByDashcards, dashCard.ID) + if len(baseEntry.Flags) > 0 { + flaggedCards[baseCard.ID] = &dashboardAnalysisFlagged{CardID: baseCard.ID, Name: baseCard.Name, Flags: baseEntry.Flags} + } + } + } + + analysis.Dashcards = append(analysis.Dashcards, entry) + } + + analysis.BaseCards = flattenBaseCards(baseCards) + analysis.FlaggedCards = flattenFlaggedCards(flaggedCards) + + return analysis, nil +} + +func buildDashboardAnalysisTabs(dashboard *client.Dashboard) []dashboardAnalysisTab { + tabIndexes := make(map[int]int, len(dashboard.Tabs)) + tabs := make([]dashboardAnalysisTab, 0, len(dashboard.Tabs)+1) + for _, tab := range dashboard.Tabs { + tabs = append(tabs, dashboardAnalysisTab{ID: tab.ID, Name: tab.Name}) + tabIndexes[tab.ID] = len(tabs) - 1 + } + + ungroupedIndex := -1 + for _, dashCard := range dashboard.DashCards { + cardID := 0 + if dashCard.CardID != nil { + cardID = *dashCard.CardID + } + if dashCard.TabID == nil { + if ungroupedIndex == -1 { + tabs = append(tabs, dashboardAnalysisTab{Name: "Ungrouped"}) + ungroupedIndex = len(tabs) - 1 + } + tabs[ungroupedIndex].DashcardIDs = append(tabs[ungroupedIndex].DashcardIDs, dashCard.ID) + if cardID != 0 { + tabs[ungroupedIndex].CardIDs = append(tabs[ungroupedIndex].CardIDs, cardID) + } + continue + } + + if tabIndex, ok := tabIndexes[*dashCard.TabID]; ok { + tabs[tabIndex].DashcardIDs = append(tabs[tabIndex].DashcardIDs, dashCard.ID) + if cardID != 0 { + tabs[tabIndex].CardIDs = append(tabs[tabIndex].CardIDs, cardID) + } + } + } + + return tabs +} + +func collectDashcardParameterIDs(dashCard client.DashCard) []string { + if len(dashCard.ParameterMappings) == 0 { + return nil + } + + ids := make([]string, 0, len(dashCard.ParameterMappings)) + seen := make(map[string]bool, len(dashCard.ParameterMappings)) + for _, mapping := range dashCard.ParameterMappings { + if seen[mapping.ParameterID] { + continue + } + seen[mapping.ParameterID] = true + ids = append(ids, mapping.ParameterID) + } + sort.Strings(ids) + return ids +} + +func traceCardLineage(c *client.Client, cache map[int]*client.Card, cardID int) (*client.Card, []int, *client.Card, error) { + current, err := getAnalyzedCard(c, cache, cardID) + if err != nil { + return nil, nil, nil, err + } + + chain := make([]int, 0) + seen := map[int]bool{cardID: true} + for current != nil { + sourceCardID := extractSourceCardID(current) + if sourceCardID == nil || seen[*sourceCardID] { + return cache[cardID], chain, current, nil + } + chain = append(chain, *sourceCardID) + seen[*sourceCardID] = true + + current, err = getAnalyzedCard(c, cache, *sourceCardID) + if err != nil { + return nil, nil, nil, err + } + } + + return cache[cardID], chain, nil, nil +} + +func getAnalyzedCard(c *client.Client, cache map[int]*client.Card, cardID int) (*client.Card, error) { + if card, ok := cache[cardID]; ok { + return card, nil + } + + card, err := c.GetCard(cardID) + if err != nil { + return nil, err + } + cache[cardID] = card + return card, nil +} + +func extractSourceCardID(card *client.Card) *int { + if card == nil || card.DatasetQuery == nil || card.DatasetQuery.Query == nil { + return nil + } + return card.DatasetQuery.Query.SourceCardID +} + +func analyzeCardFlags(card *client.Card) []string { + if card == nil || card.DatasetQuery == nil { + return nil + } + + flags := make([]string, 0) + if card.DatasetQuery.Native != nil { + query := card.DatasetQuery.Native.Query + lowerQuery := strings.ToLower(query) + if assumptionQueryPattern.MatchString(lowerQuery) { + flags = append(flags, "mentions sample or assumption data") + } + if hardcodedQueryPattern.MatchString(query) { + flags = append(flags, "contains hardcoded filter literals") + } + } + if card.DatasetQuery.Query != nil && len(card.DatasetQuery.Query.Filter) > 0 { + flags = append(flags, "contains fixed MBQL filters") + } + + return uniqueStrings(flags) +} + +func flattenBaseCards(baseCards map[int]*dashboardAnalysisBaseCard) []dashboardAnalysisBaseCard { + ids := make([]int, 0, len(baseCards)) + for id := range baseCards { + ids = append(ids, id) + } + sort.Ints(ids) + + results := make([]dashboardAnalysisBaseCard, 0, len(ids)) + for _, id := range ids { + entry := baseCards[id] + sort.Ints(entry.ReferencedByDashcards) + entry.ReferencedByDashcards = uniqueInts(entry.ReferencedByDashcards) + results = append(results, *entry) + } + return results +} + +func flattenFlaggedCards(flaggedCards map[int]*dashboardAnalysisFlagged) []dashboardAnalysisFlagged { + ids := make([]int, 0, len(flaggedCards)) + for id := range flaggedCards { + ids = append(ids, id) + } + sort.Ints(ids) + + results := make([]dashboardAnalysisFlagged, 0, len(ids)) + for _, id := range ids { + entry := flaggedCards[id] + entry.Flags = uniqueStrings(entry.Flags) + results = append(results, *entry) + } + return results +} + +func formatDashboardAnalysisTable(writer io.Writer, analysis *dashboardAnalysis) error { + tw := tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + fmt.Fprintf(tw, "dashboard_id\t%d\n", analysis.DashboardID) + fmt.Fprintf(tw, "name\t%s\n", analysis.Name) + fmt.Fprintf(tw, "description\t%s\n", analysis.Description) + fmt.Fprintf(tw, "total_dashcards\t%d\n", analysis.TotalDashcards) + fmt.Fprintf(tw, "total_parameters\t%d\n", analysis.TotalParameters) + fmt.Fprintf(tw, "unique_base_cards\t%d\n", len(analysis.BaseCards)) + fmt.Fprintf(tw, "flagged_cards\t%d\n", len(analysis.FlaggedCards)) + if err := tw.Flush(); err != nil { + return err + } + + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer, "Tabs"); err != nil { + return err + } + tw = tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "id\tname\tdashcards\tcards"); err != nil { + return err + } + for _, tab := range analysis.Tabs { + id := "" + if tab.ID != 0 { + id = strconv.Itoa(tab.ID) + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", id, tab.Name, joinInts(tab.DashcardIDs), joinInts(tab.CardIDs)); err != nil { + return err + } + } + if err := tw.Flush(); err != nil { + return err + } + + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer, "Dashcards"); err != nil { + return err + } + tw = tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "dashcard_id\ttab\tcard_id\tname\tquery_type\tsource_card_id\tbase_card_id\tparams\tflags"); err != nil { + return err + } + for _, dashCard := range analysis.Dashcards { + cardID := "" + if dashCard.CardID != nil { + cardID = strconv.Itoa(*dashCard.CardID) + } + sourceCardID := "" + if dashCard.SourceCardID != nil { + sourceCardID = strconv.Itoa(*dashCard.SourceCardID) + } + baseCardID := "" + if dashCard.BaseCardID != nil { + baseCardID = strconv.Itoa(*dashCard.BaseCardID) + } + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", dashCard.DashcardID, dashCard.Tab, cardID, dashCard.Name, dashCard.QueryType, sourceCardID, baseCardID, strings.Join(dashCard.ParameterIDs, ","), strings.Join(dashCard.Flags, "; ")); err != nil { + return err + } + } + if err := tw.Flush(); err != nil { + return err + } + + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer, "Parameters"); err != nil { + return err + } + tw = tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "id\tslug\ttype\tmapped_cards"); err != nil { + return err + } + for _, parameter := range analysis.Parameters { + mapped := make([]string, 0, len(parameter.MappedCards)) + for _, row := range parameter.MappedCards { + label := row.CardID + if row.CardName != "" { + label = fmt.Sprintf("%s:%s", row.CardID, row.CardName) + } + mapped = append(mapped, label) + } + if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", parameter.ID, parameter.Slug, parameter.Type, strings.Join(mapped, ", ")); err != nil { + return err + } + } + if err := tw.Flush(); err != nil { + return err + } + + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer, "Base Cards"); err != nil { + return err + } + tw = tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "card_id\tname\tquery_type\treferenced_by_dashcards\tflags"); err != nil { + return err + } + for _, baseCard := range analysis.BaseCards { + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", baseCard.CardID, baseCard.Name, baseCard.QueryType, joinInts(baseCard.ReferencedByDashcards), strings.Join(baseCard.Flags, "; ")); err != nil { + return err + } + } + if err := tw.Flush(); err != nil { + return err + } + + if len(analysis.FlaggedCards) == 0 { + return nil + } + + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer, "Flagged Cards"); err != nil { + return err + } + tw = tabwriter.NewWriter(writer, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "card_id\tname\tflags"); err != nil { + return err + } + for _, flagged := range analysis.FlaggedCards { + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\n", flagged.CardID, flagged.Name, strings.Join(flagged.Flags, "; ")); err != nil { + return err + } + } + return tw.Flush() +} + +func joinInts(values []int) string { + parts := make([]string, 0, len(values)) + for _, value := range uniqueInts(values) { + parts = append(parts, strconv.Itoa(value)) + } + return strings.Join(parts, ",") +} + +func uniqueInts(values []int) []int { + if len(values) == 0 { + return nil + } + result := make([]int, 0, len(values)) + seen := make(map[int]bool, len(values)) + for _, value := range values { + if seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func uniqueStrings(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + if seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + sort.Strings(result) + return result +} From 601767e285866d3e32699285aec8749c6d9ee9ce Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Thu, 12 Mar 2026 00:47:08 +0100 Subject: [PATCH 6/7] improve dashboard query safety and errors --- internal/cli/root.go | 10 +++++++++- internal/client/cards.go | 17 ++++++++++------- internal/client/dashboards.go | 16 ++++++++++------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 0e9f587..1c2fbde 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -72,9 +72,17 @@ func classifyError(err error) (errorType, suggestion string) { switch { case strings.Contains(msg, "MB_HOST") || strings.Contains(msg, "MB_API_KEY"): return "CONFIG_ERROR", "Set MB_HOST and MB_API_KEY environment variables" + case strings.Contains(msg, "parameterized query failed"): + return "API_ERROR", "Check parameter IDs with 'mb-cli dashboard get ' or 'mb-cli card get --full'" case strings.Contains(msg, "API request failed with status 401"), strings.Contains(msg, "API request failed with status 403"): - return "AUTH_ERROR", "Check that MB_API_KEY is valid" + return "AUTH_ERROR", "Check that MB_API_KEY is valid and can access the requested resource" + case strings.Contains(msg, "failed to get dashboard") && strings.Contains(msg, "status 404"): + return "API_ERROR", "Check that the dashboard ID exists and is visible to this API key" + case strings.Contains(msg, "failed to get card") && strings.Contains(msg, "status 404"): + return "API_ERROR", "Check that the card ID exists and is visible to this API key" + case strings.Contains(msg, "failed to get values for dashboard") && strings.Contains(msg, "status 404"): + return "API_ERROR", "Check that the dashboard parameter ID exists for this dashboard" case strings.Contains(msg, "API request failed with status"): return "API_ERROR", "" case strings.Contains(msg, "no database matching"), diff --git a/internal/client/cards.go b/internal/client/cards.go index db645fb..0cf7e87 100644 --- a/internal/client/cards.go +++ b/internal/client/cards.go @@ -10,7 +10,7 @@ import ( func (c *Client) ListCards() ([]Card, error) { resp, err := c.Get("/api/card/", nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to list cards: %w", err) } var cards []Card @@ -28,7 +28,7 @@ func (c *Client) GetCard(id int) (*Card, error) { resp, err := c.Get(fmt.Sprintf("/api/card/%d", id), params) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get card %d: %w", id, err) } var card Card @@ -43,10 +43,10 @@ func (c *Client) GetCard(id int) (*Card, error) { func (c *Client) RunCard(id int) (*QueryResult, error) { resp, err := c.Post(fmt.Sprintf("/api/card/%d/query", id), nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to run card %d: %w", id, err) } - return c.decodeCardQueryResult(resp) + return c.decodeCardQueryResult(resp, 0) } // RunCardWithParams executes a saved question with parameter values. @@ -66,19 +66,22 @@ func (c *Client) RunCardWithParams(id int, params map[string]string) (*QueryResu resp, err := c.Post(fmt.Sprintf("/api/card/%d/query", id), body) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to run card %d: %w", id, err) } - return c.decodeCardQueryResult(resp) + return c.decodeCardQueryResult(resp, card.DatabaseID) } -func (c *Client) decodeCardQueryResult(resp *http.Response) (*QueryResult, error) { +func (c *Client) decodeCardQueryResult(resp *http.Response, databaseID int) (*QueryResult, error) { var result QueryResult if err := c.DecodeJSON(resp, &result); err != nil { return nil, err } if c.RedactPII { + if databaseID > 0 { + c.EnrichSemanticTypes(&result, databaseID) + } RedactQueryResult(&result) } diff --git a/internal/client/dashboards.go b/internal/client/dashboards.go index 167a84a..ee51f55 100644 --- a/internal/client/dashboards.go +++ b/internal/client/dashboards.go @@ -9,7 +9,7 @@ import ( func (c *Client) ListDashboards() ([]Dashboard, error) { resp, err := c.Get("/api/dashboard/", nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to list dashboards: %w", err) } var dashboards []Dashboard @@ -24,7 +24,7 @@ func (c *Client) ListDashboards() ([]Dashboard, error) { func (c *Client) GetDashboard(id int) (*Dashboard, error) { resp, err := c.Get(fmt.Sprintf("/api/dashboard/%d", id), nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get dashboard %d: %w", id, err) } var dashboard Dashboard @@ -39,7 +39,7 @@ func (c *Client) GetDashboard(id int) (*Dashboard, error) { func (c *Client) GetDashboardParamValues(dashboardID int, paramKey string) (*ParameterValues, error) { resp, err := c.Get(fmt.Sprintf("/api/dashboard/%d/params/%s/values", dashboardID, url.PathEscape(paramKey)), nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get values for dashboard %d parameter %s: %w", dashboardID, paramKey, err) } var values ParameterValues @@ -54,7 +54,7 @@ func (c *Client) GetDashboardParamValues(dashboardID int, paramKey string) (*Par func (c *Client) SearchDashboardParamValues(dashboardID int, paramKey string, query string) (*ParameterValues, error) { resp, err := c.Get(fmt.Sprintf("/api/dashboard/%d/params/%s/search/%s", dashboardID, url.PathEscape(paramKey), url.PathEscape(query)), nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to search values for dashboard %d parameter %s: %w", dashboardID, paramKey, err) } var values ParameterValues @@ -71,6 +71,10 @@ func (c *Client) RunDashboardCard(dashboardID, dashcardID, cardID int, params ma if err != nil { return nil, err } + card, err := c.GetCard(cardID) + if err != nil { + return nil, err + } body := map[string]any{ "parameters": buildDashboardQueryParameters(dashboard, params), @@ -78,8 +82,8 @@ func (c *Client) RunDashboardCard(dashboardID, dashcardID, cardID int, params ma resp, err := c.Post(fmt.Sprintf("/api/dashboard/%d/dashcard/%d/card/%d/query", dashboardID, dashcardID, cardID), body) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to run dashboard %d card %d via dashcard %d: %w", dashboardID, cardID, dashcardID, err) } - return c.decodeCardQueryResult(resp) + return c.decodeCardQueryResult(resp, card.DatabaseID) } From def76e1b3e93bad8a452dc75a1d8594059cd8497 Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Thu, 12 Mar 2026 00:56:20 +0100 Subject: [PATCH 7/7] document dashboard workflows and prepare v0.1.4 release --- CHANGELOG.md | 6 + README.md | 71 ++++++++ internal/cli/context_embed.md | 27 +++ internal/client/types.go | 2 +- internal/version/version.go | 2 +- tests/cards_test.go | 114 +++++++++++++ tests/cli_test_helper.go | 29 ++++ tests/context_test.go | 2 + tests/dashboard_test.go | 309 ++++++++++++++++++++++++++++++++++ tests/error_format_test.go | 12 ++ 10 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 tests/cli_test_helper.go create mode 100644 tests/dashboard_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index aece36d..ea4571d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.1.4] - 2026-03-12 + +- Add dashboard inspection commands for listing dashboards, viewing tabs and dashcards, and summarizing dashboard dependencies +- Expand card inspection with `card get --full`, dashboard parameter lookup, and parameterized card/dashboard execution +- Improve dashboard query safety with clearer error messages and redaction-aware parameterized query handling + ## [0.1.3] - 2026-03-05 - PII redaction enabled by default: query result columns with Metabase PII semantic types (Email, Name, Phone, etc.) are replaced with `[REDACTED]` diff --git a/README.md b/README.md index 328aef3..02841c6 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,39 @@ mb-cli card list # Get card details mb-cli card get 10 +# Get the full card definition, including dataset_query and template tags +mb-cli card get 10 --full + # Execute a saved question mb-cli card run 10 + +# Execute a saved question with parameters +mb-cli card run 10 --param timeframe_days=14 +``` + +### Dashboards + +```bash +# List all dashboards +mb-cli dashboard list + +# Get dashboard metadata, tabs, filters, and grouped cards +mb-cli dashboard get 298 + +# List the saved questions used by a dashboard +mb-cli dashboard cards 298 + +# Discover valid values for a dashboard filter +mb-cli dashboard params values 298 merchant_name + +# Search dashboard filter values +mb-cli dashboard params search 298 merchant_name acme + +# Execute a dashboard card with dashboard parameters applied +mb-cli dashboard run-card 298 1201 398 --param merchant_name="Acme Corp" + +# Summarize tabs, parameter mappings, and source-card dependencies +mb-cli dashboard analyze 298 ``` ### Search @@ -175,8 +206,48 @@ mb-cli table metadata 42 # 4. Query data mb-cli query sql --db 1 --sql "SELECT id, email FROM users WHERE created_at > '2024-01-01' LIMIT 10" + +# 5. Inspect a dashboard that depends on saved questions +mb-cli dashboard analyze 298 ``` +## Dashboard analysis workflow + +Example flow for dashboard `298`: + +```bash +# 1. Inspect dashboard structure +mb-cli dashboard get 298 + +# 2. List the saved questions behind the dashboard +mb-cli dashboard cards 298 + +# 3. Inspect a card's full SQL or MBQL definition +mb-cli card get 398 --full + +# 4. Discover valid parameter values +mb-cli dashboard params values 298 merchant_name + +# 5. Summarize dependencies and assumption-backed cards +mb-cli dashboard analyze 298 +``` + +## API coverage + +The dashboard and parameter workflows use these Metabase endpoints: + +| Command | Endpoint | +|---------|----------| +| `dashboard list` | `GET /api/dashboard/` | +| `dashboard get` | `GET /api/dashboard/:id` | +| `dashboard cards` | `GET /api/dashboard/:id` | +| `dashboard params values` | `GET /api/dashboard/:id/params/:param-key/values` | +| `dashboard params search` | `GET /api/dashboard/:id/params/:param-key/search/:query` | +| `dashboard run-card` | `POST /api/dashboard/:dashboard-id/dashcard/:dashcard-id/card/:card-id/query` | +| `card get --full` | `GET /api/card/:id` | +| `card run --param` | `POST /api/card/:card-id/query` | +| `dashboard analyze` | `GET /api/dashboard/:id`, `GET /api/card/:id` | + ## PII Redaction When AI agents use mb-cli directly (via shell commands), query results containing PII (emails, names, phone numbers) flow through stdout into the model's context. This feature prevents agents from seeing sensitive data by redacting PII columns before data leaves the client layer. Agents can still cross-reference records using IDs; for actual PII values, the user can check directly in Metabase. diff --git a/internal/cli/context_embed.md b/internal/cli/context_embed.md index facaaea..bc3ecdc 100644 --- a/internal/cli/context_embed.md +++ b/internal/cli/context_embed.md @@ -31,6 +31,13 @@ Set both environment variables (required): | `card list` | List saved questions | none | none | | `card get ` | Get card details | id (positional) | none | | `card run ` | Execute a saved question | id (positional) | none | +| `dashboard list` | List dashboards | none | none | +| `dashboard get ` | Get dashboard details | id (positional) | none | +| `dashboard cards ` | List cards used by a dashboard | id (positional) | none | +| `dashboard analyze ` | Summarize dashboard dependencies | id (positional) | none | +| `dashboard run-card ` | Execute a dashboard card | dashboard-id, dashcard-id, card-id (positional) | none | +| `dashboard params values ` | List valid dashboard parameter values | dashboard-id, param-key (positional) | none | +| `dashboard params search ` | Search dashboard parameter values | dashboard-id, param-key, query (positional) | none | | `search ` | Search across Metabase items | query (positional) | none | | `context` | Print this agent context document | none | none | | `version` | Print version | none | none | @@ -69,6 +76,18 @@ Set both environment variables (required): | Flag | Type | Required | Default | Description | |------|------|----------|---------|-------------| | `--fields` | string | no | | Comma-separated columns to include in output | +| `--param` | string[] | no | | Parameter in `key=value` format (repeatable) | + +### `card get` +| Flag | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `--full` | bool | no | false | Include `dataset_query`, template tags, result metadata, and visualization settings | + +### `dashboard run-card` +| Flag | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `--fields` | string | no | | Comma-separated columns to include in output | +| `--param` | string[] | no | | Parameter in `key=value` format (repeatable) | ### `search` | Flag | Type | Required | Default | Description | @@ -118,6 +137,8 @@ When stdout is not a TTY (piped to another program), the default format is `json Query result commands (`query sql`, `query filter`, `card run`, `table data`) format output as column/row tables in both formats. +Dashboard inspection commands default to concise summaries in table mode. Use `--format json` for full raw dashboard or analysis payloads. + ## Structured Error Output Use `--error-format json` to get machine-readable errors on stderr: @@ -183,6 +204,12 @@ mb-cli query filter --db 1 --table products --where "id=prod_1234" --export csv mb-cli card list mb-cli card run 5 +# Inspect dashboard structure and dependencies +mb-cli dashboard get 298 +mb-cli dashboard cards 298 +mb-cli dashboard params values 298 merchant_name +mb-cli dashboard analyze 298 + # Get table output for terminal reading mb-cli database list --format table ``` diff --git a/internal/client/types.go b/internal/client/types.go index 6c451b2..bc94b6c 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -119,7 +119,7 @@ type TemplateTag struct { // StructuredQuery represents an MBQL structured query. type StructuredQuery struct { - SourceTable int `json:"source-table"` + SourceTable any `json:"source-table"` SourceCardID *int `json:"source-card,omitempty"` Filter []any `json:"filter,omitempty"` Limit int `json:"limit,omitempty"` diff --git a/internal/version/version.go b/internal/version/version.go index 6972277..4752f8d 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -var Version = "0.1.3" +var Version = "0.1.4" diff --git a/tests/cards_test.go b/tests/cards_test.go index 2242ee9..b024904 100644 --- a/tests/cards_test.go +++ b/tests/cards_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/andreagrandi/mb-cli/internal/client" @@ -155,3 +156,116 @@ func TestRunCardNotFound(t *testing.T) { t.Fatal("expected error for 404 response") } } + +func TestCardGetFull(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/card/1" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "name": "Revenue by Merchant", + "description": "Full card payload", + "database_id": 1, + "display": "table", + "query_type": "native", + "archived": false, + "dataset_query": map[string]any{ + "database": 1, + "type": "native", + "native": map[string]any{ + "query": "select * from orders where merchant_id = {{merchant_id}}", + "template-tags": map[string]any{ + "merchant_id": map[string]any{"id": "merchant_id", "name": "merchant_id", "type": "number"}, + }, + }, + }, + "visualization_settings": map[string]any{"table.columns": []string{"merchant_id", "revenue"}}, + }) + })) + defer server.Close() + + stdout, stderr, err := runMBCLI(t, map[string]string{ + "MB_HOST": server.URL, + "MB_API_KEY": "test-api-key", + }, "card", "get", "1", "--full", "-f", "json") + if err != nil { + t.Fatalf("card get --full failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "dataset_query") { + t.Fatalf("expected dataset_query in full card output, got %s", stdout) + } + if !strings.Contains(stdout, "merchant_id") { + t.Fatalf("expected template tag in full card output, got %s", stdout) + } +} + +func TestParameterizedCardRun(t *testing.T) { + c, server := setupCardTestClient(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/card/1": + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "name": "Retention Card", + "database_id": 1, + "display": "table", + "query_type": "native", + "archived": false, + "dataset_query": map[string]any{ + "database": 1, + "type": "native", + "native": map[string]any{ + "query": "select * from retention where timeframe_days = {{timeframe_days}}", + "template-tags": map[string]any{ + "timeframe_days": map[string]any{"id": "timeframe_days", "name": "timeframe_days", "type": "number"}, + }, + }, + }, + }) + case "/api/card/1/query": + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + parameters, ok := body["parameters"].([]any) + if !ok || len(parameters) != 1 { + t.Fatalf("expected one parameter, got %v", body["parameters"]) + } + parameter := parameters[0].(map[string]any) + if parameter["id"] != "timeframe_days" { + t.Fatalf("expected parameter id timeframe_days, got %v", parameter["id"]) + } + if parameter["value"] != float64(14) { + t.Fatalf("expected parameter value 14, got %v", parameter["value"]) + } + + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "cols": []map[string]any{{"name": "count", "display_name": "Count", "base_type": "type/Integer"}}, + "rows": [][]any{{12}}, + }, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + }) + defer server.Close() + + result, err := c.RunCardWithParams(1, map[string]string{"timeframe_days": "14"}) + if err != nil { + t.Fatalf("RunCardWithParams failed: %v", err) + } + + if len(result.Data.Rows) != 1 || result.Data.Rows[0][0] != float64(12) { + t.Fatalf("unexpected parameterized card result: %+v", result.Data.Rows) + } +} diff --git a/tests/cli_test_helper.go b/tests/cli_test_helper.go new file mode 100644 index 0000000..4063b19 --- /dev/null +++ b/tests/cli_test_helper.go @@ -0,0 +1,29 @@ +package tests + +import ( + "bytes" + "os" + "os/exec" + "testing" +) + +func runMBCLI(t *testing.T, env map[string]string, args ...string) (string, string, error) { + t.Helper() + + cmdArgs := append([]string{"run", "./cmd/mb"}, args...) + cmd := exec.Command("go", cmdArgs...) + cmd.Dir = ".." + + cmd.Env = os.Environ() + for key, value := range env { + cmd.Env = append(cmd.Env, key+"="+value) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return stdout.String(), stderr.String(), err +} diff --git a/tests/context_test.go b/tests/context_test.go index ed353f8..5250281 100644 --- a/tests/context_test.go +++ b/tests/context_test.go @@ -41,6 +41,8 @@ func TestContextContentContainsKeyCommands(t *testing.T) { "field get", "query sql", "card list", + "dashboard list", + "dashboard analyze", "search", "context", "version", diff --git a/tests/dashboard_test.go b/tests/dashboard_test.go new file mode 100644 index 0000000..440f023 --- /dev/null +++ b/tests/dashboard_test.go @@ -0,0 +1,309 @@ +package tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/andreagrandi/mb-cli/internal/client" + "github.com/andreagrandi/mb-cli/internal/config" +) + +func setupDashboardTestClient(handler http.HandlerFunc) (*client.Client, *httptest.Server) { + server := httptest.NewServer(handler) + cfg := &config.Config{ + Host: server.URL, + APIKey: "test-api-key", + } + return client.NewClient(cfg), server +} + +func TestListDashboards(t *testing.T) { + c, server := setupDashboardTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/dashboard/" { + t.Errorf("expected path '/api/dashboard/', got %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]any{ + {"id": 1, "name": "Overview", "description": "Top-level KPIs", "archived": false}, + {"id": 2, "name": "Retention", "description": "Monthly retention", "archived": true}, + }) + }) + defer server.Close() + + dashboards, err := c.ListDashboards() + if err != nil { + t.Fatalf("ListDashboards failed: %v", err) + } + + if len(dashboards) != 2 { + t.Fatalf("expected 2 dashboards, got %d", len(dashboards)) + } + if dashboards[0].Name != "Overview" { + t.Errorf("expected first dashboard name Overview, got %s", dashboards[0].Name) + } + if !dashboards[1].Archived { + t.Error("expected second dashboard to be archived") + } +} + +func TestGetDashboard(t *testing.T) { + c, server := setupDashboardTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/dashboard/1" { + t.Errorf("expected path '/api/dashboard/1', got %s", r.URL.Path) + } + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "name": "Merchant Retention", + "description": "30-day dashboard", + "archived": false, + "parameters": []map[string]any{ + {"id": "param-merchant", "name": "Merchant", "slug": "merchant_name", "type": "string/="}, + }, + "tabs": []map[string]any{ + {"id": 10, "name": "Overview"}, + }, + "dashcards": []map[string]any{ + { + "id": 100, + "card_id": 50, + "dashboard_tab_id": 10, + "parameter_mappings": []map[string]any{ + {"parameter_id": "param-merchant", "card_id": 50, "target": []any{"variable", []any{"template-tag", "merchant_name"}}}, + }, + "card": map[string]any{ + "id": 50, + "name": "Retention by Merchant", + "database_id": 1, + "display": "table", + "query_type": "native", + "archived": false, + }, + }, + }, + }) + }) + defer server.Close() + + dashboard, err := c.GetDashboard(1) + if err != nil { + t.Fatalf("GetDashboard failed: %v", err) + } + + if dashboard.Name != "Merchant Retention" { + t.Errorf("expected dashboard name Merchant Retention, got %s", dashboard.Name) + } + if len(dashboard.Parameters) != 1 || dashboard.Parameters[0].Slug != "merchant_name" { + t.Fatalf("expected parsed dashboard parameter, got %+v", dashboard.Parameters) + } + if len(dashboard.Tabs) != 1 || dashboard.Tabs[0].Name != "Overview" { + t.Fatalf("expected parsed dashboard tab, got %+v", dashboard.Tabs) + } + if len(dashboard.DashCards) != 1 || dashboard.DashCards[0].Card == nil { + t.Fatalf("expected parsed dashboard card, got %+v", dashboard.DashCards) + } + if len(dashboard.DashCards[0].ParameterMappings) != 1 { + t.Fatalf("expected parameter mappings to be parsed, got %+v", dashboard.DashCards[0].ParameterMappings) + } +} + +func TestGetDashboardNotFound(t *testing.T) { + c, server := setupDashboardTestClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"Not found"}`)) + }) + defer server.Close() + + _, err := c.GetDashboard(999) + if err == nil { + t.Fatal("expected error for missing dashboard") + } +} + +func TestGetDashboardCards(t *testing.T) { + c, server := setupDashboardTestClient(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "name": "Card Listing", + "archived": false, + "dashcards": []map[string]any{ + { + "id": 101, + "card_id": 77, + "card": map[string]any{ + "id": 77, + "name": "Users by Day", + "database_id": 1, + "display": "line", + "query_type": "query", + "archived": false, + }, + }, + }, + }) + }) + defer server.Close() + + dashboard, err := c.GetDashboard(1) + if err != nil { + t.Fatalf("GetDashboard failed: %v", err) + } + + if len(dashboard.DashCards) != 1 { + t.Fatalf("expected 1 dashcard, got %d", len(dashboard.DashCards)) + } + if dashboard.DashCards[0].CardID == nil || *dashboard.DashCards[0].CardID != 77 { + t.Fatalf("expected card_id 77, got %+v", dashboard.DashCards[0].CardID) + } + if dashboard.DashCards[0].Card.Name != "Users by Day" { + t.Errorf("expected nested card name Users by Day, got %s", dashboard.DashCards[0].Card.Name) + } +} + +func TestParameterLookup(t *testing.T) { + c, server := setupDashboardTestClient(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/dashboard/1/params/param-merchant/values": + json.NewEncoder(w).Encode(map[string]any{ + "values": []any{ + []any{"merchant-a", "Merchant A"}, + []any{"merchant-b"}, + }, + "has_more_values": false, + }) + case "/api/dashboard/1/params/param-merchant/search/acme": + json.NewEncoder(w).Encode(map[string]any{ + "values": []any{ + []any{"acme", "Acme Corp"}, + }, + "has_more_values": true, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + }) + defer server.Close() + + values, err := c.GetDashboardParamValues(1, "param-merchant") + if err != nil { + t.Fatalf("GetDashboardParamValues failed: %v", err) + } + if len(values.Values) != 2 { + t.Fatalf("expected 2 parameter values, got %d", len(values.Values)) + } + if values.Values[0].Value != "merchant-a" || values.Values[0].Label != "Merchant A" { + t.Fatalf("unexpected first parameter value: %+v", values.Values[0]) + } + + searchValues, err := c.SearchDashboardParamValues(1, "param-merchant", "acme") + if err != nil { + t.Fatalf("SearchDashboardParamValues failed: %v", err) + } + if !searchValues.HasMoreValues { + t.Fatal("expected has_more_values to be true for search response") + } + if searchValues.Values[0].Label != "Acme Corp" { + t.Fatalf("unexpected search label: %+v", searchValues.Values[0]) + } +} + +func TestDashboardAnalyze(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/dashboard/1": + json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "name": "Merchant Retention", + "description": "30-day retention dashboard", + "archived": false, + "tabs": []map[string]any{{"id": 10, "name": "Overview"}}, + "parameters": []map[string]any{{"id": "param-merchant", "name": "Merchant", "slug": "merchant_name", "type": "string/="}}, + "dashcards": []map[string]any{ + { + "id": 100, + "card_id": 10, + "dashboard_tab_id": 10, + "parameter_mappings": []map[string]any{{"parameter_id": "param-merchant", "card_id": 10, "target": []any{"variable", []any{"template-tag", "merchant_name"}}}}, + "card": map[string]any{"id": 10, "name": "Retention by Merchant", "database_id": 1, "display": "table", "query_type": "query", "archived": false}, + }, + }, + }) + case "/api/card/10": + json.NewEncoder(w).Encode(map[string]any{ + "id": 10, + "name": "Retention by Merchant", + "database_id": 1, + "display": "table", + "query_type": "query", + "archived": false, + "dataset_query": map[string]any{ + "database": 1, + "type": "query", + "query": map[string]any{"source-card": 20, "filter": []any{"=", []any{"field", 1, nil}, "merchant-a"}}, + }, + "visualization_settings": map[string]any{}, + }) + case "/api/card/20": + json.NewEncoder(w).Encode(map[string]any{ + "id": 20, + "name": "Merchant Base", + "database_id": 1, + "display": "table", + "query_type": "native", + "archived": false, + "dataset_query": map[string]any{ + "database": 1, + "type": "native", + "native": map[string]any{"query": "select * from merchants where plan_id = 42"}, + }, + "visualization_settings": map[string]any{}, + }) + default: + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"error":"Not found"}`)) + } + })) + defer server.Close() + + stdout, stderr, err := runMBCLI(t, map[string]string{ + "MB_HOST": server.URL, + "MB_API_KEY": "test-api-key", + }, "dashboard", "analyze", "1", "-f", "json") + if err != nil { + t.Fatalf("dashboard analyze failed: %v\nstderr: %s", err, stderr) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("failed to decode dashboard analyze output: %v\noutput: %s", err, stdout) + } + + if result["dashboard_id"] != float64(1) { + t.Fatalf("expected dashboard_id 1, got %v", result["dashboard_id"]) + } + baseCards, ok := result["base_cards"].([]any) + if !ok || len(baseCards) != 1 { + t.Fatalf("expected one base card, got %v", result["base_cards"]) + } + flaggedCards, ok := result["flagged_cards"].([]any) + if !ok || len(flaggedCards) == 0 { + t.Fatalf("expected flagged cards in analysis output, got %v", result["flagged_cards"]) + } + parameters, ok := result["parameters"].([]any) + if !ok || len(parameters) != 1 { + t.Fatalf("expected one analyzed parameter, got %v", result["parameters"]) + } +} diff --git a/tests/error_format_test.go b/tests/error_format_test.go index 9f485e5..a04a4f9 100644 --- a/tests/error_format_test.go +++ b/tests/error_format_test.go @@ -38,6 +38,18 @@ func TestClassifyConfigError(t *testing.T) { expectedType: "AUTH_ERROR", hasSuggestion: true, }, + { + name: "dashboard not found", + errMsg: "failed to get dashboard 298: API request failed with status 404: Not found", + expectedType: "API_ERROR", + hasSuggestion: true, + }, + { + name: "parameterized query failure", + errMsg: "parameterized query failed: check parameter keys and values (API request failed with status 400: bad request)", + expectedType: "API_ERROR", + hasSuggestion: true, + }, { name: "api 404", errMsg: "API request failed with status 404: Not Found",