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/card.go b/internal/cli/card.go index 1f623fd..bf13a34 100644 --- a/internal/cli/card.go +++ b/internal/cli/card.go @@ -1,9 +1,12 @@ package cli import ( + "fmt" "os" "strconv" + "strings" + "github.com/andreagrandi/mb-cli/internal/client" "github.com/andreagrandi/mb-cli/internal/formatter" "github.com/spf13/cobra" ) @@ -34,6 +37,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,7 +55,9 @@ 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") + cardRunCmd.Flags().StringSlice("param", nil, "Parameter in key=value format (repeatable)") } func runCardList(cmd *cobra.Command, args []string) error { @@ -74,7 +90,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 { @@ -88,11 +109,54 @@ func runCardRun(cmd *cobra.Command, args []string) error { return err } - result, err := c.RunCard(id) + params, err := parseNamedParams(cmd) if err != nil { return err } + result, err := c.RunCardWithParams(id, params) + if err != nil { + return wrapParameterizedRunError(err) + } + + return formatQueryResultOutput(cmd, result) +} + +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, + } +} + +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") @@ -104,3 +168,14 @@ 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 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/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/cli/dashboard.go b/internal/cli/dashboard.go new file mode 100644 index 0000000..eb51252 --- /dev/null +++ b/internal/cli/dashboard.go @@ -0,0 +1,355 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "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, +} + +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", +} + +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"` + 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"` +} + +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(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 { + 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 +} + +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 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 { + 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/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 +} 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 2d8bba1..0cf7e87 100644 --- a/internal/client/cards.go +++ b/internal/client/cards.go @@ -1,12 +1,16 @@ package client -import "fmt" +import ( + "fmt" + "net/http" + "net/url" +) // ListCards retrieves all saved questions (cards). 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 @@ -19,9 +23,12 @@ 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 + return nil, fmt.Errorf("failed to get card %d: %w", id, err) } var card Card @@ -35,16 +42,46 @@ func (c *Client) GetCard(id int) (*Card, error) { // RunCard executes a saved question and returns the query result. 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, fmt.Errorf("failed to run card %d: %w", id, err) + } + + return c.decodeCardQueryResult(resp, 0) +} + +// 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, fmt.Errorf("failed to run card %d: %w", id, err) + } + + return c.decodeCardQueryResult(resp, card.DatabaseID) +} + +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 new file mode 100644 index 0000000..ee51f55 --- /dev/null +++ b/internal/client/dashboards.go @@ -0,0 +1,89 @@ +package client + +import ( + "fmt" + "net/url" +) + +// ListDashboards retrieves all dashboards. +func (c *Client) ListDashboards() ([]Dashboard, error) { + resp, err := c.Get("/api/dashboard/", nil) + if err != nil { + return nil, fmt.Errorf("failed to list dashboards: %w", 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, fmt.Errorf("failed to get dashboard %d: %w", id, err) + } + + var dashboard Dashboard + if err := c.DecodeJSON(resp, &dashboard); err != nil { + return nil, err + } + + 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, fmt.Errorf("failed to get values for dashboard %d parameter %s: %w", dashboardID, paramKey, 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, fmt.Errorf("failed to search values for dashboard %d parameter %s: %w", dashboardID, paramKey, err) + } + + var values ParameterValues + if err := c.DecodeJSON(resp, &values); err != nil { + return nil, err + } + + 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 + } + card, err := c.GetCard(cardID) + 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, fmt.Errorf("failed to run dashboard %d card %d via dashcard %d: %w", dashboardID, cardID, dashcardID, err) + } + + return c.decodeCardQueryResult(resp, card.DatabaseID) +} 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 028c030..bc94b6c 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"` @@ -98,26 +103,132 @@ 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 any `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. +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"` + ParameterMappings []DashParameterMapping `json:"parameter_mappings,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"` +} + +// 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"` +} + +// 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 + 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. diff --git a/internal/formatter/dashboard.go b/internal/formatter/dashboard.go new file mode 100644 index 0000000..dd9048b --- /dev/null +++ b/internal/formatter/dashboard.go @@ -0,0 +1,318 @@ +package formatter + +import ( + "encoding/json" + "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 + } + if err := renderDashboardCardsTable(cards, writer); err != nil { + return err + } + if _, err := fmt.Fprintln(writer); err != nil { + return err + } + } + + if len(grouped) == 0 { + return renderDashboardCardsTable(nil, writer) + } + + 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 + } + if err := renderDashboardCardsTable(grouped[tabName], writer); err != nil { + return err + } + 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) + } + cardName := "" + if dashCard.Card != nil { + cardName = dashCard.Card.Name + } + 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 !found { + if _, err := fmt.Fprintln(tw, "-\t-\t-\t-"); err != nil { + return err + } + } + + 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() + } + + 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) +} 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",