Compare commits

...

54 Commits

Author SHA1 Message Date
Fraser
afa79587c2 Lint without building cljs (#83718) 2026-10-07 16:06:33 +13:00
Romeo Van Snick
2b271c6987 Remove unreferenced images from app/assets/img and app/img (#82788)
* Remove unreferenced static images

* Remove unreferenced images from app/img

* Remove four more unreferenced images kept by a loose name match
2026-10-07 14:03:22 +11:00
Timothy S. Dean
37d5c5b042 Fix the revision history of an edited transform test (#83818) 2026-10-06 18:19:47 -06:00
Maz Ameli
30d6158ea1 Replace dashboard loading spinner with an instant skeleton (DSN-749) (#83288)
* Replace dashboard loading spinner with an instant skeleton (DSN-749)

Dashboards no longer show a loading spinner when opened. Instead they render
a skeleton of the dashboard shell and card layout instantly:

- New DashboardSkeleton renders the header chrome (title, action buttons,
  tabs), a full-width border, a secondary-background body, filter-widget
  placeholders, and the card grid constrained to a centered 1024px column.
- New DashboardGridSkeleton draws the card grid. When the layout is cached
  in Redux from a prior visit (getLastSeenTabDashcards), it reproduces
  react-grid-layout's real geometry so the cards land where the real ones
  will; heading/text cards render title-style line skeletons. Otherwise a
  generic placeholder layout is used.
- Grid no longer shows a spinner while refetching; the header no longer
  shows a spinner while its collection query loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Draw the real dashboard layout before query metadata loads (DSN-749)

fetchDashboard awaited both GET /api/dashboard/:id and the slower
/query_metadata before any of the dashboard reached the store, even though
the GET alone already holds the card layout. On a fresh open it now
publishes the GET response's normalized dashboard and dashcards as soon as
it arrives (dashboardLayoutFetched), so the loading skeleton switches from
the generic layout to the dashboard's real one without waiting for
metadata. The full load transition is unchanged, and a refetch of a
dashboard already on screen publishes nothing early.

The virtual-card copy is extracted into copyVirtualCardsToCards so the
early layout and the fulfilled payload are built the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Render dashboard skeletons inside the real dashboard layout (DSN-749)

Replace the separate full-page dashboard skeleton with skeletons that the
header, filter panel and card grid each render while the dashboard loads,
so the surrounding page, widths and theming are the real ones and can't
drift.

- Grid renders DashboardGridSkeleton when there's no dashboard yet. This
  also covers public links, static embeds, X-rays and SDK dashboards, which
  showed "This dashboard is empty" while loading. Its spinner is restored
  for refetches of an already-open dashboard.
- The skeletons read the dashboard as last seen in the in-memory Redux
  cache: its width (assuming the default fixed width when it isn't
  cached), its tabs (a tab row only for two or more), the cards of the tab
  the user is landing on, and its header filters (excluding inline and
  hidden ones).
- The grid skeleton takes its geometry from the grid constants, including
  a new shared GRID_MARGINS, and drops the heightPx special case.
- The skeletons are marked aria-busy, and the grid announces "Loading…".
- waitForDashboardToLoad waits for a loading dashboard in tests, since
  waitForLoaderToBeRemoved no longer does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Draw no placeholder cards until the dashboard's layout is known (DSN-749)

While a dashboard that isn't in the in-memory Redux cache loads, the grid
skeleton used to draw a generic layout of placeholder cards, which rarely
matched the dashboard about to appear. It now draws no cards until the
dashboard's real layout is known, so the content area stays empty under
the header skeleton and then fills with the real layout. The grid still
marks itself busy and announces "Loading…".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Size the dashboard header skeleton from the real title (DSN-749)

The header skeleton drew a bare title bar, but the real title area is
taller: the title is an EditableText box with its own padding, border and
line height, and the last-edit label sits on a row beneath it. The header
grew when the dashboard arrived, pushing the page down.

The title and label bars now sit over invisible copies of the real
elements, so the skeleton takes their exact height without copying their
styles. The label row is only reserved when the real header would show
it; a dashboard that isn't cached is assumed to have an edit history.

Also replace a file path in a CSS comment, and note in dashboard_grid.ts
that the grid skeleton's mobile container query repeats MOBILE_BREAKPOINT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 00:06:34 +00:00
Romeo Van Snick
3d9a6743c6 Hash static images through the rspack build (#82726)
* Declare png and gif module imports for the bundler

* Hash onboarding, upsell and example images through the build

* Assert the Slack app icon link against the bundled asset

* Hash the upsell card story illustration through the build

* Take image sources as props instead of building paths from a base string

* Add a ?url image import that the SDK bundle references instead of inlining

* Hash empty state, upsell and driver logo images through the build

* Hash the map skeleton sprite and the bridge illustration through the build
2026-10-07 10:25:24 +11:00
bryan
1c0661fa03 GHY-4737: Remote sync: name the cause of a diverged-export conflict (#83682)
* GHY-4737: Remote sync: name the cause of a diverged-export conflict

A plain export against a remote that moved past the last sync ended in a
conflict task with conflicts [], so API clients (the mb CLI, agents) saw
"conflict" with no reason: the task row stores only conflicts, and
handle-task-result! drops :message. Record a factual label instead.

Also pin that such a conflict leaves the sync base alone, so a later
export with merge still sees the remote's changes.

* GHY-4737: pin the conflict label on the task row; trim the label comment

* GHY-4737: record the diverged-export cause as a stable token

Review feedback: conflicts otherwise holds short identifiers on the
import path, and an untranslated English sentence would become a wording
contract for clients. Record "remote-changed" and let clients word it.

* GHY-4737: don't let auto-import repeat an unresolved conflict

A conflict is no longer a sync base, so auto-import's "source version
matches last import" skip no longer stops a conflicted read-only
auto-import: it retried, and recorded a new conflict task, every tick.
Skip when the newest task conflicted at the current source version;
a new remote commit or a manual import still proceeds. Review feedback.

* GHY-4737: name why a task stopped in outcome, keep conflicts for what collided

Review feedback: conflicts answers "what collided" (import categories,
merge entities). A diverged export has no collision; the divergence is
why it stopped. Record conflicts [] plus outcome {:kind "remote-changed"}
instead of a token inside conflicts, and add outcome
{:kind "history-rewritten"} to the no-merge-base paths (their conflicts
sentence stays for compatibility). handle-task-result! now writes outcome
on conflicts; the outcome schema and FE type gain both kinds.
2026-10-06 16:46:55 -06:00
Andrei Chernykh
e27d36ca15 Migrate the whitelabel settings off Emotion (#83691) 2026-10-06 15:23:45 -07:00
Nemanja Glumac
d764799f8f Reusable test gate logic (#82830)
* Let each test suite decide whether it should run

run-tests.yml computed a `skip` input for every suite it called, so the
decision for a suite lived in the caller rather than next to the jobs it
governs. A new callable workflow, test-gate.yml, carries the part that is the
same everywhere: the force-run / force-skip / defer verdict, and a paths filter
that only runs on `defer`, over a sparse `.github` checkout.

Each suite now calls the gate with the filters it cares about and reads `run`,
`matched` or `files`. app-db, drivers, semantic-search, loki, e2e-tests,
frontend, embedding-sdk, build-scripts, bundle-size and backend keep no `skip`
input; run-tests only decides whether to call them at all.

static-viz-files-changed moves into backend.yml, its only consumer, and now
runs only when `backend_all` did not already match.

The uberjar and SDK package conditions become the build_uberjar and
build_embedding_sdk_package filters, each the union of what the artifact feeds.

A force-run reports every requested filter as matched, so the project-level
backend, migration and ratchet checks now also run on master and `ci:run-all`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop the redundant always() from workflow conditions

`always() && !cancelled()` is `!cancelled()`: always() only adds "run even when
cancelled", which the second term takes straight back. The four conditions that
paired !cancelled() with a `needs.<job>.result == 'success'` check restated the
implicit rule instead, so they lose both terms and let a plain `if` apply it.

In e2e-tests.yml that also fixes a latent failure: the suite entered when
e2e-matrix-builder had failed, leaving `fromJson` to parse a matrix that was
never produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Stop gate jobs from skipping themselves

A skipped job skips everything downstream of it, which is why so many consumers
carried !cancelled() just to override the implicit success(). create-test-plan,
static-viz-files-changed and story-selection now always run and gate their steps
instead, emitting empty outputs, so their consumers keep only their real
condition. create-test-plan is best-effort: a planner failure means the suites
run in full, as a skipped one already did.

The app-db, drivers and semantic-search gates were skipped on a direct dispatch
for the same reason. They pass no filters on that path instead, which already
means "only check for a force-cancel".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Land the affected-tests stats row from the planner action

The upload job existed only to POST what create-test-plan had just computed,
so it re-checked out the repo and re-prepared bun to read one output back.
It is now the action's last step, which also drops the `stats` output that
only ever carried the payload across the job boundary.

A composite action has no `secrets` context, so the endpoint and key arrive as
inputs; an empty endpoint (a fork PR, which gets no secrets) skips the upload
rather than posting nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reorder workflows

* Count the bookkeeping jobs in the result checks

`static-viz-files-changed` and `e2e-matrix-builder` feed every job in their
suite but were absent from the `*-result` needs, so a failure in either skipped
the whole suite and the required check reported success.

`sdk-tests` now requires a successful gate, like `e2e-tests`: a failed gate
skips the uberjar and SDK package builds for a reason the suite's own gate
cannot see, and the component tests would run against a missing artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Hand out a test plan only from a clean planner run

The plan artifact is uploaded before the planner's reporting steps, so a
failure after the upload still exported an artifact id and both suites narrowed
from it. Reading the step outcome covers that; the job's `continue-on-error`
means its own `result` is always `success`, so a caller-side check would not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Run the suites when the test gate changes

test-gate.yml matched no filter that gates a suite, so a diff touching only the
workflow that decides whether every suite runs ran nothing. Putting it in
shared_ci reaches backend, frontend, loki, e2e, the SDK suites and ci-scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Keep file lists out of the test gate

The match step read every paths-filter output through one environment
variable, file lists included. That variable passes the 128 KiB Linux allows
a single one at around 130 changed files, so the gate failed on large PRs and
took every suite down with it.

The gate now lists no files and reads only the names of the matched filters.
The create-test-plan action, the one consumer of paths, runs its own paths
filter instead of taking file lists and infra flags as inputs, so the
run-tests gate asks only for the filters its own jobs read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cleanup

* Address review: job-level skips, no frontend dispatch, no per-leg profiling

- story-selection, create-test-plan and static-viz-files-changed skip as a
  job again instead of skipping each step. Their consumers carry
  !cancelled() so a skipped one does not skip them, and the planner and
  static-viz probe no longer hold a large runner just to do nothing.
- frontend.yml loses its workflow_dispatch trigger and the skip inputs it
  fed.
- The profiling dispatch no longer picks a leg: the db input and every
  github.event.inputs.db read are gone, so a profile runs every leg of the
  suite.
- semantic-search drops ci:run-semantic-search-tests. The full suite runs on
  a force-run (master, release branches, ci:run-all) or a profile; every
  other run is the Postgres Latest smoke run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cleanup

* Gate a profiling dispatch like any other run

The backend suites read the dispatch's `suite` input to run everything when
profiled. They now gate a profiling dispatch like any other run: in full on
master and release branches, by the diff anywhere else, with semantic-search
running its smoke leg.

The profiler switch keys off the dispatch event instead. backend.yml sets it
on the test step; app-db, drivers and semantic-search set it in their workflow
env, which the test-driver action inherits, so the stress-test workflows that
dispatch the action directly still never profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rename

* cleanup

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ryan Laurie <iethree@gmail.com>
2026-10-06 22:05:46 +00:00
Oleksandr Yakushev
7d638168dd [pivot] Optimize node formatting (#83057) 2026-10-07 01:03:57 +03:00
Chris Vandoodewaard
519834f9ee Consolidate sharing e2e tests (#83657)
* Remove duplicate public sharing e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Merge sharing reproductions e2e tests that share setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Merge downloads e2e tests that share setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Merge public sharing e2e tests that share setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Repair downloads e2e tests that cannot fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Repair sharing reproductions e2e tests that cannot fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Drop unused setup in sharing reproductions e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Remove duplicate subscriptions e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Repair public sharing e2e tests that cannot fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Drop unused setup in downloads e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Drop unused setup in public sharing e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Remove duplicate alert e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Merge alert e2e tests that share setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Repair alert e2e tests that cannot fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Drop unused setup in alert e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Remove duplicate public sharing e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Merge subscriptions e2e tests that share setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Repair subscriptions e2e tests that cannot fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Drop unused setup in subscriptions e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Fold public dashboard link creation e2e test into 20393 repro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold 30314 subscription cancel e2e test into 14117 test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Fold 16108 tooltip e2e test into download format test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Merge public sharing e2e tests that share setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Place merged 18352 card below the Orders card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Type a name before Escape in the 24629 subscriptions e2e test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Repair public sharing e2e tests that cannot fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Tag sharing e2e tests that need maildev as external

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Drop unused setup in public sharing e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Fix popover check after Escape in dashboard downloads e2e test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Keep the webhook channel in the 12349 alert e2e test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Tighten subscriptions e2e checks found in parity review

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Tighten sharing reproductions e2e checks found in parity review

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Restore admin and UI-created link coverage in public sharing e2e tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Jc2P2sKm39MghfKPxCSiv

* Fix 7 sharing e2e failures from CI

* Restore 48402 coverage and merge more sharing e2e tests

- Restore the metabase#48402 delete-alert step in the 12349 test and check the delete modal
- Make 18669 check the email filter value and 21559 check the card image
- Merge public dashboard download flag tests with the real downloads
- Merge static-viz, branding, and default-filter subscription tests
- Merge dashboard PDF and PNG download tests and check download parameters
- Merge the two background=false public dashboard tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review on sharing e2e consolidation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix sharing e2e CI failures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:39:59 -04:00
Chris Vandoodewaard
cc2db95954 Consolidate monitor e2e tests (#83655)
* Fold monitor e2e subset tests into their supersets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Merge monitor e2e tests that share setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Repair monitor e2e checks that cannot fail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Strengthen weak monitor e2e checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Flatten monitor erroring questions e2e setup and fix titles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Fold monitor empty task logs test into the task details test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Anchor moved monitor e2e checks on rendered content

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Allow extra rows in monitor task runs tab switch check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Fold monitor task logs test into the task details test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Merge monitor task runs tab and details tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Merge monitor task runs filtering and entity picker tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Merge monitor MCP analytics tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmvNCJoEdZwRpEkem3754T

* Fix review findings in monitor e2e tests

- Cold load the task run details page
- Check the entity loader right after the started-at selection
- Check task rows, not only the tasks table card
- Match only the MCP events query in the tool calls waits
- Remove a redundant empty state check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:39:37 -04:00
Chris Vandoodewaard
ae0dbb421a flake fix: displays object details, breadcrumbs, relationships, email links, and 404 error state (#83762)
remove flaky assertion, match similar test
2026-10-06 17:39:22 -04:00
Chris Vandoodewaard
94ef260096 Consolidate explorations e2e tests (#83654)
* Fold explorations picker search test into the manual flow test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmyW5RuqbkDJu7aUM3Po3J

* Fold explorations click-to-select test into the sidebar tabs test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmyW5RuqbkDJu7aUM3Po3J

* Merge explorations trash page tests into the trash banner test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmyW5RuqbkDJu7aUM3Po3J

* Strengthen weak explorations navigation checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmyW5RuqbkDJu7aUM3Po3J

* Remove unused addMetricsToExploration e2e helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmyW5RuqbkDJu7aUM3Po3J

* Consolidate more explorations e2e tests and fix weak checks

- Merge the Summary document test into the brush explore-further test
- Merge the personal collection test into the trash banner and /trash test
- Check that the toast View action moves to a different page
- Wait for the search request with the typed query in the metrics picker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address review feedback on explorations e2e consolidation

- Append an unfiltered page to the Summary and check both card embeds
- Check the new page renders after the cartesian toast View
- Load /trash cold before the trash row actions and flatten the trash test
- Merge the sidebar hide pages test into the tabs test
- Share one beforeEach and afterEach under a top-level describe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:13:42 -04:00
Chris Vandoodewaard
c6b4dee91c run timing workflow daily with a one day lookback (#83780) 2026-10-06 16:52:46 -04:00
Nemanja Glumac
54fe3c4365 Consolidate data studio e2e tests (#83736)
* Remove duplicate data-studio e2e tests

Drop tests whose steps and assertions already run in another e2e test:
a measures-queries identity test that was copied line for line, a
CountIf-segment measure test that matches another one step for step,
an empty-folder snippet test that never exercised folder expansion, a
skipped segment-cycle test whose asserted text does not exist in the
product, and an MBQL SQL-preview LIMIT check that could not fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold subset data-studio e2e tests into their supersets

Delete tests whose steps already run in a host test with the same setup,
and add their few unique checks to that host: empty states and /new
URLs into the create flows, unsaved-changes guards, breadcrumbs,
dependency graph tabs, read-only views into the edit tests, transform
target errors, the Python library entry and editing, the inspect tabs,
and the SQL-preview LIMIT check on a transform without a limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Repair data-studio e2e assertions that could not fail

Make checks go red when the behavior breaks: wait for the run request
before reading the run label (it shows the previous result on mount),
assert values that differ before and after the action (checkpoints,
filtered rows, converted SQL, Python output, rewired dependents after a
new source row), fix selectors that never match (tab roles, a document-
wide get, wrong measure names), assert the outcome of saves that had
nothing after them, and drop checks on buttons that never render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Strengthen weak data-studio e2e assertions

Give negative checks a positive precondition on the same element so
they cannot pass before the UI mounts, wait for the request before
checks that follow a save or a navigation, use the multi-toast helper
where a previous toast may still show, guard .each loops against empty
sets, and create the metric picker fixtures on the database the picker
shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge data-studio e2e tests that share setup

Run tests with the same restore, user, fixtures and entry page as one
test, so each restore and page load serves several checks. Every part
keeps its original starting state: read-only parts run first, parts
that mutate data run last, each part starts from its own visit where
the state matters, and toasts are dismissed between parts. Tests that
replace a source or convert a model now create all dependents and run
the replacement once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop unused setup in data-studio e2e tests

Remove restores that a later restore overwrites, intercept aliases that
nothing waits on, duplicate snowplow resets, duplicate helpers and
unused helper parameters. Fix wrong logs, titles and comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix data-studio e2e merges found in the parity pass

Load the notebook fresh before the second data-picker part, check the
closed popover without the visibility guard, delete segments after a
direct visit as before, remove the failed-run test that also runs in
the runs filter test, and keep the snowplow check for the job creation
flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix and restore data-studio e2e checks from review

Assert the Python transform output as a scalar, assert the archive
banner instead of a URL check that could not fail, and bring back the
collection checks after a direct library visit. Give the
multiple-template-tags part its own visit without a database pick, run
the non-admin source-replacement check before any sandbox exists, drop
the run-status polls before the run label, and match the join table
label by text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add positive checks before data-studio e2e negatives from review

Assert the element or container is there before each remaining
negative check (sync modal, cleared-description hovercard, row
checkboxes before read-only mode, the transform row before deletion,
the transforms header in the empty state), and wait for the dashcard
query after a filter change before checking rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge more data-studio e2e tests that share setup

Run more tests with the same restore, user and fixtures as one test:
measure queries on one measure fixture, template tags with the data
reference flow, inspect loading with the summary lenses, the transform
reproductions, transform ownership with the inline settings, tag
editing, published-table queries, metric editing and moving, table
dependencies, segment tabs and dependencies, sort orders, and table
details. Each part starts from its own visit where the state matters,
and parts that change data run last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix data-studio e2e tests that failed in CI

Drop the test tables before the writable-database restore again: the
restore cannot drop the source table while the child table still has a
foreign key to it. Submit the transform form by its submit button,
which shows "Failed" for a few seconds after the rejected save. Close
the run error modal with Escape, since it has two close buttons, and
check a tag that is listed in the dropdown, because selected tags are
not listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:44:38 +02:00
Timofey Kachalov
ed1d3d1004 Data apps: move resource collections into a data-apps namespace and serialize them with the app (#83766) 2026-10-07 00:38:11 +04:00
Jeff Bruemmer
b9b5c25e7a docs - transform indexes (#83598) 2026-10-06 20:26:52 +00:00
Alexander Polyankin
d40e005888 Mock the engines endpoint in the DatabaseEditApp and DatabaseListApp specs (#83782)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:07:08 +02:00
Nemanja Glumac
b1ddf5b6c4 Consolidate dashboard filters 2 e2e specs (#83700)
* Move dashboard-filters-2 e2e specs into dashboard-filters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove unused dashboard filter query-stage helpers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove duplicate dashboard filter target picker empty state test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold dashboard filter reproduction subsets into their supersets

Run the target picker empty-section search in the empty-state test, add a
never-connected card to the disconnected-card reload test, and count public
dashcard requests in the public remapping test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Repair dashboard filter e2e checks that could not fail

Prove click-through navigation, give negative checks a positive precondition
on the same element, assert filtered results that differ from the unfiltered
card, wait for reverts and stored last-used values, map the first-stage
breakout column in the query-stage test, and check the first card in the
first-stage aggregation test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge dashboard filter e2e tests that share setup

Run the remapping, comma-separated value, sql model field value and public
and embedded remapping reproductions once per setup, and apply a query-stage
filter after checking the mapping options on the same dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop unused dashboard filter e2e setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tag the writable database filter linking reproduction as external

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check merged dashboard filter reproductions against the right state

Assert the filtered public dashcard by row count, since its Name column is
outside the card, and clear the last used remapped value before loading it
from the url.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Select the city from the autocomplete suggestions in the city filter test

The city filter searches values instead of listing them, so the selected
value shows as a pill in the search input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:36:16 +02:00
Nemanja Glumac
fd95839a47 Consolidate dashboard filter behavior e2e specs (#83692)
* Drop temporal unit public and embedded dashboard tests

The multiple column breakouts dashboard test sets temporal unit
parameters on the same dashboard through a public link and a static
embed, and checks the card columns after each change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check the auto-wired card keeps its mappings in temporal unit test

Only the manually mapped card was asserted, so overwriting the category
mapping on the auto-wired card went unnoticed. Assert both cards and
drop a negative check that ran before any data loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge temporal unit parameter tests that share setup

Fold the model mapping into the question mapping test, the no-breakout
auto-wiring checks into the new card test, the default clearing into the
allowed units test, and the URL value tests and the native query tests
into one test each.

The no-breakout card check now waits for the other new card's
suggestion, and the default clearing checks the unit was excluded before
asserting the default stayed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix temporal unit parameter test titles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold temporal unit chevron alignment check into reset test

The chevron alignment test built the same no-default temporal unit
parameter as the temporal unit reset & clear test. Run its check at the
end of that test, in the parameter sidebar it already opens, and assert
both chevrons are present before comparing them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold reset-with-open-popover check into number filter test

The required number filter test already resets a changed value with the
widget's revert icon. Open the widget popover first and assert it closes
after the reset, which covers the standalone reset-with-open-popover
test. Drop that test along with its describe and duplicate beforeEach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold required text filter set-to-default check into reset test

The standalone test deselected all values of a required filter whose
value already equalled its default, then clicked "Set to default" and
asserted the unchanged "4 selections", so it passed even if the button
did nothing. The multi-value text reset test now changes its required
filter to a non-default value first, deselects all, clicks "Set to
default" and asserts the default is applied and the reset icon is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use a distinct other value in multi-value text reset test

The other value had two selections, as did the default, so every
"2 selections" check passed whether the filter held the default or the
other value. Pick three values so the widget text tells them apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold auto-wiring subset tests into their supersets

The Undo flow now runs in the "already has a parameter" test, which
undoes before mapping card 1 to Address instead of clearing it by hand.
The auto-connected toast check moves into the add-a-card Undo test, the
replace-a-card flow into the parameter mapping test (now also checking
the replaced card is mapped), and the 8s undo-toast timeout into the
12s timeout test with tighter bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add positive preconditions to reset & clear negative checks

checkResetAllFiltersHidden asserted "Reset all filters" was absent
without showing the menu had opened; it now sees "Enter fullscreen" in
the same menu first. The first "no status icon" check on the required
filter now asserts the widget shows the default value first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Retitle number range reset test and drop dead comment

The number "multiple values" test uses "between" parameters, so call it
a range. Remove a commented-out line in the text multi-value test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make foreign key auto-wiring filter checks able to fail

Both tests filtered by ID 1 and asserted cells contain "1", which the
unfiltered tables also satisfy, and waited on a card query alias that
matched the initial dashboard load. They now filter by 3, assert the
exact cell text "3", and wait on an alias registered just before the
filter is applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add positive preconditions to auto-wiring absence checks

The card 1 "User.Name" absence check now first confirms card 1 shows
"Select…", and the no-suggestion toast check first confirms card 0 is
mapped to "User.Name", so neither passes before the mapping UI renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge unsaved and reset draft filter auto-apply tests

Both tests set a draft filter value on the same dashboard with auto-apply
off. Run the reload check first, asserting the widget shows its
placeholder rather than the draft value, then the reset steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop unused intercepts from auto-wiring spec

Nothing waits on the "@dashboard" alias or reads the card query spy,
and the "@cardQuery" alias has no remaining waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold cancelled-edit draft filter test into dashboard edit test

Both tests set the same draft filter value before entering edit mode.
The edit test now cancels an edit first and checks the draft value and
apply button survive, then edits and saves as before. Drop the emptied
describe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix stale comment in auto-wiring toast timeout test

The comment said auto-wiring toasts share an id; each toast now gets
its own id, so it states only what the test guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold auto-apply enable snowplow test into disable test

After disabling auto-apply and seeing one "auto_apply_filters_disabled"
event, the disable test now enables it again and checks the event count
stays at one, covering the standalone enable test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check the checkbox state in auto-apply filter reset test

The reset check asserted "not.be.checked" on the "Gadget" label text,
which is never checked, so it passed whether or not the reset worked.
Query the checkbox by its label instead, and assert it is checked after
selecting it so the final unchecked state is meaningful.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check rows after disabling auto-apply in toggle test

The last step only checked the widget text after disabling auto-apply,
and its log described draft values although the change had already been
applied. Assert the card still shows the 107 Gadget and Widget rows and
describe the step as it runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold filter type change subset tests into their supersets

The default-operator walk through Number, ID, Date and Location now
runs in the mapping reset test before it saves, and the mapping
cleared/restored checks run in the saved parameter value test, which
already switches Text to Number and back on the same dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Order filter management checks after the state they test

The hidden filter widget check after saving now has a positive
precondition: the shared setup confirms the "Text" widget is shown
before editing. The operator change test checks the mapping survives
only after "No default" confirms the operator change applied, so the
check can't match the pre-change DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add menu precondition to auto-apply no-permission test

The test asserted "Edit settings" was absent without showing the
dashboard menu had opened. Assert "Enter fullscreen" in the same menu
first, and title the test after what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix default operator log messages in filter management spec

The Number log said "Between" while asserting "Equal to", and the
Location log said "Date".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge dashboard filter default tests that share setup

Both tests built the same two-filter dashboard and visited it with the
same params. After setting Filter Two's default, the remaining test now
sets Filter One's default to "Baz" and checks the URL and widgets keep
Filter Two's value before and after saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold static list clear-and-restore test into number source spec

The clear-and-restore spec built the same Orders count dashboard with a
number filter and static list source as the number source static list
test. That test now edits its dropdown filter afterwards, switches it to
a text filter (list cleared), connects it to Email, and switches it back
to number (list restored). Move the helpers and delete the spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check number filter dropdown hides raw values behind labels

The number/string label test only checked the label is listed; it now
also checks the raw value "1" is not, as the string/string test does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge dashboard filter source tests that share setup

Each group now builds one dashboard with a distinctly named filter per
case and scopes every widget by name:
- structured Contains, structured Is and native Is question sources,
  archiving each source and checking the plural "2 filters" warning
  for the shared GUI source and the singular one for the SQL source
- string, number and id values remapped to string labels
- static list dropdown, static list search and field search box
- IP address and type/Quantity custom labels on postgres

The filter helpers take the filter name instead of clicking "Text".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop unused dataset intercept from filter source spec

Nothing waits on the "@dataset" alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop restoring twice before the UUID chained filter test

The test sat under the chained filter beforeEach, so it restored the
default snapshot and signed in before restoring postgres-writable
itself. It now has its own describe and does only its own setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Retitle UUID chained filter test after what it checks

"should work for all field types" described none of it; the test
checks a UUID primary key column is offered for an ID filter under the
"Update a dashboard filter" click behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Describe the default replacement step in filter defaults test

The log said the step removes the default value and its URL
placeholder, but it replaces Filter Two's default with "Foo".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold nodata filter mapping checks into data permissions test

Merge the admin and nodata filter tests into one, and add the nodata
edit-mode checks there: the card actions panel shows without an
"Add a filter" button (shown to the admin in setup), and the filter
mapper shows the key icon. Remove the parameters spec tests that only
covered those nodata checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fold subset dashboard parameter tests into their supersets

Each removed test ran on the same setup as a test that already covered
its first steps, so its remaining steps now run at the end of that test:
- resetting a last used value and reloading: last used value test
- removing a heading dashcard: heading dashcard removal undo test
- removing a question dashcard: question dashcard filter edit test
- own-card connection message: other-tab connection test
- showing all inline parameters while editing one: question dashcard
  filter removal test
- undoing filter moves: single tab filter move test
- dashboard_filter_moved tracking: dashboard_filter_created test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Repair dashboard filter checks that could not fail

- Data permissions: drop the search intercept, whose alias was mangled
  into the URL, along with the unused 403 branch that waited on it.
  Open the filter through its widget instead of matching any "Text",
  drop the "Orders" and dashboard-title clicks that always matched,
  click "Add filter" in the popover without force, and check the
  widget value and URL param.
- Heading filters: check the grabber icon on the heading card holding
  the filter, not on the question card.
- Text card variables: check the Count widget instead of Category twice.
- Public dashboards: map the Count filter to the dashcard's own card so
  its widget renders, and check it does.
- Granular filter preference: wait for the dashcard query after updating
  the header filter before checking the card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Strengthen weak dashboard parameter assertions

- Filter removal: check the filtered-out rows are present before
  filtering and that matching rows render before checking the others
  are gone; check only one filter widget remains after removal.
- Multiple filters: check the values request count after the reopened
  popover has rendered its values.
- Compare multi-slug URLs with expectSearchParams instead of exact
  search strings, whose param order is not deterministic.
- Inline auto-wiring and dashcard duplication: check the own-card
  message renders before checking the mapper has no Category option.
- Disabled embedded parameter: check the card shows unfiltered data.
- Filter focus: check the move toast renders before checking it has no
  "Show filter" button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge dashcard refetch-on-save tests into one

The four tests shared the same setup. Run their saves in sequence in
one test, so each later count also catches a fetch an earlier save
should not have made: no fetch after saving a disconnected filter or
an unchanged dashboard, then 1 after connecting one card, 2 after
disconnecting it, and 4 after connecting both.

Register the request spy before the setup save instead of after it, so
the disconnected-filter check can fail if that save fetches data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop duplicate dashboard PUT intercept

The dashcard duplication test registered the same updateDashboard
intercept twice; keep the one at the top of the test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Retitle merged dashboard filter tests

Name the steps each merged test now covers, including the admin and
nodata usage and nodata edit-mode checks in the data permissions test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop auto-apply toast tests for public and signed embeds

The slow dashboard toast is only shown to users who can edit the
dashboard, and the tests could not make the dashboard slow: cy.clock
does not affect performance.now and the card request was never delayed.
Neither test could fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check the other tab message while the inline filter is connected

Also compare the public dashboard URL params regardless of slug order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name both toast timeouts in auto-wiring timeout test title

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Describe temporal unit parameter priority without an issue reference

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix dashboard filter tests that failed in CI

The heading text is an input after the parameter sidebar closes, so
check the saved dashcards instead. The add-card sidebar stays open after
a card is added, so pick the next card from it. The auto-wired table card
is below the fold, so check its column header exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore the non-required filter reset with an open popover test

Only the non-required filter reset closes the popover. The required
filter reset keeps it open, so the check cannot run on the required
number filter. The test now uses the outer describe setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Find temporal unit auto-wired cards by name after save

Saved dashcards are ordered by their layout position, so the added
cards no longer have the indexes they had while editing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:33 +02:00
Maksym Yakubych
190a31be82 Derive the mini model only from what a connection was listed as serving (#83139)
* Derive the mini model only from what a connection was listed as serving

With nothing stored, llm-mini-model resolved to the provider type's mini
model straight from the registry. Connecting lists the account's own
catalog but never checked that guess against it, so a connection whose
listing did not include the cheaper tier ran every quick task — conversation
titles — on a model it could not serve, and the admin page showed the mini
model as unconfigured because the derived value was not among the options.

Every listing now records whether it included the type's mini model on the
connection, and the derivation reads that instead of the registry: the
cheaper model when the connection was seen to offer it, the Metabot model
otherwise. The per-type mini models stay in the registry as the tier a
listing looks for.

* Tighten mini-model docstrings
2026-10-06 17:33:20 +00:00
Nathan Voxland
f062ce0832 Improve error handling in query responses (#82531) 2026-10-06 17:20:30 +00:00
Oleksandr Yakushev
01bd5d9ea5 [metadata] Analysis optimizations (#83635) 2026-10-06 20:09:04 +03:00
Jeff Bruemmer
012e199084 docs - mcp tool documentation gen (#82592) 2026-10-06 12:58:25 -04:00
Andrei Chernykh
a6ed8f0fef Migrate the table info card off Emotion (#83444)
* Migrate the table info card off Emotion

* Use CS overflow utilities in the table info card
2026-10-06 09:57:10 -07:00
John Swanson
e53a1611b4 Log applied sandboxes in the query execution audit log (#78975)
* Log applied sandboxes in the query execution audit log

Sandboxed (row-level security) queries used to show up in Usage
Analytics' query log as completely normal queries, making sandboxing
impossible to debug from the audit log (#71604, UXW-3767).

The logged query blob can't carry this information: it lives in the
query table keyed by a hash computed before preprocessing, so a
sandboxed and an unsandboxed run of the same question share one row.
Instead, record the restrictions per-execution:

- The sandboxing middleware records each sandbox it applies on the
  query under a namespaced key, merged across preprocessing passes.
  Applied sandboxes are derived from the
  :query-permissions/sandboxed-table markers left on each replacement
  stage -- the same source of truth is_sandboxed comes from.
- merge-sandboxing-metadata copies the entries into the results
  metadata (userland queries only), next to is_sandboxed.
- process-userland-query saves them to the new
  query_execution.sandbox_details column and strips them back out of
  the response returned to the client.

Each entry records table_id, sandbox_id, group_id, card_id, and the
login attributes used (attribute name -> target field id). The user's
actual attribute values are only included when
analytics-pii-retention-enabled is set, matching how that setting
gates other user-specific data in Usage Analytics.

The column is exposed in v_query_log (v7) and the shipped Query log
model.

* Give the attached sandbox audit details a named schema

Review feedback:

- Rename `record-sandbox-details` to `attach-sandbox-details`. It puts data
  on the query; it doesn't persist anything.
- Replace the bare `:map` and `::lib.schema/query` schemas with
  `::details-entry`, `::details` and `::query-with-details`, so the key
  sandboxing attaches during preprocessing is typed both where it is
  produced and where `merge-sandboxing-metadata` reads it back.
- Mention sandbox attribute values in the `analytics-pii-retention-enabled`
  description.

* Declare sandbox_details in the schemas master closed

Rebasing onto master brought in closed argument schemas and the checker in
`metabase.util.malli.closed-schemas`, which rejects any undeclared key and any
shape it cannot see. Every schema the new key passes through has to name it:

- `metabase.lib.schema` gains `::sandboxing.details.entry` and
  `::sandboxing.details`, and declares the `::details` key on the query map
  next to the existing sandboxing key. This is the one definition of the entry
  shape; the enterprise middleware and the row schemas below point at it.
- The legacy MBQL query schema declares the same key, since preprocessing
  round-trips the query through legacy and back.
- The result metadata threaded through the rff chain, and the QueryExecution
  insert schema, declare `:sandbox_details`.
- The SDK and batch-processing tracking rows pass the value through, so they
  declare it as a deliberately-open map rather than restating the shape.

Also renumber the migration from v64 to v65: v64 has branched for release, so
a v64 id on master would not roll back with the rest of v65.

* Let the query-with-details conjunct through the closed-schema check

An `:and` conjunct that constrains a single key is an open map, which
`metabase.util.malli.closed-schemas` refuses as an argument schema. Mark it
with `:malli.core/default`, the escape the checker documents for exactly this
shape: every other key is validated by `::lib.schema/query`, the first
conjunct, which is where `::details` is declared.

* Cover the new internal query key in the serialize fixture

`internal-keys-fixture-covers-the-schemas-test` asserts the fixtures carry
every internal key the schemas declare, so the serialization tests cannot
drift. Add `::details` with a schema-valid sample entry.
2026-10-06 09:44:55 -07:00
Alexander Polyankin
2dd2330f97 Create stub databases for missing databases on serdes import (#83574)
* Create stub databases for missing databases on serdes import

* Synthesize stub databases in the import resolver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop Database load dependencies and fix tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Trim the admin database page for stub databases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test importing content on a missing database as a stub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Exclude stub databases from serdes export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show database status in the admin list and clarify the stub healthcheck message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Exempt stubs from the network policy, keep is_stub out of serdes, and wait for the save in the stub e2e test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update the is_stub extract test now that is_stub is never exported

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Import every serialized Database as a non-stub

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix review findings: no Database load deps, empty details skip the network check, name-only config matching

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove dead missing-database handling now that imports stub missing databases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the stub database admin page sections and list request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Memoize every import lookup and drop the cache when an entity's load fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the Dimension path helper master replaced with serdes/field-path->field-ref

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:42:23 -04:00
Maksym Yakubych
5927b9bce6 Let ee-embedding-provider name the connection semantic search embeds through (#82521)
* Let ee-embedding-provider name the connection semantic search embeds through

* Keep registered embedder names from being shadowed by connection keys

* Skip the connection lookup when ee-embedding-provider names an embedder

* Do not fall back to the openai connection once ee-embedding-provider names one

* Warn when removing the connection semantic search embeds through

* Let the vLLM preflight test see the losing probe start before it is cancelled

* Keep ee-embedding-provider to semantic search and to OpenAI connections
2026-10-06 13:40:29 -03:00
Tyler Plude
355147d6a9 [GHY-4748] Exclude tables hidden in user settings from sync (#83494)
* add table-user-visibility-type to the warehouse schema overlay

* exclude tables hidden in user settings from sync

* test that the 064 user-settings migration keeps hidden tables hidden

* match field sync batches over hidden tables before skipping them

* tighten hidden-table sync tests and docstrings
2026-10-06 09:25:42 -07:00
Nathan Voxland
3e57a280fe Apply the configured path settings to the Snowflake OIDC token file (#83496) 2026-10-06 11:06:09 -05:00
Will Fry
01d4659a3e GDGT-3241: SegmentedControl constrained text + icon props (#83336)
* GDGT-3241 Constrain SegmentedControl items to text and icon props

Items are now typed data (label, icon, ariaLabel) instead of arbitrary
label nodes, and the component owns icon size and spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update Loki Snapshots

* set line height on SegmentedControl label

* add second click to assert handler doesnt refire on multiple clicks

* interface -> type

* add test

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-10-06 15:42:18 +00:00
Sébastien
db875cbd2a Add a Refresh button to the embedded question when automatic reruns are off (#83732) 2026-10-06 16:54:33 +02:00
Sébastien
8561804a1a Hide MCP UI tools from clients with inline charts turned off (#83644) 2026-10-06 16:54:00 +02:00
github-automation-metabase
66c6739fdd Auto-update documentation for master (#83697)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-10-06 10:48:35 -04:00
Anderson
5ddcbeeedf Show the metric icon for bookmarked metrics in the app sidebar (#83666)
Show metric icon for bookmarked metrics in the sidebar
2026-10-06 11:19:27 -03:00
Anna Tomka
762512b71d GDGT-3291: drop creator from the create/send notification request schema (#83555)
* GDGT-3291: drop client-sent creator in POST /api/notification/send

* GDGT-3291: clarify why /send drops the client-sent creator

* GDGT-3291: drop creator from the create/send request schema and stop sending it on Send now
2026-10-06 13:58:32 +00:00
Jeff Bruemmer
12f8e514d4 docs - 432 note on remote sync (#83667) 2026-10-06 13:21:59 +00:00
Uladzimir Havenchyk
96e656af6e fix: port undolisting toasts to the toast spec always dark (#83311)
* port remaining toast contents to the always-dark toast surface

* add spacing between toast action buttons

* address review
2026-10-06 13:02:40 +00:00
Andrei Simionescu
83f20bc0b5 Add Claude Opus 5.5 and Sonnet 5.5 to Metabot (#83647) 2026-10-06 14:00:04 +01:00
YB
2d4ff73f16 Bump libthrift to 0.25.0 (#83721)
bump libthrift to 0.25.0 in databricks and hive-like drivers
2026-10-06 14:07:38 +02:00
Nemanja Glumac
6623df6c22 Consolidate permissions e2e tests (#83645)
* Remove duplicate permissions e2e tests

Drop the second copy of the create-queries 'query builder only' group
view test, the admin-permissions blocked-viz test that the nodata
reproduction already runs with the same graph and user, and the
transforms list denial test whose steps run in the grant-after-deny
test. The coverer for the blocked-viz test now scopes its error text to
the query builder, and the grant-after-deny test now asserts the key
image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge permissions e2e tests that share setup

Fold tests into siblings that run the same restore, user, token and
permission graph, keeping every step and assertion in its original
starting state:

- create-queries: 'no' in group view runs after the 'query builder and
  native' save, as a second save for another group
- baseline: the unauthorized-path loop runs as one test; the nodata
  data-load check moves into the nodata reproduction, which also hosts
  the blocked viz-settings reproduction
- reproductions: duplicate-in-view-only-collection follows the
  save-modal reproduction; the two raw-mode pivot/combo checks share
  one test
- application/admin: the application help panel joins the data help
  test; default subscriptions and monitoring checks share one test;
  the collection options check runs inside the collections edit test
- transforms: list page joins create-via-UI; create and run API denials
  share one test
- view-data: the blocked-table QB question joins the native question
  test
- sandboxing via API: existing and new user attributes share one test;
  the three normal-user sandbox checks share one test
- impersonated: drop the outer beforeEach repeated by the inner one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Repair permissions e2e assertions that could not fail

- view-data: the impersonation-then-granular test switched the wrong
  database to Granular; the database-view sandbox test clicked Save
  changes without confirming, then re-read the unsaved draft
- sandboxing via UI: every custom-column case only checked '20 rows',
  which the unsandboxed limit-20 query also shows; expression cases now
  check Gizmo-only rows, literal cases check sandboxed responses
- sandboxing via API: a misspelled columnAssertion and an undefined
  PEOPLE.USER_ID skipped the column checks; the user attribute test
  now checks the saves
- admin help: not.contain with a RegExp is stringified and never fails;
  the stub intercept now registers before the visit
- application settings: drop absence checks for texts that no longer
  exist in that container
- downloads: assert the limited/full download level, and reload after
  saving download permissions
- reproductions: the 20717 regex had a typo and the 22727 text check
  passed on an empty picker; 39221 now performs a real user-local
  setting write before checking for a site settings fetch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add positive preconditions and waits to permissions e2e tests

Pair negative assertions with a positive check on the same element,
wait for the requests that the following checks depend on, and assert
exclusive category lists for the native-question sandbox. The revoked
subscriptions test now sets up SMTP so the missing Subscriptions button
reflects the permission rather than the absent email channel, and the
download denials check the download button by label as well as icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop unused setup and fix names in permissions e2e tests

Remove intercepts that are never waited on, an unused erroring question,
a commented-out duplicate line, a reload followed by a visit, and a
data model click that only unhid a table before navigating away. Tag
the misconfiguration spec @external since it uses the writable
Postgres database. Fix the 45073 title, the 14873 describe name, a
typo, unused helper params, the OSS index constant used after the
downgrade, and the title of the no-token View data column test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore the reload before opening the native editor

Clicking a table on the browse page leaves the app on an ad-hoc
question URL, so the native editor helper's hash-only visit would not
reload the page without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test real models in native download permissions tests

The model steps only renamed the card, so they re-tested the plain
question. Convert the card to a model and visit it as one. Also assert
the visible category values before the absent ones, so the absence
checks can't pass before the values have loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge two more permissions e2e tests that share setup

- sandboxing via API: the 14990 subscriptions sidebar check runs in the
  sandboxed subscription test, which has the same SMTP setup, sandbox
  and user
- view-data: the unsaved impersonated-then-granular check runs before
  the saved variant, from a fresh visit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Chain permissions e2e tests whose earlier parts save nothing

Run read-only or draft-only tests first and the saving test last, with
a full page load between parts:

- admin-permissions: the 30494 toggle check before collection editing;
  the group-view revision-change check before the database-view one
- view-data: preserve-on-granular and parent inference before the
  group-view granular save
- impersonated: impersonated access checks before the cache check,
  signing back in as admin first
- transforms: deny, grant, then deny again in one test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix positive preconditions in permissions e2e tests

The native editor run button is scoped by test id because the hidden
floating run button also carries the Refresh label and stays enabled.
The view data popover omits the current value, so it is checked for
Blocked instead of Can view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the collection permission popover by reloading

Escape does not dismiss the permission popover, so the test now visits
Second collection directly, which also discards the open popover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address permissions e2e review feedback

Merge the remaining tests that share setup: the transforms column check
and the save flow, the transform page denial and the API denials, the
adhoc native check and the unauthorized paths, the blocked card check and
the nodata reproduction, and the two database view create queries tests.
Run the steps of merged tests once instead of repeating them, and drop
the second #37811 check.

Remove an unused join and sandbox setup and a final visit with no check,
and assert the mini picker has loaded before checking an absent database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check a visible mini picker item before the absent database

The transforms mini picker lists no databases for this user, so the
positive check uses Our analytics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:35:08 +00:00
Romeo Van Snick
7907c04a71 Fix parseZone day arithmetic for datetimes without an offset (#83394)
* Fix parseZone day arithmetic for datetimes without an offset

* Assert the sync start time with the formatter the component uses

* Parse both sides of the SmartScalar same-day comparison with parseZone
2026-10-06 19:00:23 +11:00
Romeo Van Snick
41661db787 Keep engines and the timezone list out of the bulk settings payloads (#80138)
* Keep engines and the timezone list out of the bulk settings payloads

Both are large, change only when the instance is upgraded, and are read by a
handful of screens, so inlining them into every document and into
GET /api/session/properties costs every visitor. :include-in-list? false leaves
them out of those payloads while GET /api/setting/:key still serves them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fetch engines and the timezone list by key in the screens that need them

The settings payload no longer carries either value, so the consumers read them
through RTK queries against GET /api/setting/:key instead of getSetting. Both
queries hold their entry indefinitely, since neither value changes without a
restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Wait for model metadata before opening the Actions menu in e2e

The menu is gated on supportsImplicitActions(), which reads the source table's
metadata, so the test raced GET /api/card/:id/query_metadata. The existing
getModel alias does not cover that request, because a Cypress glob does not
cross a path separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 18:59:42 +11:00
Sébastien
0feec26aea Fix flaky cursor-filtering diagnostics test (#83398) 2026-10-06 09:30:32 +02:00
github-automation-metabase
39ea08c8d8 Update translations 2026-10-06 (#83725)
* Update translation sources 2026-10-06

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-10-06 06:22:08 +00:00
Alexander Polyankin
9050b176d6 Move Dimensions to FieldUserSettings in serdes (#83652) 2026-10-06 02:26:31 +00:00
Fraser
145357fbb1 Skip unused addons and font loads in Loki screenshot runs (#83068) 2026-10-06 14:23:32 +13:00
Tyler Plude
e2369cc1d4 [GHY-4755] Only follow a discovered remote sync task the current user started (#83579)
only follow a discovered remote sync task the current user started
2026-10-05 16:28:49 -07:00
bryan
d6b2903ccd Skip the redundant audit DB sync after a fresh install (#83681)
Skip the redundant audit DB sync after a fresh install (GHY-4728)

The install sync in maybe-install-audit-db! sees every analytics view but
never recorded last-analytics-views-checksum, so maybe-sync-audit-db! always
found the views stale on a fresh install and synced the audit DB a second
time. Record the checksum after the install sync. On a Postgres app DB this
removes the second sync (~2s); H2/MySQL still sync again for the engine swap.
2026-10-05 23:17:21 +00:00
Braden Shepherdson
27e4890c7a Metric Dimensions: Healthy remote sync, with unportable but stable IDs (#82751)
There are two significant changes here.

First, properly signal a change to `dependencies` and the remote sync machinery when the dimension (mappings) are edited. They were ignored before because they didn't publish an `:event/card-update`.

Second, for pre-curation metrics (with `:card_schema` 23 and earlier) the `:dimensions` are regenerated on each read, until the first time they are saved by an edit being made. Such metrics originally had *randomized* `:id`s, but since they're not persisted it made them permanently dirty from remote sync's POV. These are now based on the metric card's `:entity_id` as well as the mapped column, which means they are *stable* on any particular instance, *unique* within that metric (since it includes join details, not just the target field ID), but they are not *reproducible* across instances.

That's fine - the instance that created a metric pre-64 will export it with a now-stable ID; that will be persisted by downstream instances on import. Eventually the originating instance will persist them too, when it makes an edit to the metric. Since the generated IDs are now *stable*, all instances will converge on the true IDs.

Apologies for the noise, since this required adding `:entity_id` to a pile of `(t2/select [:model/Card :field :lists] ...)`, but its required by the `:card_schema` upgrades so we need to ensure that it's fetched.

Co-authored-by: Ethan McCue <ethan@mccue.dev>
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: appleby <86076+appleby@users.noreply.github.com>
2026-10-05 22:21:38 +00:00
Andrei Chernykh
6bf81a1340 Migrate the billing help panel off Emotion (#83677) 2026-10-05 15:14:05 -07:00
Chris Vandoodewaard
bff0c3ba61 Consolidate search e2e tests (#83454)
* Remove duplicate search e2e tests

Drop tests whose steps and assertions are fully repeated by another test
in the same describe:
- snowplow "filter used in the URL" tests, each the first step of the
  matching "filter removed from the UI" test
- created_at/last_edited_at=thisday hydrate tests, covered by the
  remove-X tests that load the same URL
- native query hydrate test, covered by the toggle-off test
- "sandboxed" user runs of the created_by/last_edited_by tests: the user
  is not sandboxed in this spec and runs the same path as the normal user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge search filter e2e tests that share setup

Run the apply, hydrate-from-URL and remove steps of each search filter in
one test instead of one test each. Every step that loaded its own URL
still loads it, now with a fresh alias, so each step starts from the same
state as before. The setup for each filter now runs once instead of up to
nineteen times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge search bar and recents e2e tests that share setup

- typeahead admin/normal tests run inside the matching universal search
  tests, which use the same users and entry page
- the empty-string search check runs inside the "Enter with no text"
  test, now with an assertion that no search request was sent
- the direct dashboard visit (metabase#35099) runs inside the homepage
  redirect test (metabase#34226), both fixed by the same change
- the long-description overflow check runs inside the 28788 test
- the recents list and keyboard tests run inside the 36868 test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Repair search e2e assertions that could not fail

- expectSearchResultContent passed a callback to .first(), which Cypress
  ignores, so no collection label was ever checked
- the snowplow "type filter removed" check expected content_type [],
  which matches the earlier ["card"] event; the removal event has null
- the trash filter check for zero results passed while results were
  loading; it now waits for the empty state and the URL
- the recently created collection test clicked the modal title and found
  the collection in the root listing; it now opens Recent items

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Strengthen weak search e2e assertions

- check the created_at pill and URL after picking Today, since sample
  content is also created today and results alone can't show the filter
- expect exactly one result for last_edited_at=thisday, which excludes
  the Reviews table
- wait for the URL to change before reading results with non-retrying
  checks after a filter is applied or removed
- check that filters survive a new text query, not just the results
- check that full page search opened after Enter (metabase#20018)
- check that "Next page" took effect before changing filters (#65501)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop unneeded setup from search filter e2e tests

- the created_by beforeEach restored the snapshot a second time
- the last_edited_by and last_edited_at setups edited questions through
  the query builder (two page loads and a save per question); a PUT as
  the editing user records the same revision that search reads the last
  editor and edit time from. The edit waits a second after creation
  because an edit in the same second is shown as "Created"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore search e2e steps narrowed by the merges

- run the non-admin user filter steps for the "sandboxed" user again, in
  the same test as the normal user: it has no data access, so it is a
  different user setup even though no sandbox is applied
- pick two users at once from a fresh page, as the separate "more than
  one user" tests did, instead of adding a user to an existing pick
- hydrate created_by with the original URL parameter order
- check the results heading on the native query hydrate step
- wait for the verified request after the toggle instead of the URL alone
- catch any /api/search request in the empty-query check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Consolidate search scenarios and preserve regression coverage

* Scope search e2e request counts to search context

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmyW5RuqbkDJu7aUM3Po3J

* Consolidate remaining duplicate search e2e coverage

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:52:04 -04:00
bryan
6cb8e9e904 Add API changelog tooling: semantic OpenAPI diff with severity ranking (#82688)
* Add API changelog tooling: semantic OpenAPI diff with severity ranking

Answering "has the API changelog been updated?" currently means reading a
10k-line JSON diff by hand, so it falls to whoever is asked in Slack. This adds
the mechanical part of that job as two mage tasks.

`./bin/mage openapi-diff OLD.json NEW.json [--severity breaking]`

resources/openapi/openapi.json is generated from the Malli endpoint schemas, so
the spec at any git ref is what the API was at that ref. The task parses two
specs into {METHOD /path -> shape} maps, inlines $refs (component schemas change
too), and compares params, request body, and response schemas.

Findings are classified BREAKING / ADDITIVE / DOC_ONLY and sorted breaking-first.
A change is breaking when it requires MORE from the caller or provides LESS to
the caller; anything else is just a change. That rule is directional and it
inverts between request and response, so severity is decided from both schemas
rather than from how a change happens to render:

  request   breaking: newly required, type/enum narrowed, schema closed, field removed
            additive: new OPTIONAL field or param, type/enum widened, made nullable
  response  breaking: field removed, field may now be null
            additive: new field returned

Adding an optional parameter is not breaking; returning extra data is not
breaking. A public diff of two Stripe specs found 663 of 679 changes additive,
which is why filtering matters more than summarizing.

`./bin/mage openapi-staleness`

The self-healing CI job runs only on PRs labelled `openapi-self-healing`, so the
committed spec drifts; it is currently ~38 API commits behind. A stale spec makes
the diff under-report, so this reports drift and names the commits rather than
letting a clean diff read as "no changes". The label gate is deliberate (#67854
introduced it to stage the rollout after #67294 removed the spec entirely over
merge conflicts), so it is left alone here.

Response coverage is partial: ~199 response entries declare a schema and can be
compared, ~1,700 are description-only 2XX/4XX/5XX stubs and cannot be.

Tests cover both directions of every request and response rule, $ref resolution
including the SCIM schemas whose property is literally named "$ref", a guard
against the failure mode in mozilla-ai/otari#1315 (breaking markers dropped at
render time), and a signal-to-noise case asserting additive bulk cannot drown
the breaking few.

* Generate specs from git refs, so the diff is not bound to the committed blob

`./bin/mage openapi-diff --refs <old-ref> <new-ref>` checks each ref out into a
throwaway worktree, runs that ref's own `generate-openapi-spec`, and diffs the
two results. Nothing is committed and no checkout is touched.

This removes the staleness problem rather than reporting it. The committed
`openapi.json` only updates on PRs labelled `openapi-self-healing`, so it lags
source badly: `--committed origin/release-x.63.x origin/master` reports 0
findings, because both refs carry the same stale blob. Generating from source
reports 670 -> 714 operations and 10 removed endpoints for the same pair.

It needs no support from the ref being diffed beyond `generate-openapi-spec`
already existing, so it works on historical refs. `--committed` keeps the fast
path and now prints a warning that its result under-reports, since an empty diff
from a stale spec is indistinguishable from an API that did not change.

Validated against the real v63 -> master delta. The top removals are the three
`GET .../execute` endpoints that the hand-written 0.64 changelog documents as
replaced by `POST .../execute/values`, and it independently surfaces a query
param rename (`pinned_state` -> `pinned-state`) that has no entry yet.

That run also shows a precision limit worth stating: it reports 239 breaking
findings, but 217 are the identical `additionalProperties: false` line from one
PR (#82447, closing mu/defn argument schemas). The classification is right --
closing a schema does require more of the caller -- but as changelog input that
is one entry, not 217. The skill now tells the drafter to collapse findings by
text and treat a repeated finding as a single systematic change.

* Group identical findings so a systematic change reads as one entry

`--grouped` collapses findings with identical text across endpoints and sorts by
blast radius, because the per-endpoint view is the wrong shape for drafting a
changelog.

On the real v63 -> master delta it turns 239 breaking findings into 113 distinct
changes. The largest spans 217 endpoints and is a single upstream PR (#82447,
closing mu/defn argument schemas): one changelog entry, reported once with its
endpoints, instead of 217 identical lines to scroll past.

Grouping is a separate render mode rather than a transformation of the default.
The per-endpoint view answers "what changed on this route?", the grouped view
answers "what distinct changes happened?", and collapsing the former into the
latter would lose the ability to check a specific endpoint.

The long tail is where the individually-interesting entries live: the same run
surfaces a `pinned_state` -> `pinned-state` query param rename that no changelog
entry covers yet.

* Fix review findings: two defects let breaking changes pass silently

Eric's review found 13 issues. The two that mattered both caused false
negatives - the tool reporting nothing where a caller would break.

The depth guard counted nesting levels, not `$ref` hops. `seen` already breaks
cycles, so the counter was doing a different job than its name claimed: past 12
levels both specs collapsed to the literal "<deep>" and compared equal, hiding
every change in 52 of 637 operations. It now advances only on a `$ref` hop, and
52 falls to 17.

`response-lines` compared only top-level properties and delegated anything deeper
to `(widening? new old)`. Swapping those arguments inverts value-set semantics -
which is why the nullable case read correctly - but not object-property
semantics, so a removed nested response field read as additive. It now recurses
the way `body-lines` does.

Chasing the second one surfaced a third of the same shape: `widening?` never
compared array `items`, so a narrowing inside an array was invisible. Found by a
test written for the SCIM `$ref`-named-property case.

Also fixed:

- `committed-spec!` built a shell command by interpolation, so a ref containing
  `$`, a quote, or a space produced a wrong file. Values now ride as positional
  arguments and the shell never parses them.
- The `--grouped` header counted severity-filtered-out changes, inflating the
  count above the body printed beneath it.
- The removed and added endpoint lists were severity-gated, so `--severity
  breaking` hid the added half of an endpoint move - at exactly the severity the
  skill tells a drafter to start with. Both lists now print at every severity.
- The staleness check watched 66 of the 158 files that contain `defendpoint`,
  missing `src/metabase/*/api/*.clj` and all of `enterprise/backend/src`, so it
  could report `OK: spec is current` with dozens of newer endpoint files on disk.
  It also compared `%cI` strings across a history carrying both `+08:00` and `Z`,
  and listed commits from a narrower pathspec than the date came from.
- `DOC_ONLY` was unreachable: a description-only change produced no findings and
  was dropped. Descriptions are what the skill reads to spot changes it cannot
  explain, so a reworded docstring now surfaces as doc-only.
- A misspelled `--severity` fell through to nil and silently printed everything.
  It now exits. `--committed` without `--refs` was accepted and ignored; it now
  exits too.
- Each `--refs` run left two multi-megabyte specs in /tmp.
- `brief` truncated its non-map branch without the "..." its map branch adds.
- The first commit message claimed a test covered the SCIM `$ref`-named property.
  No such test existed; one exists now, along with tests for the depth guard, the
  nested response removal, and the doc-only path.

One correction to that commit message stands: `babashka.json` would have avoided
the `:discouraged-namespace` ratchet entirely, since `mage/doctor.clj` already
uses it unsuppressed. Keeping cheshire for consistency with the three other mage
namespaces that use it; the budget moves 68 -> 69 against current master.

The `de-slop` skill the review could not find does exist, at
~/.claude/skills/de-slop - it is a personal skill, not in the repo.

* Fix second review: four more directions where a finding was inverted or absent

All nine findings addressed. The reviewer supplied a failing test for each of the
first five; all five reproduced on the previous head, and all now pass.

Three of the four major findings were the same mistake in different places: a
schema shape that the output path handed to `widening?` with swapped arguments,
which inverts value-set semantics but not object-property semantics.

- A response array of objects went to the leaf branch, so a removed element field
  read as additive and an added one read as breaking. 24 of the 199 comparable
  response schemas in the committed spec are top-level arrays. Arrays now recurse
  through `items` in the output direction.
- `schema-props` unwraps a nullable `oneOf`, so a nullable OBJECT reached the
  object branch, where only keys and requiredness were compared and the type
  change was invisible. The committed spec has 726 nullable object properties.
  Both `body-lines` and `response-lines` now compare type sets there, with the
  severity test pointing the correct way for each direction.
- `response-lines` discarded the required sets, so a response field the API may
  now omit produced nothing - the same loss of guarantee as one that may now be
  null, which was already breaking. 174 of 637 operations declare `required` in a
  response schema.

The fourth major finding was a shortcut in `widening?`: any empty old schema was
treated as "no constraint", so a field going from Malli `:any` to a concrete type
read as additive. `operations` stored an absent body as `{}`, which made "no body"
and "any value" indistinguishable; an absent body is now nil and `{}` falls
through to the type and enum checks. After ref resolution the committed spec has
1,182 property schemas that are `{}`.

The depth limit is gone rather than re-tuned. `seen` already terminates every
path against a finite component set, so the limit only ever replaced deep nodes
with the same literal on both sides - which compares equal and hides the change.
The MBQL clause schemas form chains long enough to hit it: 17 operations carried
`<deep>`, and now none do while 18 terminate on genuine cycles via `<recursive>`.
A self-diff of the committed spec costs 2.9s instead of 1.4s and still reports 0
findings.

Also fixed:

- `format-epoch` shelled out to `date -r`, which only BSD and macOS read as epoch
  seconds; on GNU coreutils it means "the mtime of this file" and printed nothing,
  silently, because the call was `:quiet?`. Now `java.time`.
- `openapi-staleness` claimed the spec was "current" from endpoint files alone.
  661 of 763 component schemas live outside `.api` namespaces, so a shared-schema
  change moves the generated spec invisibly. The OK message now says what it
  actually checked and points at `--refs`.
- A `--refs` run that could neither generate nor read the second ref leaked the
  first spec: materialization happened in the `let` bindings, outside the `try`
  whose `finally` removes the directory.
- The skill told an agent that two identical committed specs prove the API did not
  change, and that the commit touching `openapi.json` is the PR that changed the
  API. Both are wrong for the same reason - the committed spec only moves on
  `openapi-self-healing` PRs. It now says to re-run without `--committed` before
  reporting no changes, and to search endpoint source for attribution.

* Drop the ratchet bump: babashka.json instead of cheshire, and fix stale docs

The `:discouraged-namespace` increase was avoidable and the rationale I gave for
it was backwards. `babashka.json` is not in the discouraged list, and 6 mage
namespaces already use it unsuppressed against 5 on cheshire - so the
"consistency" argument pointed the other way. Swapping to it removes both the
68 -> 69 bump and the `:clj-kondo/ignore`, and this PR no longer touches
.clj-kondo/ratchets.edn at all. `read-str` keywordizes by default, so the two
spec reads pass `{:key-fn identity}`; this namespace is string-key throughout.
Verified unchanged behavior: self-diff still 0 findings, the real 0.64 change
still 1 breaking.

Three docstring counts were wrong. The `api-source-pathspecs` docstring claimed
238 `/api/ee/` paths (actual 106 - it looks like 106 paths plus 132 operations,
double-counted), 66 files matching `src/metabase/**/api.clj` (actual 65), and 158
files containing `defendpoint` (actual 160). The argument the docstring makes is
unaffected - the narrow pathspec still misses most endpoint files - but the
numbers are now the measured ones.

Also removed documentation that described code this branch already deleted:

- A comment in `resolve-refs` said "Only a $ref hop advances depth" while the
  docstring 17 lines above correctly says there is no depth limit. There is no
  depth counter left; the comment described the previous commit's version.
- Two test comments and a test name referenced a `<deep>` placeholder guard that
  no longer exists, and cited a "52 of 637" measurement of the mechanism that was
  removed. The tests themselves are valid - they assert deep and long-chain
  changes are detected - so only the prose changed.
- SKILL.md quoted "`--committed` reports 0 findings" for v63 vs master as a fixed
  figure. That was true when written and is not now: the blobs have since
  diverged and it reports 1. The underlying point stands, so it is stated
  qualitatively rather than as a number that drifts.
- SKILL.md said `--refs` "works on any ref". The code only attempts that and has
  a documented fallback for refs that will not build with the current toolchain;
  the text now says so.

* Fix third review: object unions and nullable arrays hid breaking changes

- A union of two or more object variants was compared by its first
  variant only, so removing or editing any other variant produced no
  finding. Such unions now compare whole: widening only when every old
  variant survives unchanged.
- Nullable arrays (`oneOf [array, null]`) skipped the array branch, so a
  change to their items was ranked additive. Both array branches now
  unwrap the null first.
- The render test never rendered. It now asserts breaking operations
  print under the breaking headings of both renderers, and a new test
  covers the --severity filter.

* Rank changes to unmodeled schema keywords breaking, and compare bounds, allOf, and tuples

The old widening check returned "safe" for any keyword it did not
recognize, so each review round found another way for a breaking change
to read as additive: allOf, minimum/minLength, map-of value schemas,
prefixItems, nullable enums, closed nullable objects.

`compatible?` replaces it. It takes a direction (:request or :response),
compares unions variant by variant, compares bounds, pattern, and format,
and compares allOf and prefixItems by position. A change to any keyword
it does not model ranks breaking. A false breaking finding costs one
look; a missed one ships undocumented.

Documentation keys (description, title, examples, default) are stripped
before comparison, so a reworded field description is doc-only instead
of an additive schema change.

Also:
- When two summaries are identical, the finding names where the schemas
  differ (e.g. `items.enum: +"measure"`), so distinct changes no longer
  merge in the grouped view.
- Generation deletes the checked-out spec before running, so a generator
  that logs and swallows an error no longer passes the committed spec off
  as fresh. A timeout falls back to the committed spec instead of
  throwing.
- An empty or non-spec input file exits 1 instead of reporting 0 BREAKING.
- openapi-staleness lists commits after the spec commit, not including it.

* Close the gaps an independent check found in the compatible? rewrite

- Only a nullable union unwraps to its object. `string | object` and
  similar unions are compared whole, so a change to the non-object
  variant is no longer hidden.
- Keywords beside an object's properties (allOf, a map-of
  additionalProperties, minProperties) are compared.
- If the field walk finds nothing but the stripped schemas differ, the
  whole schema is ranked instead of falling through to doc-only.
- Inclusive and exclusive numeric bounds compare as one bound.
- A response allOf may gain members (narrowing output); a request allOf
  may lose them.
- An enum or const without a type widening to its type is additive.
- requestBody.required and request content types are compared, and a
  response that declares a schema for the first time is reported as
  additive.
- A missing input file says so.

* Match operations across path-variable renames; count only visible severities in the grouped header

- Operations and path params are keyed by position, not variable name.
  Renaming `{id}` to `{card-id}` leaves the URL a caller sends unchanged,
  so it is now a doc-only "path variable renamed" finding rather than a
  removed endpoint plus an added one. Routes that would merge under
  position-keying (`/api/database/{id}/schemas` and
  `/api/database/{virtual-db}/schemas`) keep their names. Reports still
  print the real spec paths.
- At --severity breaking, the grouped header no longer counts the
  new-endpoint list, which is additive. The list still prints.
- The skill's attribution section no longer opens with the
  openapi.json git log that it then says names the wrong author.

* Rank a changed or dropped request default as breaking

`default` was stripped with the documentation keys, so a default that
flipped (say a query param from false to true) ranked doc-only and
--severity breaking hid it. The schema still validates the same
requests, but a caller who omits the field now gets different
behavior.

Request defaults are now compared: changed or dropped is breaking,
added where there was none is additive. Response defaults are ignored.

* State one definition of breaking that covers every rule

A change is breaking when an existing caller, sending exactly what it
sent before, can now fail or get a different result. The old wording
(requires more / provides less) did not cover changed defaults, and it
presented two conservative rules - removed request fields and changes
to unmodeled keywords - as if they followed from it. Those are now
stated as what the tool does when it cannot tell.

* Skill: point to docs-write, and attribute with all six API source pathspecs

de-slop is a personal skill the repo does not ship; docs-write carries
the Metabase style guide. The attribution git log used three of the six
pathspecs in api-source-pathspecs and missed enterprise api/ dirs and
routes.clj files (35 tracked files, e.g. sso/api/saml.clj).
2026-10-05 21:33:14 +00:00
Andrei Simionescu
515ab1961f Explain BYOK provider failures in Metabot instead of a generic error (#83430)
* Explain BYOK provider failures in Metabot instead of a generic error

* Don't report every provider 403 as a rejected key
2026-10-05 22:23:26 +01:00
749 changed files with 55598 additions and 51420 deletions

View File

@@ -1,6 +1,6 @@
## Linting and Formatting
- **Lint:** `bun run lint-oxlint-pure`
- **Lint:** `bun run lint-oxlint`
- Run oxlint on the codebase
- **Format:** `bun run format`
- Format code with oxfmt (`oxfmt --write`); use `bun run lint-format-pure` to check without writing

View File

@@ -0,0 +1,184 @@
---
name: api-changelog
description: Draft or audit the REST API changelog (docs/developers-guide/api-changelog.md) by semantically diffing the checked-in OpenAPI spec between two git refs. Use when asked "has the API changelog been updated", "what are the breaking API changes in vNN", when cutting a release branch, or when reviewing whether a PR needs a changelog entry.
---
# API changelog
Answers "what changed in the REST API between two versions, and is the changelog
honest about it?"
## Ground truth
`resources/openapi/openapi.json` is generated from the Malli endpoint schemas, so
the spec at any git ref is what the API actually was at that ref. Diff the spec,
do not read endpoint source or boot a server.
Response coverage is partial: about 199 response entries declare a real schema
and are compared, but roughly 1,700 are description-only `2XX/4XX/5XX` stubs. For
an endpoint with no declared response schema, a response-shape change is
invisible here. Say so rather than implying the diff is complete.
### Check the spec is fresh first - it usually is not
The self-healing CI job (`.github/workflows/openapi-check.yml`) only runs on PRs
labelled `openapi-self-healing`, so the committed spec drifts behind master by
default. Verify before trusting a diff:
```bash
./bin/mage openapi-staleness
```
If the source is newer, regenerate before diffing the new ref:
```bash
bun run generate-openapi # rewrites resources/openapi/openapi.json in place
```
This staleness check only matters when you are diffing the committed spec.
`--refs` (below) generates each ref's spec from its own source, so it is not
subject to this drift at all - prefer it.
When you do read a committed spec, a change present in source but absent from
that spec is a **false negative**: the diff will not report it, and an empty
result is indistinguishable from an API that did not change. Confirm suspected
gaps with `grep -rn "defendpoint" src/.../api.clj`.
## Steps
1. Pick the refs. Default old ref = the previous release branch
(`origin/release-x.63.x`), new ref = the one being released
(`origin/release-x.64.x`) or `origin/master`. Confirm with the user if
ambiguous. `git fetch origin` first.
2. Extract and diff:
```bash
# Preferred: generate both specs from source. ~2 min (two JVM boots), no drift.
./bin/mage openapi-diff --refs origin/release-x.63.x origin/release-x.64.x --severity breaking
# Fast, but reads the committed spec, which lags source. Warns when it does.
./bin/mage openapi-diff --refs --committed origin/release-x.63.x origin/release-x.64.x
# Two spec files directly.
./bin/mage openapi-diff /tmp/old.json /tmp/new.json --severity breaking
```
`--refs` checks each ref out into a throwaway worktree and runs that ref's own
`generate-openapi-spec`, so it is not limited to refs that carry a committed spec
and never touches your checkout. Very old refs may not build with the current
toolchain; it falls back to the committed spec and says so when that happens.
Prefer it. `--committed` compares whatever blob each ref happens to carry, and
when a release branch and master share a stale one it reports far fewer findings
than the API actually changed - an artifact of the spec, not of the API.
### Start from the grouped view
A single upstream change can produce hundreds of findings. `--grouped` collapses
identical findings and sorts by blast radius, which is the shape you draft from:
```bash
./bin/mage openapi-diff --refs <old> <new> --severity breaking --grouped
```
On the real v63 -> master delta that turns 239 breaking findings into 113
distinct changes. The top entry spans 217 endpoints and is one PR (#82447,
closing `mu/defn` argument schemas) - **one changelog entry, not 217**.
Drop `--grouped` when you need the per-endpoint view to check a specific route.
A change spanning many endpoints is usually one upstream PR: write it once, name
the cause, and say which endpoints it spans. The long tail of single-endpoint
changes is where the individually-interesting entries are - the v63 -> master run
surfaced a `pinned_state` -> `pinned-state` query param rename there.
Findings are classified and sorted breaking-first.
**A change is breaking when an existing caller, sending exactly what it sent
before, can now fail or get a different result.** That covers three cases: the
API requires more of the request, provides less in the response, or behaves
differently for the same request. The first two invert between request and
response:
| | Breaking | Not breaking |
|---|---|---|
| **Request** | field or param becomes required; type, enum, or bound (`minimum`, `minLength`, `pattern`, ...) narrowed; `additionalProperties: false` added; default changed or dropped | new *optional* field or param; type, enum, or bound widened; field made nullable; schema opened; default added |
| **Response** | field removed or no longer always returned; field may now be `null`; new enum value | new field returned; field that was nullable never is |
| **Endpoint** | removed | added |
When the tool cannot tell, it ranks the change breaking: a false alarm costs
you one look, a miss ships undocumented. Two rules follow from that, not from
the definition, so check them before drafting an entry:
- **A removed request field** is ranked breaking because the spec does not say
whether the server rejects or acts on keys it does not declare. If the
endpoint ignores unknown keys, the removal is not breaking.
- **A change to a schema keyword the tool does not model** is ranked breaking
so that it is never hidden. Read the finding to decide.
A reworded description, title, or example is DOC_ONLY.
Adding an optional parameter is not breaking. Returning extra data is not
breaking. Existing callers keep working in both cases.
Start from `--severity breaking`. The long tail of API change is additive
(a public Stripe-spec diff found 663 of 679 changes additive); reading the
full diff to find the breaking few is how entries get missed.
**Identical or near-identical committed specs do not mean the API did not
change.** Two refs often carry the same stale blob, so `--committed` can report
zero findings for a pair that generating from source shows has removed endpoints.
Before reporting "no API surface changes", re-run without `--committed`. Only a
clean generated diff supports that conclusion.
3. Review the classification. The tool decides severity structurally, but two
cases still need your judgement:
- **A removed + added pair is often one endpoint moving** (method change, or a
param moving between query/path/body). The tool reports two findings; read
the docstrings to pair them and write ONE changelog entry describing the move.
- **An `ADDITIVE` finding can still be worth an entry** when it is a new
endpoint clients should know about. Additive means "will not break existing
callers", not "not worth mentioning".
4. For every breaking change, decide whether you can state **what it does for a
client** from the spec's `description` alone. If you cannot, do not guess and
do not paper over it - list it under "Needs author input" with the endpoint
name and what is unclear. Thin endpoint docstrings are the actual finding;
report them loudly.
5. Cross-check `docs/developers-guide/api-changelog.md` for the target version.
Report three buckets:
- **Undocumented**: breaking changes with no changelog entry -> draft entries
- **Stale**: changelog entries with no matching spec change -> verify by hand
(may be a response-shape or behavior change the spec cannot see - do not
delete without asking)
- **Covered**: matched, no action
6. Draft entries in the existing house style: `## Metabase 0.64.0` heading,
one `-` bullet per change, endpoint as `` `POST /api/foo/:id` `` with
**`:id` colon-style params, not OpenAPI `{id}` braces**. Say what changed, what
clients must do, and whether there was a deprecation period. Apply the
`docs-write` skill. Show the diff before writing to the file.
## Attribution
Find who to ask about an unclear change. Search the endpoint SOURCE, not `openapi.json`: the committed spec only changes on
PRs carrying the `openapi-self-healing` label, which is usually a later, unrelated
PR, so its history names the wrong author.
```bash
git log <old-ref>..<new-ref> --oneline -- \
'src/metabase/**/api.clj' 'src/metabase/**/api/*.clj' 'src/metabase/**/routes.clj' \
'enterprise/backend/src/**/api.clj' 'enterprise/backend/src/**/api/*.clj' 'enterprise/backend/src/**/routes.clj'
```
Attribution by route search is unreliable in both directions - `git log -S` reports
where a string's count *changed*, which for a long-lived route is its creation years
ago rather than its recent removal. Treat any commit you find as a candidate to
verify, never as the established cause.
## Backports
A change landing in both `0.64.0` and older lines gets a full entry under the
newest version and a one-line pointer under each backport version
("See the 0.64.0 entry."). Match the existing `POST /api/slack/bug-report` pattern.

View File

@@ -39,7 +39,7 @@ what and who may depend on whom.**
| module elements, tiers, `enforceSharedTiers`, `enforcePublicApi` | `frontend/lint/module-boundaries.mjs` |
| shared sub-tiers and levels, cluster rules | `frontend/lint/shared-tiers.mjs` |
| standalone boundaries lint (all violations, incl. grandfathered) | `bun run module-boundaries` (config `eslint.config.module-boundaries.mjs`) |
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint-pure` |
| PR lint (only enforced modules fail CI) | `bun run lint-oxlint` |
| side-effect-free directories and their exceptions | `frontend/build/shared/rspack/side-effect-free-modules.js` (`SIDE_EFFECT_FREE_PATHS`, `SIDE_EFFECT_PATHS`) |
| the lint rules that enforce that promise | `metabase/no-module-side-effects`, `metabase/no-base-api-access` in `frontend/lint/eslint-plugin-metabase/rules/` |
| public-api enforcement | `metabase/enforce-module-public-api` rule, driven by `getPublicApiModules()` |
@@ -199,7 +199,7 @@ prefix; never generic `-shared` / `-feature` suffixes.
```
grep -rn "<old path>" frontend enterprise/frontend e2e # must be empty
bunx eslint --no-warn-ignored <touched files>
bun run lint-oxlint-pure # enforce-module-public-api included
bun run lint-oxlint # enforce-module-public-api included
bun run type-check-pure
bun run test-unit-keep-cljs <touched folders' specs>
bun run module-boundaries 2>&1 | tail -1 # after; no violation may name the new files

View File

@@ -172,7 +172,7 @@ export const OverviewStory = {
- **Scope controls per story.** A static matrix should hide knobs it ignores: `parameters.controls = { include: [...] }` (or `{ disable: true }`). Wire any kept knob (e.g. `children`) through to the cells so it does something.
- `loki.config.js` has a `storiesFilter`; check it before renaming a story that's already covered.
Validate: `bun run lint-oxlint-pure -- <files>` and `bun run type-check-pure`.
Validate: `bun run lint-oxlint -- <files>` and `bun run type-check-pure`.
### Optional: verify in the browser yourself
@@ -217,7 +217,7 @@ General gotchas when extending Mantine components — adapt per component:
- **Built-in icons use `currentColor`**, so they inherit the slot's text color.
- For text on a brand fill, follow the existing convention (`text-primary-inverse`) unless the user wants pure white; flag the dark-theme implication.
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint-pure -- <files>`, `bun run type-check-pure`.
After edits: `npx stylelint <css>`, `LINT_CSS_MODULES=true bun run lint-oxlint -- <files>`, `bun run type-check-pure`.
## Phase 5 — Iterate with the user

View File

@@ -624,6 +624,7 @@
metabase-enterprise.cache.config-test/with-temp-persist-models hooks.common/with-seven-bindings
metabase-enterprise.dependencies.api-test/with-dependents-test! hooks.common/with-two-bindings
metabase-enterprise.metabot-v3.tools.dependencies-test/with-dependent-transforms! hooks.common/with-two-bindings
metabase-enterprise.remote-sync.metric-dimension-api-test/with-synced-metric hooks.common/with-one-binding
metabase-enterprise.semantic-search.db.connection/with-migrate-tx hooks.common/let-one-with-optional-value
metabase-enterprise.serialization.api-test/with-serialization-test-data! hooks.common/with-three-bindings
metabase-enterprise.serialization.test-util/with-temp-dpc hooks.toucan2.tools.with-temp/with-temp

View File

@@ -728,6 +728,7 @@
:uses #{api
api-routes
app-db
mcp
metabot
metabot.llm
models
@@ -1331,7 +1332,8 @@
metabase.mcp.http-handler
metabase.mcp.init
metabase.mcp.usage
metabase.mcp.v2.api}
metabase.mcp.v2.api
metabase.mcp.v2.registry}
:model-exports #{:model/McpSessionLog :model/McpToolCallLog}
:uses #{activity-feed
ai-tracing
@@ -4130,7 +4132,8 @@
{:team "UX West"
:ns-prefix "metabase-enterprise.sandbox"
:api #{metabase-enterprise.sandbox.api.routes metabase-enterprise.sandbox.api.util metabase-enterprise.sandbox.schema}
:uses #{api
:uses #{analytics
api
api-scope
app-db
audit-app
@@ -4202,6 +4205,7 @@
:model/Collection
:model/Dashboard
:model/DataApp
:model/Dimension
:model/Document
:model/Field
:model/FieldUserSettings

1
.github/CODEOWNERS vendored
View File

@@ -43,6 +43,7 @@ docs/configuring-metabase/environment-variables.md # auto-generated
docs/configuring-metabase/config-template.md # auto-generated
docs/installation-and-operation/commands.md # auto-generated
docs/ai/providers.md # auto-generated
docs/ai/mcp-tools.md # auto-generated
docs/embedding/sdk/api # auto-generated
docs/embedding/eajs/snippets # auto-generated

View File

@@ -1,33 +1,17 @@
name: Create test plan
name: Create frontend test plan
description: Compute the affected-tests plan for the changed files.
inputs:
changed-files:
description: All changed files (CSV).
required: true
fe-changed-files:
description: Changed frontend_sources files (CSV).
required: true
be-changed-files:
description: Changed backend_sources files (CSV).
required: true
unit-infra-touched:
description: Whether FE unit infra changed.
required: true
loki-infra-touched:
description: Whether Loki infra changed.
required: true
e2e-infra-touched:
description: Whether e2e harness/support code changed.
required: true
shared-sources-touched:
description: Whether cljc/cljs/package.json changed.
required: true
stats-url:
description: Endpoint that stores the affected-tests stats row.
required: false
default: ''
stats-api-key:
description: API key for stats-url.
required: false
default: ''
outputs:
stats:
description: FE Affected Tests stats row (JSON).
value: ${{ steps.compute.outputs.stats }}
artifact-id:
description: Artifact containing test-plan.json for this run.
value: ${{ steps.upload.outputs.artifact-id }}
@@ -35,6 +19,14 @@ outputs:
runs:
using: composite
steps:
# The action reads the diff itself, so a caller hands it no file lists.
- name: Test which files changed
id: changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
token: ${{ github.token }}
list-files: csv
filters: .github/file-paths.yaml
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Resolve closest-ancestor e2e coverage manifest
@@ -59,13 +51,13 @@ runs:
id: compute
shell: bash
env:
CHANGED_FILES: ${{ inputs.changed-files }}
FE_CHANGED_FILES: ${{ inputs.fe-changed-files }}
BE_CHANGED_FILES: ${{ inputs.be-changed-files }}
UNIT_INFRA_TOUCHED: ${{ inputs.unit-infra-touched }}
LOKI_INFRA_TOUCHED: ${{ inputs.loki-infra-touched }}
E2E_INFRA_TOUCHED: ${{ inputs.e2e-infra-touched }}
SHARED_SOURCES_TOUCHED: ${{ inputs.shared-sources-touched }}
CHANGED_FILES: ${{ steps.changes.outputs.all_changed_files }}
FE_CHANGED_FILES: ${{ steps.changes.outputs.frontend_sources_files }}
BE_CHANGED_FILES: ${{ steps.changes.outputs.backend_sources_files }}
UNIT_INFRA_TOUCHED: ${{ steps.changes.outputs.frontend_unit_infra }}
LOKI_INFRA_TOUCHED: ${{ steps.changes.outputs.frontend_loki_infra }}
E2E_INFRA_TOUCHED: ${{ steps.changes.outputs.e2e_infra }}
SHARED_SOURCES_TOUCHED: ${{ steps.changes.outputs.shared_sources }}
E2E_SPEC_MANIFEST: ${{ steps.manifest.outputs.path }}
run: |
# stdout is the plan JSON; the planner's own info logs go to stderr.
@@ -83,7 +75,7 @@ runs:
- name: Report test plan
shell: bash
run: |
run: | # sh
plan=test-plan.json
row() { # name, selected, total
if [ "$2" -eq "$3" ]; then echo "| $1 | all ($3) | $3 |"; else echo "| $1 | $2 | $3 |"; fi
@@ -122,3 +114,20 @@ runs:
echo '```'
echo "</details>"
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
# Only land rows for PRs from the upstream repo so forks don't pollute the table. Best-effort:
# a failed upload must not cost us the plan.
- name: Upload affected tests stats
if: >-
${{ inputs.stats-url != ''
&& github.event.pull_request.head.repo.full_name == 'metabase/metabase' }}
continue-on-error: true
shell: bash
env:
STATS_JSON: ${{ steps.compute.outputs.stats }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
ENG_STATS_URL: ${{ inputs.stats-url }}
API_KEY: ${{ inputs.stats-api-key }}
run: bun .github/scripts/upload-affected-tests-stats.ts

View File

@@ -150,14 +150,14 @@ runs:
shell: bash
id: run-test
env:
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. Inside
# a called workflow `github.event` is the caller's payload, so these resolve here rather than
# in each calling workflow. On a normal CI run there are no dispatch inputs, so these render
# `false` and empty and the job's own selection stands.
# PROFILE_TESTS is not set here: the suites that test.profile-backend.yml dispatches set it in
# their workflow `env`, which reaches this step. The stress-test workflows are dispatched too
# but leave it unset, so they never profile.
#
# ONLY_TESTS replaces the job's own test selection, so every matrix leg of the chosen job
# runs the whole list.
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
# ONLY_TESTS comes from test.profile-backend.yml. Inside a called workflow `github.event` is
# the caller's payload, so it resolves here rather than in each calling workflow. On any
# other run it renders empty and the job's own selection stands. It replaces the job's own
# test selection, so every matrix leg of the chosen job runs the whole list.
ONLY_TESTS: ${{ github.event.inputs.tests }}
TEST_RUNNER_INDEX: ${{ inputs.runner-index }}
TEST_RUNNER_COUNT: ${{ inputs.runner-count }}

View File

@@ -1,5 +1,5 @@
# Catch-all filter — emits the full changed-files list as `all_changed_files`
# so create-test-plan in run-tests.yml can reuse dorny's diff.
# for the create-test-plan action.
all_changed:
- "**"
@@ -23,6 +23,8 @@ shared_ci: &shared_ci
- ".github/actions/prepare-frontend/**"
- ".github/actions/prepare-backend/**"
- ".github/workflows/run-tests.yml"
# Every suite's gate calls it, so a change here can turn any suite off.
- ".github/workflows/test.gate.yml"
shared_e2e: &shared_e2e
- "e2e/runner/**"
@@ -246,14 +248,14 @@ embedding_sdk_ci: &embedding_sdk_ci
- ".github/actions/prepare-cypress/**"
- ".github/workflows/embedding-sdk*.yml"
embedding_sdk_components:
embedding_sdk_components: &embedding_sdk_components
- *default
- *embedding_sdk_ci
- *sources
- *shared_e2e
- "e2e/test-component/**/*.cy.*.{js,jsx,ts,tsx}"
embedding_sdk_host_sample_apps:
embedding_sdk_host_sample_apps: &embedding_sdk_host_sample_apps
- *default
- *embedding_sdk_ci
- *frontend_sources
@@ -283,7 +285,7 @@ e2e_infra: &e2e_infra
- ".github/scripts/build-e2e-matrix.js"
- ".github/file-paths.yaml"
e2e_all:
e2e_all: &e2e_all
- *default
- *e2e_ci
- *sources
@@ -299,10 +301,28 @@ documentation:
docs:
- "docs/**"
embedding_documentation:
embedding_documentation: &embedding_documentation
- ".typedoc/**"
- "docs/embedding/**"
# Everything the SDK package build feeds, so the build itself follows the union of its consumers.
build_embedding_sdk_package:
- *embedding_sdk_ci
- *embedding_sdk_components
- *embedding_sdk_host_sample_apps
- *embedding_documentation
- *frontend_sources
# The uberjar only exists to feed e2e, the SDK suites that boot a Metabase instance, bundle-size and
# containerize, so it is worth building exactly when one of them might run. The SDK's type checks run
# against the package artifact alone, so embedding docs do not belong here.
build_uberjar:
- *backend_all
- *frontend_all
- *e2e_all
- *embedding_sdk_components
- *embedding_sdk_host_sample_apps
yaml:
- "**/*.yml"
- "**/*.yaml"

View File

@@ -117,6 +117,56 @@ describe("file-paths.yaml", () => {
expect(matches("ci_scripts", path)).toBe(true);
});
// Every suite's gate calls the same workflow, so a change to it has to reach every suite.
it.each([
"backend_all",
"frontend_all",
"frontend_loki_all",
"e2e_all",
"embedding_sdk_components",
"embedding_sdk_host_sample_apps",
"ci_scripts",
])("runs %s when the test gate changes", (filter) => {
expect(matches(filter, ".github/workflows/test.gate.yml")).toBe(true);
});
// The build filters replace what used to be an inline condition in run-tests.yml, so what they
// leave out is the whole point: a diff that changes nothing an artifact ships gets no build.
it.each([
"src/metabase/core.clj",
"frontend/src/metabase/App.tsx",
"e2e/test/scenarios/question/foo.cy.spec.js",
// The SDK component and host-app suites boot a Metabase instance, so their CI entry points
// have to build one.
".github/workflows/embedding-sdk.yml",
])("builds the uberjar for %s", (path) => {
expect(matches("build_uberjar", path)).toBe(true);
});
it.each([
".clj-kondo/ratchets.edn",
"README.md",
"docs/questions/introduction.md",
// The SDK type checks read the package artifact, not a running instance.
"docs/embedding/sdk/introduction.md",
])("skips the uberjar for %s", (path) => {
expect(matches("build_uberjar", path)).toBe(false);
});
it.each([
"enterprise/frontend/src/embedding-sdk-package/index.ts",
"docs/embedding/sdk/introduction.md",
".github/workflows/embedding-sdk.yml",
])("builds the SDK package for %s", (path) => {
expect(matches("build_embedding_sdk_package", path)).toBe(true);
});
it("does not build the SDK package for unrelated documentation", () => {
expect(
matches("build_embedding_sdk_package", "docs/questions/introduction.md"),
).toBe(false);
});
it("runs the ratchet check on the ratchets file", () => {
expect(matches("project_ratchet_checks", ".clj-kondo/ratchets.edn")).toBe(
true,

View File

@@ -2,14 +2,6 @@ name: App DB Tests
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
force-run:
description: Run the full suite regardless of the diff (master / release branches).
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-app-db
@@ -21,10 +13,19 @@ concurrency:
env:
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
# so a dispatch is a profiling run.
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: backend_all
app-db-matrix:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
name: Determine app DB versions
@@ -38,7 +39,7 @@ jobs:
id: versions
uses: actions/github-script@v9
env:
RUN_ALL: ${{ inputs.force-run }}
RUN_ALL: ${{ needs.gate.outputs.force-run }}
with:
script: | # js
const runAll = process.env.RUN_ALL === 'true';
@@ -130,7 +131,6 @@ jobs:
be-tests-mariadb:
needs: app-db-matrix
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -191,7 +191,6 @@ jobs:
be-tests-mysql:
needs: app-db-matrix
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -250,7 +249,6 @@ jobs:
be-tests-postgres:
needs: app-db-matrix
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -292,6 +290,7 @@ jobs:
app-db-tests-result:
needs:
- gate
- app-db-matrix
- be-tests-mariadb
- be-tests-mysql
@@ -299,7 +298,7 @@ jobs:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
name: app-db-tests-result
if: always() && !cancelled()
if: ${{ !cancelled() }}
steps:
- name: Check job status
uses: actions/github-script@v9

View File

@@ -2,26 +2,6 @@ name: Backend
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
force-run:
description: Run the full suite regardless of the diff (master / release branches).
type: boolean
default: false
run-project-backend-checks:
description: Run project-level backend checks.
type: boolean
default: false
run-project-migration-checks:
description: Run project-level migration checks.
type: boolean
default: false
run-project-ratchet-checks:
description: Run the ratchet check for a diff that touches nothing but the ratchets file.
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-backend
@@ -35,13 +15,47 @@ env:
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: backend_all project_backend_checks project_migration_checks project_ratchet_checks
# Generated static-viz output can shift without any backend source changing, so the suite follows
# it too. Only worth probing when nothing else has already decided the suite.
static-viz-files-changed:
name: Check whether static-viz files changed
needs: gate
if: >-
${{ needs.gate.outputs.verdict == 'defer'
&& !contains(fromJSON(needs.gate.outputs.matched), 'backend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
static_viz: ${{ steps.static_viz.outputs.static_viz }}
steps:
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Build static-viz frontend
run: bun run build-static-viz
env:
MB_EDITION: ee
- name: Check for static viz changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: static_viz
with:
token: ${{ github.token }}
filters: .github/static-viz-sources.yaml
project-checks:
name: 'Project tests: Suites'
needs: gate
if: >-
${{ inputs.force-run ||
inputs.run-project-backend-checks ||
inputs.run-project-migration-checks ||
inputs.run-project-ratchet-checks }}
${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks')
|| contains(fromJSON(needs.gate.outputs.matched), 'project_migration_checks')
|| contains(fromJSON(needs.gate.outputs.matched), 'project_ratchet_checks') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
steps:
@@ -53,21 +67,23 @@ jobs:
run: ./bin/mage
- parallel:
- name: Run backend checks
if: ${{ inputs.force-run || inputs.run-project-backend-checks }}
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks') }}
run: ./bin/mage project-tests backend
- name: Run migration checks
if: ${{ inputs.force-run || inputs.run-project-migration-checks }}
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'project_migration_checks') }}
run: ./bin/mage project-tests migrations
# Babashka plus a scoped clj-kondo run, not a JVM test run, so it is cheap enough to run alongside the
# backend checks. It needs the JDK and Clojure CLI prepare-backend installs.
- name: Run ratchet checks
if: ${{ inputs.force-run || inputs.run-project-backend-checks || inputs.run-project-ratchet-checks }}
if: >-
${{ contains(fromJSON(needs.gate.outputs.matched), 'project_backend_checks')
|| contains(fromJSON(needs.gate.outputs.matched), 'project_ratchet_checks') }}
run: ./bin/mage kondo-ratchets
project-tests:
name: Project tests
needs: project-checks
if: ${{ always() && !cancelled() }}
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
steps:
@@ -125,7 +141,14 @@ jobs:
process.exit(1);
be-linter-clj-kondo:
if: ${{ !inputs.skip }}
needs: &backend-gate-needs [gate, static-viz-files-changed]
# `!cancelled()` so a skipped static-viz probe -- the suite was already decided -- does not skip
# this job; its failure still does.
if: &backend-gate >-
${{ !cancelled() && needs.gate.result == 'success'
&& needs.static-viz-files-changed.result != 'failure'
&& (contains(fromJSON(needs.gate.outputs.matched), 'backend_all')
|| needs.static-viz-files-changed.outputs.static_viz == 'true') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
name: Clj-Kondo
@@ -137,9 +160,10 @@ jobs:
run: ./bin/mage kondo
be-linter-eastwood:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
timeout-minutes: 30
name: Eastwood
steps:
- uses: actions/checkout@v7
@@ -149,10 +173,14 @@ jobs:
name: Run Eastwood linter
be-linter-eastwood-test:
# Master / release safety net: lint every backend test namespace.
if: ${{ !inputs.skip && inputs.force-run }}
# Master / release safety net: lint every backend test namespace. A force-run never probes
# static-viz, so the gate alone decides this one.
needs: gate
if: >-
${{ needs.gate.outputs.force-run == 'true'
&& contains(fromJSON(needs.gate.outputs.matched), 'backend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 45
timeout-minutes: 50
name: Eastwood (tests only)
steps:
- uses: actions/checkout@v7
@@ -163,7 +191,13 @@ jobs:
be-linter-eastwood-test-changed:
# Lint only the test namespaces touched by the diff.
if: ${{ !inputs.skip && !inputs.force-run && github.event_name == 'pull_request' }}
needs: *backend-gate-needs
if: >-
${{ !cancelled() && needs.gate.result == 'success'
&& needs.static-viz-files-changed.result != 'failure'
&& needs.gate.outputs.force-run != 'true' && github.event_name == 'pull_request'
&& (contains(fromJSON(needs.gate.outputs.matched), 'backend_all')
|| needs.static-viz-files-changed.outputs.static_viz == 'true') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
name: Eastwood (changed tests only)
@@ -228,7 +262,8 @@ jobs:
run: clojure -X:dev:dev/test:ee:ee-dev:drivers:drivers-dev:test:eastwood/test :namespaces "[$NAMESPACES]"
be-tests:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -352,14 +387,14 @@ jobs:
$BASE :exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]' ${{ matrix.job.test-args }}
fi
env:
# Both derived from test.profile-backend.yml, the sole dispatcher; its runs always profile. On a
# normal CI run there are no dispatch inputs, so these render `false` and empty and the
# Both come from test.profile-backend.yml, the only workflow that dispatches this suite, so a
# dispatch is a profiling run. On a normal CI run these render `false` and empty and the
# leg's own selection stands.
#
# ONLY_TESTS replaces the leg's own test selection, so every matrix leg runs the whole
# list: the partition args no longer apply, and legs that hold none of the named tests run
# nothing.
PROFILE_TESTS: ${{ github.event.inputs.suite != '' }}
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
ONLY_TESTS: ${{ github.event.inputs.tests }}
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
@@ -435,7 +470,8 @@ jobs:
# checks that all the namespaces we actually ship can be compiled, without any dependencies that we don't ship (such
# as `:dev` dependencies). See #27009 for more context.
be-check:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
strategy:
@@ -451,7 +487,8 @@ jobs:
run: clojure -M:ee:drivers:load-namespaces
be-cljfmt:
if: ${{ !inputs.skip }}
needs: *backend-gate-needs
if: *backend-gate
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
name: Cljfmt
@@ -464,6 +501,8 @@ jobs:
be-tests-result:
needs:
- gate
- static-viz-files-changed
- project-tests
- be-linter-eastwood
- be-linter-eastwood-test
@@ -472,7 +511,7 @@ jobs:
- be-cljfmt
- be-check
- be-tests
if: always() && !cancelled()
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
env:

View File

@@ -2,18 +2,16 @@ name: Build scripts
on:
workflow_call:
inputs:
skip-build-scripts:
type: boolean
default: false
skip-embedder-plugin:
type: boolean
default: false
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: build_scripts embedder_plugin
test-embedder-plugin:
if: ${{ !inputs.skip-embedder-plugin }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedder_plugin') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
permissions:
@@ -63,7 +61,8 @@ jobs:
'[metabase-enterprise.semantic-search.appdb-pgvector-mode-test/appdb-pgvector-in-process-embedder-round-trip-test]'
test-build-scripts:
if: ${{ !inputs.skip-build-scripts }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'build_scripts') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
steps:

View File

@@ -13,8 +13,18 @@ concurrency:
cancel-in-progress: true
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: frontend_sources
bundle-size-check:
name: "Bundle size check"
needs: gate
# There is no base uberjar to compare against outside a pull request.
if: >-
${{ github.event_name == 'pull_request'
&& contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
continue-on-error: true
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20

View File

@@ -86,6 +86,16 @@ jobs:
clojure -M:ee:doc ai-providers-documentation
continue-on-error: false
- name: Generate MCP tool documentation
id: generate_mcp_tool_docs
# Same reason as the AI provider step: release branches without the generator would fail the command.
if: hashFiles('src/metabase/cmd/mcp_tools_dox.clj') != ''
run: |
set -e # Exit on any error
echo "Generating MCP tool documentation..."
clojure -M:ee:doc mcp-tools-documentation
continue-on-error: false
- name: Generate usage analytics documentation
run: ./bin/generate-usage-analytics-docs.bb
@@ -176,6 +186,7 @@ jobs:
- \`clojure -M:ee:doc api-documentation\`
- \`clojure -M:ee:doc command-documentation\`
- \`clojure -M:ee:doc ai-providers-documentation\`
- \`clojure -M:ee:doc mcp-tools-documentation\`
- \`./bin/generate-usage-analytics-docs.bb\`
- \`bun run embedding-sdk:docs:generate\`
- \`bun run embedding-eajs:docs:generate\`

View File

@@ -2,14 +2,6 @@ name: Driver Tests
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
force-run:
description: Run the full suite regardless of the diff (master / release branches, ci:run-all label).
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-drivers
@@ -21,11 +13,21 @@ concurrency:
env:
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
# so a dispatch is a profiling run.
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: backend_all
# This job determines which driver tests should run based on PR context.
# All decision logic is consolidated in mage driver-decisions.
determine-driver-skips:
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
@@ -69,8 +71,6 @@ jobs:
--git-ref="${BASE_REF}"
--force-run="${FORCE_RUN}"
--pr-labels="${{ join(github.event.pull_request.labels.*.name, ',') }}"
--skip="${{ inputs.skip }}"
--only-driver="${PROFILE_DRIVER}"
)
echo "Running:"
echo "./bin/mage -driver-decisions ${MAGE_ARGS[*]}"
@@ -84,10 +84,7 @@ jobs:
./bin/mage -driver-decisions "${MAGE_ARGS[@]}" --github-output-only >> "$GITHUB_OUTPUT"
env:
BASE_REF: ${{ github.event.pull_request.base.ref || github.ref_name }}
FORCE_RUN: ${{ inputs.force-run }}
# Set only by test.profile-backend.yml. Absent on a normal CI run, which leaves the decision rules
# alone; mage rejects a name that is not a driver.
PROFILE_DRIVER: ${{ github.event.inputs.db }}
FORCE_RUN: ${{ needs.gate.outputs.force-run == 'true' }}
shell: bash
be-tests-athena-ee:
@@ -1247,6 +1244,7 @@ jobs:
drivers-tests-result:
needs:
- gate
- determine-driver-skips
# if you add a driver job, you must add it to this list for it to be a required check
- be-tests-athena-ee
@@ -1274,7 +1272,7 @@ jobs:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
name: drivers-tests-result
if: always() && !cancelled()
if: ${{ !cancelled() }}
steps:
- name: Check job status
uses: actions/github-script@v9
@@ -1284,8 +1282,8 @@ jobs:
script: | # js
const needs = JSON.parse(process.env.needs);
// Drop the determine-driver-skips bookkeeping job; every remaining job is a driver
// job whose result gates this check.
// Drop the determine-driver-skips bookkeeping job; the rest -- the gate and the
// driver jobs -- gate this check.
const jobs = Object.entries(needs)
.filter(([jobName]) => jobName !== 'determine-driver-skips')
.map(([jobName, jobValues]) => ({

View File

@@ -3,9 +3,6 @@ name: E2E Tests
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
test-plan-artifact-id:
description: "ID of this run's test-plan.json artifact, '' to run the full suite."
type: string
@@ -17,8 +14,14 @@ concurrency:
cancel-in-progress: true
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: e2e_all
e2e-matrix-builder:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/e2e-matrix-builder.yml
with:
chunks: 50
@@ -60,10 +63,9 @@ jobs:
uses: ./.github/workflows/e2e-test.yml
needs:
- e2e-matrix-builder
if: |
!cancelled() && !inputs.skip &&
needs.e2e-matrix-builder.result == 'success' &&
needs.e2e-matrix-builder.outputs.hasTests == 'true'
# No status function, so a skipped or failed matrix builder keeps this out: the strategy below
# cannot parse a matrix it never produced. The builder itself only runs when the gate says so.
if: ${{ needs.e2e-matrix-builder.outputs.hasTests == 'true' }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.e2e-matrix-builder.outputs.matrix) }}
@@ -83,7 +85,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
needs: e2e-tests
if: always() && !cancelled() && github.ref == 'refs/heads/master'
if: ${{ !cancelled() && github.ref == 'refs/heads/master' }}
steps:
- uses: actions/checkout@v7
with:
@@ -142,21 +144,26 @@ jobs:
e2e-tests-result:
needs:
- gate
- e2e-matrix-builder
- e2e-tests
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
name: e2e-tests-result
if: always() && !cancelled()
if: ${{ !cancelled() }}
env:
E2E_TESTS_RESULT: ${{ needs.e2e-tests.result }}
MATRIX_RESULT: ${{ needs.e2e-matrix-builder.result }}
HAS_TESTS: ${{ needs.e2e-matrix-builder.outputs.hasTests }}
SKIP: ${{ inputs.skip }}
GATE_RESULT: ${{ needs.gate.result }}
RUN: ${{ needs.gate.outputs.run }}
steps:
- name: Test results
run: | # sh
if [ "$SKIP" = "true" ]; then
if [ "$GATE_RESULT" != "success" ]; then
echo "The test gate failed, so the suite never ran"
exit 1
elif [ "$RUN" != "true" ]; then
echo "Didn't run due to conditional filtering"
elif [ "$MATRIX_RESULT" != "success" ]; then
echo "E2E matrix builder failed"

View File

@@ -2,39 +2,23 @@ name: SDK
on:
workflow_call:
inputs:
cli-snippets-type-check:
description: "Whether to run cli snippets type check."
required: true
type: boolean
docs-snippets-type-check:
description: "Whether to run docs snippets type check."
required: true
type: boolean
documentation-validation:
description: "Whether to run documentation generation validation check."
required: true
type: boolean
component-tests:
description: "Whether to run component tests."
required: true
type: boolean
host-sample-apps-tests:
description: "Whether to run host and sample apps compatibility tests."
required: true
type: boolean
skip:
description: Whether to skip running tests due to one of the artifacts failing to build.
required: true
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
# Each job below maps to its own filter, so they read `matched` rather than `run`.
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: >-
frontend_sources embedding_documentation embedding_sdk_components
embedding_sdk_host_sample_apps
embedding-sdk-cli-snippets-type-check:
if: inputs.cli-snippets-type-check && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
@@ -50,7 +34,8 @@ jobs:
run: bun run embedding-sdk:cli-snippets:type-check
embedding-sdk-docs-snippets-type-check:
if: inputs.docs-snippets-type-check && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_documentation') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
@@ -66,7 +51,8 @@ jobs:
run: bun run embedding-sdk:docs-snippets:type-check
embedding-sdk-api-documentation-generation-validation:
if: inputs.documentation-validation && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_sources') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
@@ -116,23 +102,26 @@ jobs:
# See `Component e2e tests` in `enterprise/frontend/src/embedding-sdk-package/dev.md`
embedding-sdk-component-tests:
name: "SDK component tests"
if: inputs.component-tests && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_sdk_components') }}
uses: ./.github/workflows/embedding-sdk-component-tests.yml
secrets: inherit
not-required-embedding-sdk-apps-compatibility-tests:
name: "SDK e2e tests for compatibility with (host/sample) apps"
if: inputs.host-sample-apps-tests && !inputs.skip
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'embedding_sdk_host_sample_apps') }}
uses: ./.github/workflows/embedding-sdk-host-sample-apps-tests.yml
secrets: inherit
embedding-sdk-tests-result:
needs:
- gate
- embedding-sdk-cli-snippets-type-check
- embedding-sdk-docs-snippets-type-check
- embedding-sdk-api-documentation-generation-validation
- embedding-sdk-component-tests
if: always() && !cancelled()
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
env:

View File

@@ -3,36 +3,10 @@ name: Frontend
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
skip-lint:
type: boolean
default: false
skip-custom-viz:
type: boolean
default: false
# ID of this run's test-plan.json artifact, or '' to run the full suite.
test-plan-artifact-id:
type: string
default: ""
skip-ci-scripts:
type: boolean
default: false
workflow_dispatch:
inputs:
skip:
description: "Skip main frontend jobs (type-check, unit tests, timezone tests)"
type: boolean
default: false
skip-lint:
description: "Skip linting jobs (oxfmt, oxlint, css variables check)"
type: boolean
default: false
skip-custom-viz:
description: "Skip custom-viz package tests"
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-frontend
@@ -46,32 +20,39 @@ env:
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
jobs:
# Each job below maps to its own filter, so they read `matched` rather than `run`.
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: frontend_all e2e_all custom_viz_all ci_scripts
fe-lint:
if: ${{ !inputs.skip-lint }}
needs: gate
# Linting covers the e2e specs too.
if: >-
${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all')
|| contains(fromJSON(needs.gate.outputs.matched), 'e2e_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Compile CLJS
run: bun run build-pure:cljs
- name: Find CSS variables that are used but never defined
run: bun run find-never-defined-css-variables
- name: Run oxfmt formatting linter
run: bun run lint-format-pure
- name: Run oxlint linter (including module boundaries)
run: bun run lint-oxlint-pure
run: bun run lint-oxlint
- name: Verify lint configuration and behavior
run: bun run test-oxlint
- name: Run CSS linter
run: bun run lint-css
fe-type-check:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
@@ -86,7 +67,8 @@ jobs:
name: Check types
fe-tests-unit:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
strategy:
@@ -150,7 +132,8 @@ jobs:
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
fe-tests-timezones:
if: ${{ !inputs.skip }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'frontend_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 20
steps:
@@ -162,7 +145,8 @@ jobs:
# No CLJS compile: the CI-script specs don't import app code.
fe-tests-ci-scripts:
if: ${{ !inputs.skip-ci-scripts }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'ci_scripts') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
@@ -175,7 +159,8 @@ jobs:
# The custom-viz package is an isolated Bun workspace under
# enterprise/frontend/src/custom-viz
fe-custom-viz:
if: ${{ !inputs.skip-custom-viz }}
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'custom_viz_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
defaults:
@@ -198,13 +183,14 @@ jobs:
fe-tests-result:
needs:
- gate
- fe-lint
- fe-type-check
- fe-tests-unit
- fe-tests-timezones
- fe-tests-ci-scripts
- fe-custom-viz
if: always() && !cancelled()
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
env:

View File

@@ -3,10 +3,6 @@ name: Loki Visual Regression Testing
on:
workflow_call:
inputs:
skip:
description: "Skip the Loki visual tests."
type: boolean
default: false
test-plan-artifact-id:
description: "ID of this run's test-plan.json artifact, or '' to run the full suite."
type: string
@@ -17,8 +13,15 @@ concurrency:
cancel-in-progress: true
jobs:
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: frontend_loki_all
# Without a plan there is nothing to narrow, so this is skipped and every story runs.
story-selection:
if: ${{ !inputs.skip && inputs.test-plan-artifact-id != '' }}
needs: gate
if: ${{ needs.gate.outputs.run == 'true' && inputs.test-plan-artifact-id != '' }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
outputs:
@@ -46,10 +49,14 @@ jobs:
visual-test:
name: loki-run
needs: story-selection
# A missing job output compares equal to '0' in GitHub expressions,
# so an empty selection is marked with a non-numeric value.
if: ${{ !cancelled() && !inputs.skip && needs.story-selection.outputs.selection != 'empty' }}
needs: [gate, story-selection]
# `!cancelled()` so a skipped selection (no plan) still runs every story. A missing job output
# compares equal to '0' in GitHub expressions, so an empty selection is marked with a
# non-numeric value.
if: >-
${{ !cancelled() && needs.gate.outputs.run == 'true'
&& needs.story-selection.result != 'failure'
&& needs.story-selection.outputs.selection != 'empty' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
services:
@@ -110,8 +117,8 @@ jobs:
loki-tests-result:
name: visual-test
needs: [story-selection, visual-test]
if: always() && !cancelled()
needs: [gate, story-selection, visual-test]
if: ${{ !cancelled() }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
steps:

View File

@@ -13,8 +13,8 @@ on:
default: 7
required: false
schedule:
# Run weekly on Sundays at 6 AM UTC
- cron: "0 6 * * 0"
# Run daily at 6 AM UTC
- cron: "0 6 * * *"
jobs:
collect-and-average-timings:
@@ -22,7 +22,7 @@ jobs:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
env:
DAYS_BACK: ${{ inputs.days_back || 7 }}
DAYS_BACK: ${{ inputs.days_back || 1 }}
steps:
- name: Generate GitHub App Token
uses: actions/create-github-app-token@v1

View File

@@ -7,7 +7,6 @@ on:
- "release-**"
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
id-token: write
@@ -18,153 +17,53 @@ permissions:
security-events: write
jobs:
# `force-run` and `force-skip` are global overrides; `defer` means this
# job has no opinion and every downstream gate behaves as it always has.
#
# Only the lowest unmerged PR of a stack runs CI: it is the one whose base
# ref is the stack base. Every PR above it is force-skipped and gets tested
# once it reaches the bottom of the stack.
should-run:
name: Decide which jobs should run
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
outputs:
verdict: ${{ steps.decide.outputs.verdict }}
steps:
- name: Decide
id: decide
# Precedence matters for the conditions below.
env:
VERDICT: >-
${{ (github.ref_name == 'master' || startsWith(github.ref_name, 'release-x.')) && 'force-run'
|| contains(github.event.pull_request.labels.*.name, 'ci:run-all') && 'force-run'
|| contains(github.event.pull_request.labels.*.name, 'ci:skip') && 'force-skip'
|| (github.event.pull_request.stack != null && github.event.pull_request.stack.base.ref != github.event.pull_request.base.ref) && 'force-skip'
|| 'defer' }}
run: echo "verdict=$VERDICT" | tee -a "$GITHUB_OUTPUT"
files-changed:
needs: should-run
name: Detect changed files
if: ${{ needs.should-run.outputs.verdict == 'defer' }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
outputs:
e2e_all: ${{ steps.changes.outputs.e2e_all }}
e2e_specs: ${{ steps.changes.outputs.e2e_specs }}
e2e_infra: ${{ steps.changes.outputs.e2e_infra }}
backend_all: ${{ steps.changes.outputs.backend_all }}
build_scripts: ${{ steps.changes.outputs.build_scripts }}
embedder_plugin: ${{ steps.changes.outputs.embedder_plugin }}
frontend_all: ${{ steps.changes.outputs.frontend_all }}
frontend_sources: ${{ steps.changes.outputs.frontend_sources }}
frontend_unit_infra: ${{ steps.changes.outputs.frontend_unit_infra }}
frontend_loki_infra: ${{ steps.changes.outputs.frontend_loki_infra }}
frontend_loki_all: ${{ steps.changes.outputs.frontend_loki_all }}
shared_sources: ${{ steps.changes.outputs.shared_sources }}
documentation: ${{ steps.changes.outputs.documentation }}
embedding_documentation: ${{ steps.changes.outputs.embedding_documentation }}
embedding_sdk_components: ${{ steps.changes.outputs.embedding_sdk_components }}
embedding_sdk_host_sample_apps: ${{ steps.changes.outputs.embedding_sdk_host_sample_apps }}
embedding_sdk_ci: ${{ steps.changes.outputs.embedding_sdk_ci }}
custom_viz_all: ${{ steps.changes.outputs.custom_viz_all }}
ci_scripts: ${{ steps.changes.outputs.ci_scripts }}
project_backend_checks: ${{ steps.changes.outputs.project_backend_checks }}
project_migration_checks: ${{ steps.changes.outputs.project_migration_checks }}
project_ratchet_checks: ${{ steps.changes.outputs.project_ratchet_checks }}
# A ratchets-only change (the shrink automation PR) needs the ratchet check, not an uberjar.
no_source_code_changes: >-
${{ (steps.changes.outputs.documentation == 'true'
|| steps.changes.outputs.project_ratchet_checks == 'true')
&& steps.changes.outputs.backend_all == 'false'
&& steps.changes.outputs.frontend_all == 'false'
&& steps.changes.outputs.e2e_all == 'false' }}
all_changed_files: ${{ steps.changes.outputs.all_changed_files }}
e2e_all_files: ${{ steps.changes.outputs.e2e_all_files }}
e2e_specs_files: ${{ steps.changes.outputs.e2e_specs_files }}
frontend_sources_files: ${{ steps.changes.outputs.frontend_sources_files }}
backend_sources_files: ${{ steps.changes.outputs.backend_sources_files }}
steps:
- uses: actions/checkout@v7
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
# comma separated list of files
list-files: csv
filters: .github/file-paths.yaml
# Force-cancel is the only decision left here. The jobs below read `matched`;
# each suite that owns its gating calls the gate itself with the filters it cares about.
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: build_embedding_sdk_package build_uberjar e2e_all
# The plan only ever narrows a PR's suites; every other run skips this and they run in full. A
# planner failure does the same, so it must not fail the run either.
create-test-plan:
needs: [should-run, files-changed]
needs: gate
name: Create test plan
if: ${{ github.event_name == 'pull_request' && needs.should-run.outputs.verdict == 'defer' }}
if: ${{ github.event_name == 'pull_request' && needs.gate.outputs.verdict == 'defer' }}
continue-on-error: true
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
stats: ${{ steps.compute.outputs.stats }}
artifact-id: ${{ steps.compute.outputs.artifact-id }}
# The artifact is uploaded before the planner's last steps, so a later failure would otherwise
# hand the suites a plan from a run that errored. Anything short of a clean run means no plan.
artifact-id: ${{ steps.compute.outcome == 'success' && steps.compute.outputs.artifact-id || '' }}
steps:
- uses: actions/checkout@v7
- name: Create test plan
id: compute
uses: ./.github/actions/create-test-plan
with:
changed-files: ${{ needs.files-changed.outputs.all_changed_files }}
fe-changed-files: ${{ needs.files-changed.outputs.frontend_sources_files }}
be-changed-files: ${{ needs.files-changed.outputs.backend_sources_files }}
unit-infra-touched: ${{ needs.files-changed.outputs.frontend_unit_infra }}
loki-infra-touched: ${{ needs.files-changed.outputs.frontend_loki_infra }}
e2e-infra-touched: ${{ needs.files-changed.outputs.e2e_infra }}
shared-sources-touched: ${{ needs.files-changed.outputs.shared_sources }}
upload-affected-tests-stats:
needs: create-test-plan
# Only land rows for PRs from the upstream repo so forks don't pollute the table.
if: |
!cancelled() &&
needs.create-test-plan.result == 'success' &&
github.event.pull_request.head.repo.full_name == 'metabase/metabase'
# Stats upload is best-effort — a failure here shouldn't fail the PR run.
continue-on-error: true
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 3
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/prepare-node-bun
- name: Upload affected tests stats
env:
STATS_JSON: ${{ needs.create-test-plan.outputs.stats }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
ENG_STATS_URL: ${{ secrets.ENG_STATS_URL }}
API_KEY: ${{ secrets.ENG_STATS_API_KEY }}
run: bun .github/scripts/upload-affected-tests-stats.ts
stats-url: ${{ secrets.ENG_STATS_URL }}
stats-api-key: ${{ secrets.ENG_STATS_API_KEY }}
uberjar:
needs: [should-run, files-changed]
if: |
!cancelled() && needs.should-run.outputs.verdict != 'force-skip' &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.outputs.no_source_code_changes != 'true')
needs: gate
if: >-
${{ needs.gate.outputs.verdict != 'force-skip'
&& contains(fromJSON(needs.gate.outputs.matched), 'build_uberjar') }}
uses: ./.github/workflows/uberjar.yml
secrets: inherit
containerize:
needs: [uberjar]
needs: uberjar
if: >-
!cancelled() &&
needs.uberjar.result == 'success' &&
(
(github.event_name == 'push' && github.ref_name == 'master') ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'build-docker-uberjar'))
)
${{ (github.event_name == 'push' && github.ref_name == 'master')
|| (github.event_name == 'pull_request'
&& contains(github.event.pull_request.labels.*.name, 'build-docker-uberjar')) }}
name: containerize (${{ matrix.edition }})
strategy:
matrix:
edition: ['ee', 'oss']
edition: ["ee", "oss"]
uses: ./.github/workflows/containerize-jar.yml
secrets: inherit
with:
@@ -174,16 +73,17 @@ jobs:
edition: ${{ matrix.edition }}
release: false
bundle-size:
needs: uberjar
uses: ./.github/workflows/bundle-size.yml
secrets: inherit
# Compile CLJS in parallel with the uberjar and pass it to each e2e group.
# The compile is doesn't depend on MB_EDITION
e2e-cljs:
name: Compile CLJS for e2e
needs: [should-run, files-changed]
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
(needs.files-changed.result == 'success' &&
needs.files-changed.outputs.e2e_all == 'true'))
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'e2e_all') }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
steps:
@@ -204,223 +104,100 @@ jobs:
# replace it rather than fail on a re-run.
overwrite: true
static-viz-files-changed:
name: Check whether static-viz files changed
needs: should-run
# Its only consumer is the backend skip gate, which a force-run overrides.
if: ${{ needs.should-run.outputs.verdict == 'defer' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
static_viz: ${{ steps.static_viz.outputs.static_viz }}
steps:
- uses: actions/checkout@v7
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Build static-viz frontend
run: bun run build-static-viz
env:
MB_EDITION: ee
- name: Check for static viz changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: static_viz
with:
token: ${{ github.token }}
filters: .github/static-viz-sources.yaml
backend-tests:
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.result == 'success')
needs: [should-run, files-changed, static-viz-files-changed]
needs: gate
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
uses: ./.github/workflows/backend.yml
secrets: inherit
with:
skip: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.backend_all != 'true' &&
needs.static-viz-files-changed.outputs.static_viz != 'true' }}
force-run: ${{ needs.should-run.outputs.verdict == 'force-run' }}
run-project-backend-checks: ${{ needs.files-changed.outputs.project_backend_checks == 'true' }}
run-project-migration-checks: ${{ needs.files-changed.outputs.project_migration_checks == 'true' }}
run-project-ratchet-checks: ${{ needs.files-changed.outputs.project_ratchet_checks == 'true' }}
# These suites gate themselves; the only thing decided here is whether to call them at all.
semantic-search-tests:
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.result == 'success')
needs: [should-run, files-changed]
needs: gate
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
uses: ./.github/workflows/semantic-search.yml
secrets: inherit
with:
skip: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.backend_all != 'true' }}
smoke-only: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
!contains(github.event.pull_request.labels.*.name, 'ci:run-semantic-search-tests')
}}
app-db-tests:
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.result == 'success')
needs: [should-run, files-changed]
needs: gate
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
uses: ./.github/workflows/app-db.yml
secrets: inherit
with:
skip: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.backend_all != 'true' }}
force-run: ${{ needs.should-run.outputs.verdict == 'force-run' }}
driver-tests:
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.result == 'success')
needs: [should-run, files-changed]
needs: gate
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
uses: ./.github/workflows/drivers.yml
secrets: inherit
with:
skip: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.backend_all != 'true' }}
force-run: ${{ needs.should-run.outputs.verdict == 'force-run' }}
frontend-tests:
needs: [should-run, files-changed, create-test-plan]
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.result == 'success')
needs: [gate, create-test-plan]
# `!cancelled()` so a skipped planner (no plan) still runs the suite.
if: >-
${{ !cancelled() && needs.gate.result == 'success'
&& needs.gate.outputs.verdict != 'force-skip' }}
uses: ./.github/workflows/frontend.yml
secrets: inherit
with:
skip: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.frontend_all != 'true' }}
# A skipped/failed planner supplies no artifact, so the suite runs in full.
test-plan-artifact-id: ${{ needs.create-test-plan.result == 'success' && needs.create-test-plan.outputs.artifact-id || '' }}
skip-lint: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.frontend_all != 'true' &&
needs.files-changed.outputs.e2e_all != 'true' }}
skip-custom-viz: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.custom_viz_all != 'true' }}
skip-ci-scripts: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.ci_scripts != 'true' }}
# No artifact means the suite runs in full.
test-plan-artifact-id: ${{ needs.create-test-plan.outputs.artifact-id }}
loki-tests:
needs: [should-run, files-changed, create-test-plan]
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.result == 'success')
needs: [gate, create-test-plan]
# `!cancelled()` so a skipped planner (no plan) still runs the suite.
if: >-
${{ !cancelled() && needs.gate.result == 'success'
&& needs.gate.outputs.verdict != 'force-skip' }}
uses: ./.github/workflows/loki.yml
with:
skip: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.frontend_loki_all != 'true' }}
# A skipped/failed planner supplies no artifact, so the suite runs in full.
test-plan-artifact-id: ${{ needs.create-test-plan.result == 'success' && needs.create-test-plan.outputs.artifact-id || '' }}
# No artifact means the suite runs in full.
test-plan-artifact-id: ${{ needs.create-test-plan.outputs.artifact-id }}
e2e-tests:
needs: [should-run, files-changed, uberjar, create-test-plan]
needs: [gate, uberjar, create-test-plan]
# Skip e2e tests when the uberjar build failed or was cancelled — the
# downstream tests can't fetch a missing artifact, and we don't want to
# mask the uberjar failure with a wave of misleading e2e errors. We still
# enter the workflow when uberjar is `skipped` (docs-only PRs), because
# the inner `skip` input handles that path and reports the required
# the suite's own gate handles that path and reports the required
# `e2e-tests-result` check as success. When the uberjar genuinely failed,
# this whole job is skipped, the inner required checks never report, and
# branch protection blocks the merge as pending.
if: |
always() && !cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' || needs.files-changed.result == 'success') &&
!cancelled() &&
needs.gate.result == 'success' &&
needs.gate.outputs.verdict != 'force-skip' &&
needs.uberjar.result != 'failure' &&
needs.uberjar.result != 'cancelled'
uses: ./.github/workflows/e2e-tests.yml
secrets: inherit
with:
skip: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.e2e_all != 'true' }}
# Only narrow specs on pull requests that haven't been forced to run everything.
# Push and merge-queue runs always use the full suite.
test-plan-artifact-id: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
github.event_name == 'pull_request' &&
needs.create-test-plan.result == 'success' &&
needs.create-test-plan.outputs.artifact-id || '' }}
# No artifact means the suite runs in full.
test-plan-artifact-id: ${{ needs.create-test-plan.outputs.artifact-id }}
embedding-sdk-package:
needs: [should-run, files-changed]
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.outputs.embedding_sdk_components == 'true' ||
needs.files-changed.outputs.frontend_sources == 'true' ||
needs.files-changed.outputs.embedding_documentation == 'true' ||
needs.files-changed.outputs.embedding_sdk_ci == 'true' ||
needs.files-changed.outputs.embedding_sdk_host_sample_apps == 'true')
needs: gate
if: ${{ contains(fromJSON(needs.gate.outputs.matched), 'build_embedding_sdk_package') }}
uses: ./.github/workflows/embedding-sdk-package-build.yml
secrets: inherit
sdk-tests:
needs: [should-run, uberjar, embedding-sdk-package, files-changed]
if: always() && !cancelled()
needs: [gate, uberjar, embedding-sdk-package]
# Same rule as e2e-tests: a `skipped` artifact build still enters the suite, whose own gate then
# skips every job and reports the required result check. A genuine failure keeps this job out, so
# the check stays pending instead of being buried under missing-artifact errors. A failed gate
# skips both builds for a reason the suite's own gate cannot see, so it has to keep this out too.
if: |
!cancelled() &&
needs.gate.result == 'success' &&
needs.uberjar.result != 'failure' &&
needs.uberjar.result != 'cancelled' &&
needs.embedding-sdk-package.result != 'failure' &&
needs.embedding-sdk-package.result != 'cancelled'
uses: ./.github/workflows/embedding-sdk.yml
secrets: inherit
with:
cli-snippets-type-check: >-
${{ needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.outputs.frontend_sources == 'true' }}
docs-snippets-type-check: >-
${{ needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.outputs.embedding_documentation == 'true' }}
documentation-validation: >-
${{ needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.outputs.frontend_sources == 'true' }}
component-tests: >-
${{ needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.outputs.embedding_sdk_components == 'true' }}
host-sample-apps-tests: >-
${{ needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.outputs.embedding_sdk_host_sample_apps == 'true' }}
skip: >-
${{ needs.embedding-sdk-package.result != 'success' ||
needs.uberjar.result != 'success' }}
build-scripts-tests:
needs: [should-run, files-changed]
if: |
!cancelled() &&
(needs.should-run.outputs.verdict == 'force-run' ||
needs.files-changed.result == 'success')
needs: gate
if: ${{ needs.gate.outputs.verdict != 'force-skip' }}
uses: ./.github/workflows/build-scripts.yml
secrets: inherit
with:
skip-build-scripts: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.build_scripts != 'true' }}
skip-embedder-plugin: >-
${{ needs.should-run.outputs.verdict != 'force-run' &&
needs.files-changed.outputs.embedder_plugin != 'true' }}
bundle-size:
needs: [uberjar, files-changed]
if: |
!cancelled() &&
github.event_name == 'pull_request' &&
needs.uberjar.result == 'success' &&
needs.files-changed.outputs.frontend_sources == 'true'
uses: ./.github/workflows/bundle-size.yml
secrets: inherit

View File

@@ -2,14 +2,6 @@ name: Semantic Search Tests
on:
workflow_call:
inputs:
skip:
type: boolean
default: false
smoke-only:
type: boolean
default: false
description: Only run Postgres Latest to do a quick resource-efficient sanity check, intended to be set for PRs.
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}-semantic-search
@@ -21,10 +13,21 @@ concurrency:
env:
CI_CONDUCTOR_BASE_URL: ${{ vars.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
# Read by the test-driver action. This suite is only ever dispatched by test.profile-backend.yml,
# so a dispatch is a profiling run.
PROFILE_TESTS: ${{ github.event_name == 'workflow_dispatch' }}
jobs:
# A CI run is a smoke run -- Postgres Latest alone, to keep PRs cheap -- unless the whole suite is
# forced to run (master, release branches, `ci:run-all`).
gate:
uses: ./.github/workflows/test.gate.yml
with:
filters: backend_all
semantic-search-tests-h2:
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'h2') }}
needs: gate
if: ${{ needs.gate.outputs.force-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -68,7 +71,8 @@ jobs:
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
semantic-search-tests-mariadb:
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mariadb') }}
needs: gate
if: ${{ needs.gate.outputs.force-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -133,7 +137,8 @@ jobs:
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
semantic-search-tests-mysql:
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'mysql') }}
needs: gate
if: ${{ needs.gate.outputs.force-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -205,7 +210,8 @@ jobs:
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
semantic-search-tests-postgres:
if: ${{ !inputs.skip && (github.event.inputs.db == '' || github.event.inputs.db == 'postgres') }}
needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -221,7 +227,7 @@ jobs:
postgres-image: postgres:latest
pgvector-image: pgvector/pgvector:pg17
exclude: >-
${{ inputs.smoke-only
${{ needs.gate.outputs.force-run != 'true'
&& fromJSON('[{"version":{"name":"Postgres 14.x with pgvector pg17"}}]')
|| fromJSON('[]') }}
job:
@@ -276,7 +282,8 @@ jobs:
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
# appdb-pgvector-mode round trip (and the URL-independent unit tests) run for real.
semantic-search-tests-appdb-mode:
if: ${{ !inputs.skip && !inputs.smoke-only && (github.event.inputs.db == '' || github.event.inputs.db == 'appdb-mode') }}
needs: gate
if: ${{ needs.gate.outputs.force-run == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
@@ -328,6 +335,7 @@ jobs:
semantic-search-tests-result:
needs:
- gate
- semantic-search-tests-h2
- semantic-search-tests-mariadb
- semantic-search-tests-mysql
@@ -336,7 +344,7 @@ jobs:
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
name: semantic-search-tests-result
if: always() && !cancelled()
if: ${{ !cancelled() }}
steps:
- name: Check job status
uses: actions/github-script@v9

119
.github/workflows/test.gate.yml vendored Normal file
View File

@@ -0,0 +1,119 @@
name: Test gate
# Decides whether a test suite should run, so each suite owns its own gating
# instead of the caller computing a `skip` input for it.
#
# `force-run` and `force-skip` are global overrides; `defer` hands the decision
# to the paths filter, which is the only path that pays for a checkout.
#
# Only the lowest unmerged PR of a stack runs CI: it is the one whose base ref
# is the stack base. Every PR above it is force-skipped and gets tested once it
# reaches the bottom of the stack.
on:
workflow_call:
inputs:
filters:
description: >-
Filter names from .github/file-paths.yaml, comma- or
whitespace-separated. The suite runs when the diff matches any of them.
Leave empty to decide on the overrides alone and skip the paths filter.
type: string
default: ''
outputs:
run:
description: Whether the calling suite should run.
value: >-
${{ jobs.should-run.outputs.verdict != 'force-skip'
&& (inputs.filters == ''
|| jobs.should-run.outputs.verdict == 'force-run'
|| jobs.files-changed.outputs.any == 'true') }}
matched:
description: >-
Which of the requested filters the diff touched, as a JSON array, for suites whose jobs
map to different filters. A force-run reports every requested filter.
value: >-
${{ (jobs.should-run.outputs.verdict == 'force-run' && jobs.should-run.outputs.filters)
|| jobs.files-changed.outputs.matched || '[]' }}
force-run:
description: Whether the suite should run in full, ignoring the diff.
value: ${{ jobs.should-run.outputs.verdict == 'force-run' }}
verdict:
description: force-run, force-skip, or defer.
value: ${{ jobs.should-run.outputs.verdict }}
jobs:
should-run:
name: Decide whether to run
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
outputs:
verdict: ${{ steps.decide.outputs.verdict }}
filters: ${{ steps.decide.outputs.filters }}
steps:
- name: Decide
id: decide
# Precedence matters for the conditions below.
env:
FILTERS: ${{ inputs.filters }}
VERDICT: >-
${{ (github.ref_name == 'master' || startsWith(github.ref_name, 'release-x.')) && 'force-run'
|| contains(github.event.pull_request.labels.*.name, 'ci:run-all') && 'force-run'
|| contains(github.event.pull_request.labels.*.name, 'ci:skip') && 'force-skip'
|| (github.event.pull_request.stack != null && github.event.pull_request.stack.base.ref != github.event.pull_request.base.ref) && 'force-skip'
|| 'defer' }}
run: |
echo "verdict=$VERDICT" | tee -a "$GITHUB_OUTPUT"
# Echoed back as JSON so a force-run can report every requested filter as matched.
echo "filters=$(jq -Rc 'split("[,\\s]+";"") | map(select(length > 0))' <<< "$FILTERS")" | tee -a "$GITHUB_OUTPUT"
files-changed:
name: Detect changed files
needs: should-run
if: ${{ needs.should-run.outputs.verdict == 'defer' && inputs.filters != '' }}
runs-on: ${{ vars.SLIM_RUNNER_KEY }}
timeout-minutes: 5
outputs:
matched: ${{ steps.match.outputs.matched }}
any: ${{ steps.match.outputs.any }}
steps:
# The filter definitions are the only thing on disk that paths-filter reads.
- uses: actions/checkout@v7
with:
sparse-checkout: .github
# No file lists: one filter's list can outgrow the 128 KiB a single environment variable may
# hold on Linux, and every caller after the gate pays for it. A job that needs the paths runs
# its own paths filter.
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
- name: Match the diff against the requested filters
id: match
uses: actions/github-script@v9
env:
REQUESTED: ${{ inputs.filters }}
# The names of every filter the diff matched, as a JSON array.
CHANGED: ${{ steps.changes.outputs.changes }}
with:
script: | # js
const fs = require('fs');
const requested = process.env.REQUESTED.split(/[\s,]+/).filter(Boolean);
const changed = JSON.parse(process.env.CHANGED);
// A filter name that no longer exists would silently gate the suite off forever.
const defined = [...fs.readFileSync('.github/file-paths.yaml', 'utf8')
.matchAll(/^(\w+):/gm)].map(([, name]) => name);
const unknown = requested.filter((filter) => !defined.includes(filter));
if (unknown.length) {
core.setFailed(`Unknown filter(s) in .github/file-paths.yaml: ${unknown.join(', ')}`);
return;
}
const matched = requested.filter((filter) => changed.includes(filter));
core.info(`requested: ${requested.join(', ')}`);
core.info(`matched: ${matched.join(', ') || 'none'}`);
core.setOutput('matched', JSON.stringify(matched));
core.setOutput('any', matched.length > 0);

View File

@@ -1,23 +1,22 @@
name: Profile Backend
# The only on-demand entry point for the backend test suites. Dispatching this workflow runs one
# job of one suite against the ref you dispatch on, under clj-async-profiler, and attaches a
# flamegraph to the job.
# suite against the ref you dispatch on, under clj-async-profiler, and attaches a flamegraph to
# each job.
#
# The suite workflows below are called unchanged: services, matrix legs, test args and quarantine
# gating are whatever the normal CI run uses, because they are the same jobs. They declare nothing
# for this feature -- they read `suite`, `db` and `tests` off `github.event.inputs`, which inside a
# called workflow is this dispatch's payload.
# for this feature beyond treating a `workflow_dispatch` as a profiling run, and reading `tests` off
# `github.event.inputs`, which inside a called workflow is this dispatch's payload.
#
# `suite` picks the workflow, `db` picks the leg within it. `db` is free text so that adding a
# driver or an app-DB leg needs no edit here; an unknown driver fails the drivers run through
# mage's `--only-driver`, and an unknown app-DB or semantic-search leg matches nothing and runs no
# jobs.
# The suite is gated like any other run: dispatched on master or a release branch it runs in full;
# anywhere else it runs only if the ref's diff from master touches the backend, and semantic-search
# runs its Postgres-latest smoke leg alone.
#
# Renaming an input here means updating every reader -- `grep -rn github.event.inputs .github/`.
# Nothing enforces that, deliberately: a stale name reads as empty, which runs every leg of the
# suite unprofiled, on the run you are watching. It cannot affect a normal CI run, where those
# references are empty either way.
# Renaming `tests` means updating its readers -- `grep -rn github.event.inputs.tests .github/`.
# Nothing enforces that, deliberately: a stale name reads as empty, which profiles each leg's usual
# set instead of the tests you named, on the run you are watching. It cannot affect a normal CI
# run, where the reference is empty either way.
on:
workflow_dispatch:
inputs:
@@ -30,13 +29,8 @@ on:
- app-db
- drivers
- semantic-search
db:
description: 'Which leg of the suite: app-db takes mariadb, mysql or postgres; drivers takes a driver name, e.g. bigquery or mongo-ssl; semantic-search takes h2, mariadb, mysql, postgres or appdb-mode. Ignored by backend. Blank runs the whole suite -- every leg for app-db and semantic-search, mage''s usual decisions for drivers.'
type: string
required: false
default: ''
tests:
description: 'Optional: profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles the leg''s usual set.'
description: 'Optional: profile only these tests, comma- or space-separated, e.g. metabase.query-processor-test/some-test. Bare namespaces work too. Blank profiles each leg''s usual set.'
type: string
required: false
default: ''

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -1,6 +1,7 @@
frontend/src/cljs
docs/configuring-metabase/environment-variables.md
docs/ai/providers.md
docs/ai/mcp-tools.md
docs/api/*
docs/api-documentation.md
frontend/src/embedding-sdk-shared/.storybook/msw-public/mockServiceWorker.js

View File

@@ -61,6 +61,15 @@ const config: StorybookConfig = {
build: {
test: {
disableTreeShaking: false,
// Setting this replaces the addons --test disables by default,
// so the first three repeat Storybook's defaults.
disabledAddons: [
"@storybook/addon-docs",
"@storybook/addon-essentials/docs",
"@storybook/addon-coverage",
"@storybook/addon-a11y",
"@storybook/addon-interactions",
],
},
},
typescript: {

View File

@@ -49,16 +49,17 @@ if (
document.head.appendChild(fontFaceStyle);
}
// Force every registered font to load before the story renders. This ensures we
// use same fonts for tests every time, instead of using generic fallback
// family like `sans-serif` which might resolve to different specific fonts (depending
// on what available on current machine) which might have different metrics, in turn
// producing inconsistent behavior in code relying on measuring text (e.g., column
// autosize in table visualization). Awaiting all loads here makes measurements
// deterministic across platforms.
const PRELOADED_FONT_FAMILIES = ["Lato", "JetBrains Mono"];
// Load the app's text and monospace fonts before the story renders,
// so code that measures text, like the table's column autosize, gets the same metrics on every machine.
const fontsReady = async () => {
const loads: Promise<unknown>[] = [];
document.fonts.forEach((face) => loads.push(face.load()));
document.fonts.forEach((face) => {
if (PRELOADED_FONT_FAMILIES.includes(face.family)) {
loads.push(face.load());
}
});
await Promise.all(loads);
};

24
bb.edn
View File

@@ -253,6 +253,30 @@
:requires [[mage.quick-test-runner :as qtr]]
:task (task! (qtr/go parsed))}
openapi-diff
{:doc "Semantic diff of two OpenAPI specs, classified breaking-first, for the API changelog"
:examples [["./bin/mage openapi-diff old.json new.json" "Full diff, breaking changes first"]
["./bin/mage openapi-diff old.json new.json --severity breaking" "Only what needs a changelog entry"]
["./bin/mage openapi-diff --refs release-x.63.x release-x.64.x --severity breaking" "Generate both specs from source, then diff"]
["./bin/mage openapi-diff --refs --committed release-x.63.x master" "Use each ref's committed spec (fast, may be stale)"]
["./bin/mage openapi-diff --refs --grouped release-x.63.x master --severity breaking" "One line per distinct change, widest blast radius first"]]
:arg-schema [:tuple
[:string {:name "old" :description "Path to the older openapi.json, or a git ref with --refs"}]
[:string {:name "new" :description "Path to the newer openapi.json, or a git ref with --refs"}]]
:options [["-s" "--severity LEVEL" "Only show findings at or above LEVEL: breaking or additive"]
["-r" "--refs" "Treat the arguments as git refs and generate each spec from its source"]
["-c" "--committed" "With --refs, read each ref's committed spec instead of generating (fast, may be stale)"]
["-g" "--grouped" "Collapse identical findings across endpoints: one line per distinct change"]]
:requires [[mage.openapi-diff]]
:task (task! (mage.openapi-diff/cli-diff parsed))}
openapi-staleness
{:doc "Is the committed OpenAPI spec current relative to the API source? (a stale spec under-reports)"
:examples [["./bin/mage openapi-staleness"]]
:arg-schema [:tuple]
:requires [[mage.openapi-diff]]
:task (task! (mage.openapi-diff/cli-staleness parsed))}
affected-modules
{:doc "Which backend modules have changes?"
:examples [["./bin/mage affected-modules master" "prints changes compared to `master`"]

View File

@@ -159,6 +159,7 @@ Metabase's reference documentation.
- [AI system prompts](./ai/system-prompts.md)
- [Agent API](./ai/agent-api.md)
- [MCP server](./ai/mcp.md)
- [MCP server tools](./ai/mcp-tools.md)
- [Metabot in Slack](./ai/metabot-slack.md)
- [AI privacy](./ai/privacy.md)

View File

@@ -113,9 +113,7 @@ Set up Remote Sync in your production Metabase in Read-only mode pointed at the
### 2. Create a branch from the Metabase UI
Switch branches in Metabase, as the Metabase UI is the source of truth for which branch the development instance pushes to and pulls from.
In your development Metabase, click the **branch dropdown** at the top and [create a new branch](../installation-and-operation/remote-sync.md#creating-a-branch) for your work, like `feature/support-dashboard`.
In your development Metabase, [create a new branch](../installation-and-operation/remote-sync.md#creating-a-branch) for your work, like `feature/support-dashboard`. Metabase switches to the new branch. Your local Git checkout doesn't change which branch Metabase pushes to and pulls from.
### 3. Ask the agent to create content

469
docs/ai/mcp-tools.md Normal file
View File

@@ -0,0 +1,469 @@
---
title: MCP server tools
summary: The tools Metabase's MCP server exposes to AI clients, the permission each one needs, and the arguments each one takes.
---
# MCP server tools
_This documentation was generated from source by running:_
```
clojure -M:ee:doc mcp-tools-documentation
```
These are the tools an AI client can call once you've [connected it to your Metabase's MCP server](./mcp.md). Every tool runs as you, scoped to your permissions, so a tool can never reach data you couldn't see in Metabase yourself.
Some clients (like Claude Desktop) ask you to approve or block each tool the first time it's used.
The argument notes here are exactly what your agent sees (which is why they sound robotic). Several tools take or return a `query_handle`. A handle stands for a query that already ran (or was validated), so your agent can visualize or save exactly that query without sending it again. By default, handles expire after 24 hours.
Your agent will use `execute_query` for anything Metabase's query language can express (counts, sums, grouping, filtering, joins) and `execute_sql` for the rest (window functions, CTEs, engine-specific functions), or when you ask for SQL outright.
## Alert write
- Tool name: `alert_write`
- Permission scope: `agent:delivery:write` — Set up scheduled delivery of your data to email addresses and Slack channels it chooses
- Creates or changes content.
Arguments:
| Argument | Type | Description |
| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `active` | boolean | false pauses the alert, true resumes it (resuming needs the agent:query:run scope). Defaults to true on create. Alerts have no archived state and cannot be deleted here. |
| `card_id` | integer or string | Numeric id of the saved question the alert runs. Fixed at creation. 21-character entity_id of the saved question the alert runs. Fixed at creation. |
| `channel` | string | One of: `email`, `slack`. Where to deliver: "email" (default) with `recipients`, or "slack" with `slack_channel`. Passing any of channel, slack_channel, or recipients on update replaces the alert's delivery; omit all three to leave it alone. |
| `condition` | object | When the alert sends. Defaults to sending whenever the question returns rows; the goal conditions need a goal line on the question's chart. |
| `id` | integer or string | Numeric id of the alert to update. The same numeric id as a string. Alerts have no entity_id. |
| `method` | string | One of: `create`, `update`. "create" makes a new alert (requires `card_id` and `schedule`); "update" edits the one named by `id`, changing only the fields you pass. |
| `recipients` | array | Who gets the email: numeric user ids, or email addresses. Defaults to you. On update this replaces the current list. Not used for channel "slack". |
| `schedule` | object | When the question runs, in the instance's report time zone. Required on create; never a cron string. |
| `slack_channel` | string | Slack channel name to post to, e.g. "#data-team". Required for channel "slack". |
## Bookmark content
- Tool name: `bookmark_content`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Creates or changes content.
- Running it again with the same arguments has the same effect as running it once.
Arguments:
| Argument | Type | Description |
| ------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `bookmarked` | boolean | true bookmarks the item, false removes the bookmark. |
| `id` | integer or string | Numeric id. A 21-character entity_id. |
| `type` | string | One of: `collection`, `dashboard`, `document`, `metric`, `model`, `question`. The content type, as returned by search/browse_collection. |
## Browse collection
- Tool name: `browse_collection`
- Permission scope: `agent:content:read` — See your Metabase content and data structure
- Read-only.
Arguments:
| Argument | Type | Description |
| ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `created_by` | string | One of: `me`. items mode: me restricts results to items the current user created (questions, models, metrics, dashboards, documents — other types return nothing under this filter). Composes with type. |
| `depth` | integer | tree mode: subcollection levels to expand (default 2). Deeper or trimmed nodes carry a truncation marker naming the re-rooting call. |
| `fields` | array of string | items mode: dot-paths picked from the detailed row shape, item-relative (e.g. "last-edit-info.email"). Mutually exclusive with response_format. |
| `id` | integer or string | Numeric collection id. A 21-character entity_id, "root" (the per-namespace root), or "trash" (items mode only — archived content, making restore discoverable). |
| `limit` | integer | items mode: maximum rows to return (default 50). |
| `mode` | string | One of: `items`, `tree`. items (default) lists the collection's contents; tree returns the nested subcollection structure (collections only, no items, no pagination). |
| `namespace` | string | One of: `content`, `snippets`, `transforms`, `analytics`. Which collection partition to browse; only meaningful with id: "root" (a real collection id already carries its namespace). content (default) holds questions/dashboards/etc.; snippets holds snippet folders and snippets; transforms holds transform folders and transforms; analytics is the read-only usage-analytics tree. |
| `offset` | integer | items mode: rows to skip, for paging. |
| `pinned_state` | string | One of: `all`, `is_pinned`, `is_not_pinned`. items mode: all (default) interleaves pinned and unpinned rows. Pinned-first as in the product is two calls — is_pinned, then is_not_pinned — each paging independently. |
| `response_format` | string | One of: `concise`, `detailed`. items mode: concise (default) returns {id, name, model, description, collection_position} rows; detailed adds entity_id, collection_id, archived, location, and last-edit info. |
| `sort_column` | string | One of: `name`, `last_edited_at`, `model`. items mode: sort key (default name). |
| `sort_direction` | string | One of: `asc`, `desc`. items mode: sort direction (default asc). |
| `type` | array of string | One of: `question`, `model`, `metric`, `dashboard`, `collection`, `document`. items mode, content namespace only: return only these item types. |
## Browse data
- Tool name: `browse_data`
- Permission scope: `agent:content:read` — See your Metabase content and data structure
- Read-only.
Arguments:
| Argument | Type | Description |
| ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | string | One of: `list_databases`, `list_schemas`, `list_tables`, `list_models`, `get_fields`. What to browse. list_databases → list_schemas → list_tables → get_fields walks the hierarchy; list_models lists the models built on a database. |
| `database_id` | integer | Numeric database id (databases have no entity_id). Required for list_schemas, list_tables, and list_models. Ignored by get_fields. |
| `fields` | array of string | Dot-paths picked from the detailed row shape, item-relative (e.g. "fields.name"). Mutually exclusive with response_format. Not supported for list_schemas. |
| `include_hidden` | boolean | Include hidden schemas/tables (list_schemas, list_tables) or hidden fields (get_fields). Sensitive fields are always excluded. Default false. |
| `limit` | integer | list_* actions: maximum rows to return (default 50). |
| `offset` | integer | list_* actions: rows to skip, for paging. For get_fields it pages the fields of a single oversized table, as directed by the continuation message. |
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns the essential columns; detailed adds the full projection (for get_fields: effective_type, coercion_strategy, database_type, fingerprint, has_field_values, and inline values for list-type fields). Values and fingerprints are permission-dependent, and absent rather than empty when withheld: values need permission to query the table, not just to read its metadata, and fingerprints are withheld entirely when your row access to the table is narrowed. |
| `schema` | string | list_tables only: the schema to list. Omit (or pass "") for databases without schemas. |
| `search` | string | list_tables only: case-insensitive substring filter on table name or display name, applied before paging. |
| `table_ids` | array of integer | get_fields only: numeric table ids (tables have no entity_id), at most 20 per call. |
## Collection write
- Tool name: `collection_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Can overwrite or delete existing data or content.
Arguments:
| Argument | Type | Description |
| ----------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | Update only: true moves the collection and its contents to the trash, false restores them. Archiving is the only removal path — there is no hard delete. Omit to leave the collection's trashed state alone. |
| `authority_level` | string | One of: `official`. Marks the collection Official. Requires an admin on an instance with the Official Collections feature. To make a collection unofficial again, name it in `clear` — `clear: ["authority_level"]`. |
| `clear` | array of string | One of: `description`, `authority_level`. Update only: property names to unset (description, authority_level). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `description` | string | Optional human-readable description. To remove one, name it in `clear` — `clear: ["description"]`. |
| `id` | integer or string | Numeric id of the collection to update. 21-character entity_id of the collection to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new collection (requires `name`); "update" edits the one named by `id`. |
| `name` | string | Required on create; editable on update: display name of the collection. |
| `namespace` | string | One of: `snippets`, `transforms`. Create only: puts the collection in a separate hierarchy instead of the normal one. "snippets" holds SQL snippet folders; "transforms" holds transform folders, the ones transform_write's `collection_id` names. Omit for a normal collection. |
| `parent_id` | integer or string | The collection to nest under (create) or move into (update). Numeric id, 21-character entity_id, or "root" for the top level. Omitted on create means your personal collection. You need write access to the parent. |
## Dashboard write
- Tool name: `dashboard_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Can overwrite or delete existing data or content.
Arguments:
| Argument | Type | Description |
| --------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
| `auto_apply_filters` | boolean | When false, filter changes wait for the viewer to press Apply. Default true. |
| `cache_ttl` | integer | Cache lifetime for this dashboard's results, in hours. |
| `clear` | array of string | One of: `description`, `collection_position`, `cache_ttl`. Update only: property names to unset (description, collection_position, cache_ttl). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `collection_id` | integer or string | Numeric id of the collection to put it in. Omit on create for your personal collection. Collection entity_id, or "root" for the top-level collection. |
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
| `description` | string | One or two sentences on what the dashboard answers. |
| `id` | integer or string | Numeric id of the dashboard to update. 21-character entity_id of the dashboard to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new dashboard (requires `name`); "update" edits the one named by `id`. |
| `name` | string | Dashboard title. |
| `ops` | array of object | Editor operations, applied in order. One atomic save on update; on create the dashboard row is written before the ops are applied. |
| `validate_only` | boolean | Dry run: returns the layout the ops would produce, writing nothing. |
| `width` | string | One of: `fixed`, `full`. "fixed" (default) centers the grid; "full" stretches it to the browser width. |
## Document write
- Tool name: `document_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Creates or changes content.
Arguments:
| Argument | Type | Description |
| --------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | Update only: true moves it to the trash, false restores it. |
| `clear` | array of string | One of: `collection_position`. Update only: property names to unset (collection_position). A null cannot say this — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `collection_id` | integer or string | Numeric id of the collection to put it in. Omit on create for your personal collection. Collection entity_id, or "root" for the top-level collection. |
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
| `content_markdown` | string | The full body in Metabase-flavored Markdown: CommonMark plus card embeds, entity links, and ::: layout containers (learn("documents")). Required on create. On update it is a deliberate full-body rewrite that orphans every comment thread anchored to the body; pass `edits` to change text in place instead. |
| `edits` | array of object | Update only: surgical text edits, each {old_str, new_str, replace_all?}, applied in order against the current server-side Markdown. Exactly one of `edits` or `content_markdown`. An empty list changes only name, collection_id, collection_position, or archived without touching the body. |
| `id` | integer or string | Numeric id of the document to update. 21-character entity_id of the document to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new document (requires `name` and `content_markdown`); "update" edits the one named by `id`. |
| `name` | string | Document title. Required on create; on update, renames it. |
## Duplicate content
- Tool name: `duplicate_content`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Creates or changes content.
Arguments:
| Argument | Type | Description |
| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `collection_id` | integer or string | Collection to copy into. Omit to copy into your personal collection; pass "root" for the root collection. A 21-character collection entity_id, or "root". |
| `id` | integer or string | Numeric id of the item to copy. A 21-character entity_id of the item to copy. |
| `is_deep_copy` | boolean | Dashboards only: also copy the dashboard's questions into the destination collection, instead of pointing the copy at the originals. |
| `new_name` | string | Name for the copy. Defaults to "Copy of <source name>". |
| `type` | string | One of: `question`, `dashboard`, `document`. The kind of content to copy. Card flavors other than question (model, metric) aren't supported yet. |
## Execute query
- Tool name: `execute_query`
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
- Read-only.
Arguments:
| Argument | Type | Description |
| --------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cursor` | string | The next_cursor from a previous truncated response — fetches the next page. Exactly one of query \| query_handle \| cursor, but row_limit may accompany it and sets this page's size. |
| `prompt` | string | The user's original request, stored with the minted query_handle and carried along its cursor pages. |
| `query` | object | A fresh structured query (shape and examples in the tool description): numeric table/field ids from browse_data, never base64, never SQL. Exactly one of query \| query_handle \| cursor. |
| `query_handle` | string | A query_handle from a previous call — re-validates and re-runs the exact stored query. Exactly one of query \| query_handle \| cursor. |
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000) — the page size, not a bound on the result; the bound is limit: N in the query's stage. |
| `validate_only` | boolean | true validates against schema + database metadata and mints a query_handle without executing (default false). |
## Execute SQL
- Tool name: `execute_sql`
- Permission scope: `agent:sql:run` — Write and run its own raw SQL on your connected databases
- Can overwrite or delete existing data or content.
Arguments:
| Argument | Type | Description |
| --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `database_id` | integer | Numeric id of the database to run the SQL against. Requires native-query permission on it. |
| `prompt` | string | The user's original request, stored with the minted query_handle and carried along its cursor pages. |
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000). |
| `sql` | string | The raw SQL text, run verbatim against the database. Put caller-supplied values behind {% raw %}{{tag}}{% endraw %} placeholders bound via template_tag_values — never splice them into this string. |
| `template_tag_values` | object | Values for the {% raw %}{{tag}}{% endraw %} placeholders in sql, keyed by tag name. Each value binds as a driver-level prepared-statement parameter (injection-safe): strings bind as text, numbers as numbers. Snippet ({% raw %}{{snippet: …}}{% endraw %}) and card-reference ({% raw %}{{#123}}{% endraw %}) tags cannot be populated here. |
| `validate_only` | boolean | true mints a query_handle without executing — template tags and permissions are checked, the SQL text itself is not (default false). |
## Get content
- Tool name: `get_content`
- Permission scope: `agent:content:read` — See your Metabase content and data structure
- Read-only.
Arguments:
| Argument | Type | Description |
| ----------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `include` | array of string | One of: `definition`, `fields`, `visualization_settings`, `parameters`, `layout`, `dimensions`, `comments`. Extra sections, each applied to every item whose type supports it and ignored for the rest — so a mixed-type batch can ask for several at once: definition (query-bearing types, returned as the stored query — numeric ids, the shape execute_query and question_write accept back verbatim), fields (question/model column metadata), visualization_settings (question/model stored chart settings, the shape question_write takes back; {} when nothing is stored), parameters (dashboard's full parameter array), layout (dashboard grid + tabs, document block outline), dimensions (metric/measure), comments (document comment threads, each anchored into the returned content_markdown by {start, end, text} character offsets — the exact slice of the block the thread is attached to; comments attach to whole blocks, a block nested inside a list/blockquote anchors to the span of the nearest enclosing block that has one, and an empty block gives start == end; threads whose block no longer exists come back under orphaned_comments so they can be re-anchored by editing the right block, and if the document read fell back to flattened text no thread carries an anchor). A section no item in the batch supports is an error. |
| `items` | array of object | The content to fetch — up to 10 items, mixed types allowed (e.g. a dashboard and its questions in one call). |
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns each type's essential shape; detailed adds entity_id, creator, timestamps, and other secondary columns. |
## Get parameter values
- Tool name: `get_parameter_values`
- Permission scope: `agent:content:read` — See your Metabase content and data structure
- Read-only.
Arguments:
| Argument | Type | Description |
| -------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `constraints` | object | Chain filtering: the current selections of the dashboard's OTHER filters, keyed by their parameter ids, narrowing this filter to the values still valid alongside them. Dashboards only. |
| `id` | integer or string | Numeric id. A 21-character entity_id. |
| `limit` | integer | Maximum values to return in this call (default 100, max 1000). |
| `offset` | integer | Index of the first value to return (default 0) — continue a truncated response. Each page refetches from the source, which returns at most 1000 values, so narrow with `query` rather than paging to reach anything past that. |
| `parameter_id` | string | The parameter's id, as returned by get_content under `parameters` — not its name or slug. |
| `query` | string | Return only values matching this search string. Use it to narrow a large value list. |
| `target` | string | One of: `dashboard`, `question`. Whether id names a dashboard or a card. "question" covers any card — question, model, or metric. |
## Glossary
- Tool name: `glossary`
- Permission scope: `agent:content:read` — See your Metabase content and data structure
- Read-only.
Arguments:
| Argument | Type | Description |
| -------- | ------- | ---------------------------------------------------------------------------------------------------- |
| `limit` | integer | Maximum entries to return (default 50, max 500). Ignored with "term", which is a lookup, not a page. |
| `offset` | integer | Number of entries to skip, for paging (default 0). Ignored with "term". |
| `term` | string | The term to define, matched case-insensitively. Omit to list terms with their definitions. |
## Learn
- Tool name: `learn`
- Permission scope: `agent:content:read` — See your Metabase content and data structure
- Read-only.
Arguments:
| Argument | Type | Description |
| ----------- | ------ | -------------------------------------------------------------------------- |
| `reference` | string | A reference file of `topic`, by the name the skill (or the catalog) lists. |
| `topic` | string | A topic from the catalog. Omit to list all topics. |
## Measure write
- Tool name: `measure_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Can overwrite or delete existing data or content.
Arguments:
| Argument | Type | Description |
| ------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `definition` | object or array | Either (a) the aggregation clause — what get_content's "definition" include returns for a measure (one-element array or bare clause) and what execute_query takes in stages[0].aggregation — reassembled onto `table_id`; or (b) a full single-stage query ("lib/type": "mbql/query") holding exactly one aggregation. No filters, breakouts, joins, expressions, or limits. May reference other measures but not metrics; cycles are rejected. |
| `description` | string | Optional human-readable description. |
| `id` | integer or string | Numeric id of the measure to update. 21-character entity_id of the measure to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new measure (requires `table_id`, `name`, `definition`); "update" edits the one named by `id` (requires `revision_message`). |
| `name` | string | Create only (editable on update): display name of the measure. |
| `revision_message` | string | Update only, required: a short sentence describing the change, recorded in the revision history. |
| `table_id` | integer | Create only: numeric table id (tables have no entity_ids). A bare-clause `definition` is reassembled into a query on this table; a full-query one must name this same source table — a mismatch is a teaching error, not silently reconciled. |
## Metric write
- Tool name: `metric_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Can overwrite or delete existing data or content.
Arguments:
| Argument | Type | Description |
| --------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `archived` | boolean | Update only: true moves the metric to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
| `clear` | array of string | One of: `description`, `collection_position`. Update only: property names to unset (description, collection_position). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `collection_id` | integer or string | Numeric id of the collection to save the metric in. 21-character entity_id of the collection, or "root" for the root collection. |
| `collection_position` | integer | Pins the metric at this position in its collection. |
| `definition` | object | The metric's query: a full single-stage query holding exactly one aggregation and at most one breakout. Accepts the same numeric-id shape get_content's "definition" include returns for a metric and execute_query takes. Pass this or query_handle, not both. |
| `description` | string | Optional human-readable description. |
| `display` | string | One of: `table`, `bar`, `line`, `pie`, `scatter`, `area`, `row`, `combo`, `pivot`, `scalar`, `smartscalar`, `gauge`, `progress`, `funnel`, `map`, `waterfall`, `sankey`. How the metric's result is visualized. Defaults to "scalar" — right for a single number, wrong for a metric with a grouping, which usually wants "line" (a grouping over time) or "bar" (a grouping over categories). Editable on update. |
| `id` | integer or string | Numeric id of the metric to update. 21-character entity_id of the metric to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new metric (requires `name` and one query source); "update" edits the one named by `id`. |
| `name` | string | Create only (editable on update): display name of the metric. |
| `query_handle` | string | A query_handle from execute_query — saves exactly the query that ran. Pass this or definition, not both. |
## Question write
- Tool name: `question_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Can overwrite or delete existing data or content.
Arguments:
| Argument | Type | Description |
| ------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | Update only: true moves it to the trash, false restores it. |
| `cache_ttl` | integer | Legacy per-question cache TTL. Stored and echoed back, but no longer read: caching is configured through cache policies in admin settings. |
| `card_type` | string | One of: `question`, `model`. "question" (default) or "model". |
| `clear` | array of string | One of: `description`, `collection_position`, `cache_ttl`. Update only: property names to unset (description, collection_position, cache_ttl). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `collection_id` | integer or string | Numeric id of the collection to save it in. Omit on create for your personal collection. Exclusive with `dashboard_id`. Collection entity_id, or "root" for the top-level collection. |
| `collection_position` | integer | Pin position within the collection; omit to leave it unpinned. |
| `column_metadata` | array of object | Per-column result metadata to set, matched to the query's result columns by name. Typically used with card_type "model". |
| `dashboard_id` | integer or string | Numeric id of a dashboard to save the question inside; it inherits that dashboard's collection. On update, moves the card into that dashboard. Exclusive with `collection_id`. 21-character entity_id of the dashboard to save the question inside. |
| `description` | string | One or two sentences on what the question answers. |
| `display` | string | One of: `table`, `list`, `bar`, `line`, `pie`, `scatter`, `area`, `row`, `combo`, `pivot`, `scalar`, `smartscalar`, `gauge`, `progress`, `funnel`, `map`, `waterfall`, `sankey`. Visualization type. learn("visualization-settings") covers the choice. |
| `id` | integer or string | Numeric id of the question to update. 21-character entity_id of the question to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new question or model (requires `name` and exactly one of `query_handle`, `query`, or `native`); "update" edits the one named by `id`. |
| `name` | string | Question title. Required on create. |
| `native` | object | A native SQL query to save: {database_id, sql, template_tags?}. Requires the agent:sql:run scope and the mcp-execute-sql-enabled setting. Call learn("native-parameters") before first passing template_tags. |
| `query` | object | An inline query with numeric ids and a top-level database id (learn("query-dialect")). Prefer `query_handle`. |
| `query_handle` | string | A handle returned by execute_query, execute_sql, or visualize_query. The preferred query source on create: it saves exactly the query that tool validated. |
| `visualization_settings` | object | Display settings for the chosen `display`; learn("visualization-settings") lists the keys. |
## Render drill through
- Tool name: `render_drill_through`
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
- Read-only.
- Interactive: renders a chart inline in your AI client. Only available in clients that support inline visualizations.
Arguments:
| Argument | Type | Description |
| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
| `query_handle` | string | The handle UUID from the user's drill-through message. Pass it through verbatim — do not run the query yourself. |
## Run saved question
- Tool name: `run_saved_question`
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
- Read-only.
Arguments:
| Argument | Type | Description |
| ------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer or string | Numeric card id. A 21-character entity_id. |
| `parameters` | array of object | Parameter values to apply, each {id, value} where id is the parameter's id or slug. The card's stored target and type always apply. Discover a card's parameters with get_content. |
| `row_limit` | integer | Maximum rows to return in this call (default 100, max 2000). |
## Search
- Tool name: `search`
- Permission scope: `agent:content:read` — See your Metabase content and data structure
- Read-only.
Arguments:
| Argument | Type | Description |
| ------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | true searches the trash instead of active content. |
| `collection_id` | integer or string | Numeric collection id. A 21-character entity_id, or "root" for no scoping. |
| `created_by` | string | One of: `me`. "me" restricts results to items you created. Only question, model, metric, dashboard, document, measure, and action index a creator. |
| `fields` | array of string | Dot-paths picked from the detailed row shape, item-relative (e.g. "collection.name"). Mutually exclusive with response_format. |
| `limit` | integer | Maximum results to return (default 20, max 50). |
| `offset` | integer | Number of results to skip, for paging (default 0). |
| `recent` | boolean | true returns your recently viewed items instead of searching. Combines with type (only question, model, metric, dashboard, document, collection, table are tracked) but not with queries or other filters. Reflects Metabase UI views only — content you read through these tools is not recorded as a view, so a fresh token's recents can be empty even after get_content calls in the same session. |
| `response_format` | string | One of: `concise`, `detailed`. concise (default) returns {type, id, name, collection_path, description} rows; detailed returns the full search-result rows. |
| `semantic_queries` | array of string | A natural-language query matched by semantic similarity when a semantic engine is active (keyword-ranked otherwise). Each query runs separately; results are merged by rank. |
| `term_queries` | array of string | A keyword query matched against names and descriptions via full-text search. Each query runs separately; results are merged by rank. |
| `type` | array of string | One of: `question`, `model`, `metric`, `measure`, `segment`, `dashboard`, `document`, `collection`, `table`, `database`, `snippet`, `transform`, `action`. Restrict results to these entity types. "snippet" is served by a separate listing (snippets aren't in the search index), requires the agent:content:read scope, and must be requested on its own — combining it with other types is an error. Omit to search every type this tool supports except snippets. |
## Segment write
- Tool name: `segment_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Can overwrite or delete existing data or content.
Arguments:
| Argument | Type | Description |
| ------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | Update only: true moves it to the trash, false restores it. Archiving is the only removal path — there is no hard delete. |
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `definition` | object or array | Either (a) an array of filter clauses — what get_content's "definition" include returns for a segment and what execute_query takes in stages[0].filters — reassembled onto `table_id`; or (b) a full single-stage query. Filters only: no aggregations, breakouts, joins, expressions, or limits. May reference other segments; cycles are rejected. |
| `description` | string | Optional human-readable description. |
| `id` | integer or string | Numeric id of the segment to update. 21-character entity_id of the segment to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new segment (requires `table_id`, `name`, `definition`); "update" edits the one named by `id` (requires `revision_message`). |
| `name` | string | Create only (editable on update): display name of the segment. |
| `revision_message` | string | Update only, required: a short sentence describing the change, recorded in the revision history. |
| `table_id` | integer | Create only: numeric table id (tables have no entity_ids). A bare-clause `definition` is reassembled into a query on this table; a full-query one must name this same source table — a mismatch is a teaching error, not silently reconciled. |
## Subscription write
- Tool name: `subscription_write`
- Permission scope: `agent:delivery:write` — Set up scheduled delivery of your data to email addresses and Slack channels it chooses
- Creates or changes content.
Arguments:
| Argument | Type | Description |
| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `archived` | boolean | Update only: true pauses the subscription and moves it to the trash, false restores it. This is the only removal path. |
| `channel` | string | One of: `email`, `slack`. Where to deliver. Defaults to "email" on create; on update, to the subscription's channel when it has exactly one. |
| `dashboard_id` | integer or string | Numeric id of the dashboard to deliver. 21-character entity_id of the dashboard to deliver. |
| `id` | integer or string | Numeric id of the subscription to update. 21-character entity_id of the subscription to update. |
| `method` | string | One of: `create`, `update`. "create" subscribes to the dashboard named by `dashboard_id`; "update" edits the subscription named by `id`. |
| `parameters` | array of object | Filter values applied to the dashboard before it is sent, making a filtered subscription. Only ids the dashboard actually has are accepted. |
| `recipients` | array | Who gets the email: numeric user ids, or raw email addresses for people without a Metabase account. Defaults to you. On update this replaces the current list. Numeric id of a Metabase user. An email address. |
| `schedule` | object | When to deliver. Metabase sends at the top of the hour, in the instance's report time zone. |
| `skip_if_empty` | boolean | When true, no email is sent if every card comes back empty. Default false. |
| `slack_channel` | string | Slack channel or username to post to, e.g. "data-team". Required for channel "slack". |
## Transform write
- Tool name: `transform_write`
- Permission scope: `agent:content:write` — Create, edit and trash Metabase content
- Creates or changes content.
Arguments:
| Argument | Type | Description |
| --------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `clear` | array of string | One of: `description`. Update only: property names to unset (description). Needed because a null cannot say "clear this" — strict clients fill every unset property with null, so nulls are stripped at the boundary. |
| `collection_id` | integer or string | Numeric id of the transform folder to file the transform in. 21-character entity_id of the transform folder, or "root" for the top level of the transforms tree. |
| `definition` | object | The transform's source: {"type": "query", "query": …}, the shape get_content's "definition" include returns for a transform. The query inside is the same numeric-id dialect execute_query takes, and may be native SQL. Pass this or query_handle, not both. |
| `description` | string | Optional human-readable description. |
| `id` | integer or string | Numeric id of the transform to update. 21-character entity_id of the transform to update. |
| `method` | string | One of: `create`, `update`. "create" makes a new transform (requires `name`, `target`, and one query source); "update" edits the one named by `id`. |
| `name` | string | Create only (editable on update): display name of the transform. |
| `query_handle` | string | A query_handle from execute_query or execute_sql — saves exactly the query that ran. Pass this or definition, not both. |
| `tag_ids` | array of integer | Numeric ids of transform tags to label the transform with; jobs select transforms by tag. Replaces the current list — pass [] to clear it. |
| `target` | object | The table the transform writes, recreated on every run. On update this patches the current target, so passing only `name` renames the table and keeps its schema. A target read back from get_content can be passed back unchanged. |
## Visualize query
- Tool name: `visualize_query`
- Permission scope: `agent:query:run` — Run queries against your connected databases and see the results
- Read-only.
- Interactive: renders a chart inline in your AI client. Only available in clients that support inline visualizations.
Arguments:
| Argument | Type | Description |
| -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prompt` | string | The user's original request, recorded with a freshly minted handle for the iframe's feedback flow. Ignored alongside `query_handle`: the stored prompt is fixed at mint time and there is no update path, so pass `prompt` on the call that mints the handle. |
| `query` | object | A fresh query in the same dialect execute_query takes: numeric table/field ids from browse_data, never base64. Exactly one of query \| query_handle. |
| `query_handle` | string | A query_handle from a previous execute_query / execute_sql call — visualizes the exact stored query, MBQL or native SQL. Preferred over query. Exactly one of query \| query_handle. |

View File

@@ -13,17 +13,19 @@ Metabase includes an [MCP (Model Context Protocol)](https://modelcontextprotocol
Your Metabase's MCP server is served from the `/api/metabase-mcp` endpoint.
If your admin has turned on [your Metabase's MCP server](#enable-mcp-server), all you need to do is point your MCP client at your Metabase's MCP server's URL.
Once an admin has [turned on the MCP server](#turn-on-the-mcp-server), point your MCP client at:
```
https://{your-metabase.example.com}/api/metabase-mcp
```
Replace `{your-metabase.example.com}` with your Metabase's URL. Admins can also find your instance's MCP URL in **Admin > AI > MCP > Settings > MCP server URL**.
Replace `{your-metabase.example.com}` with your Metabase's URL. Admins can also copy the URL from **Admin > AI > MCP > Settings > MCP server URL**.
Your client will direct you to an authentication page for your Metabase.
Once authenticated, you can approve or block the tools you want your Agent to have access to in your client (not in your Metabase).
Once authenticated, you can approve or block the tools in your client (not in your Metabase).
Connected a client before Metabase 0.64? It'll ask you to sign in again after you upgrade.
### Connecting Claude Code to your Metabase's MCP server
@@ -45,6 +47,8 @@ Replace `{your-metabase-url}` with your Metabase address.
5. Click **Authenticate**, and authenticate with your Metabase. (You may first need to **Enable** the Metabase MCP server).
If Claude asks you to choose authentication options, see [Choose authentication options in Claude](#choose-authentication-options-in-claude).
Once authenticated, ask your agent about your Metabase. You should see your agent use Metabase tools to interact with your Metabase.
See [Claude Code MCP docs](https://code.claude.com/docs/en/mcp).
@@ -53,97 +57,94 @@ See [Claude Code MCP docs](https://code.claude.com/docs/en/mcp).
If you use Claude on the web or Claude Desktop, go to [Claude's connector directory](https://claude.ai/directory/connectors/metabase) and enter your [Metabase's MCP URL](#connect-a-client-to-your-metabase-mcp-servers-url).
One of your Metabase admins will still need to have [turned on your Metabase's MCP server](#enable-mcp-server). You'll authenticate against your own Metabase during setup.
One of your Metabase admins will still need to have [turned on your Metabase's MCP server](#turn-on-the-mcp-server). You'll authenticate against your own Metabase during setup. If Claude asks you to choose authentication options, see [Choose authentication options in Claude](#choose-authentication-options-in-claude).
## Available tools
## MCP tools
Some clients (like Claude Desktop) will ask you to approve each tool the first time it's used. We've grouped the tools below, but your client may simply list the available tools.
The MCP server gives your agent tools for searching, browsing, querying, and creating content in your Metabase. For the full list and the permission each one needs, see [MCP server tools](./mcp-tools.md).
### Interactive tools
## Using the MCP server
These render inline visualizations in your AI client, and only work in clients that support inline charts.
The MCP server will return results as either text or an inline chart, depending on the question you asked.
- **Render drill through** (`render_drill_through`): Render the chart a person drilled into from an inline visualization.
- **Visualize query** (`visualize_query`): Render a query's results as an inline chart in your AI client.
If you want the MCP server to return an inline chart, ask it to "show" or "visualize" the data:
### Read-only tools
![Show me the stuff](./images/mcp-chart.png)
- **Construct query** (`construct_query`): Construct a query against a table or metric. Returns an opaque query handle that can be passed to `execute_query`.
- **Execute query** (`execute_query`): Execute a previously constructed query and return the results with column metadata, row count, and execution time.
- **Query tables and metrics** (`query`): Run a query and return results, paging through large result sets with a continuation token. Accepts a query handle from `construct_query`, a fresh query, or a continuation token. Paging returns up to 200 rows per page and 2,000 rows total.
- **Read resource** (`read_resource`): Read up to five Metabase entities (or lists) per call by `metabase://` URI. List URIs (like a collection's items or a table's fields) return up to 25 items each. Entities available to read include:
- collection
- dashboard
- database
- metric
- model
- question
- schema
- table
- transform
- **Search tables and metrics** (`search`): Find tables and metrics using keyword or natural language search.
Metabase picks a chart type based on the shape of the result. You can switch between bar, line, and table (when the data fits), and if the chart has a date axis, change the time range and granularity. You can also drill through the chart: zooming and sorting update the chart in place, and other drills either re-render in place or, in Claude, open the question in your Metabase. Every chart has an **Explore in Metabase** button that opens the question in Metabase.
### Write/delete tools
- **Create collection** (`create_collection`): Create a new collection, optionally nested under a parent collection.
- **Create dashboard** (`create_dashboard`): Create a new dashboard, optionally populated with saved questions.
- **Create question** (`create_question`): Save a query as a named question.
- **Execute SQL** (`execute_sql`): Execute a raw SQL query against a database. Requires native-query permission on the target database. An admin can disable this tool instance-wide via the `mcp-execute-sql-enabled` setting (enabled by default).
- **Update dashboard** (`update_dashboard`): Update a dashboard's metadata (name, description, collection, archived).
- **Update question** (`update_question`): Update a saved question. Setting `collection_id` moves the question to another collection.
Clients that can't render inline charts (like Claude Code) won't list the `visualize_query` tool at all. That's expected, not a sign of a broken connection.
## MCP server settings
_Admin > AI > MCP_
### Enable MCP server
### Turn on the MCP server
From **Admin > AI**, open the **MCP** tab in the left sidebar, and use the **MCP server** toggle to turn external access to the MCP server on or off.
From **Admin > AI**, open the **MCP** tab in the left sidebar, and use the **MCP server** toggle to turn the MCP server on or off.
The MCP server also requires that [AI features](./overview.md) are enabled for your instance. You don't need to configure an AI provider to use the MCP server, but if **AI features** are turned off in **Admin > AI**, the MCP server stays off too.
The MCP server also needs [AI features](./overview.md) on. If **Disable all AI features** is on in **Admin > AI**, the MCP server stays off and the **MCP** settings are grayed out.
You don't need an [AI provider](settings.md#choose-ai-provider) set up in Metabase to use the MCP server. Your client brings the model; Metabase just brings the tools. So MCP calls don't count against your Metabase's AI connection, even if you've set up a provider for Metabot. Results from the MCP server go to your client, which may forward them to an AI provider depending on how the client is set up. See [AI privacy](./privacy.md).
### Show inline charts in these MCP clients
These toggles control which browser-based clients can display [inline charts](#using-the-mcp-server) generated by Metabase. Switch on any of the supported clients:
- **Claude** (Claude Desktop and Claude on the web)
- **Claude** (Claude Desktop and Claude on the web). In Claude Desktop, use Chat, _not_ Cowork: inline charts don't work in Cowork.
- **Cursor and VS Code**
- **ChatGPT**
Toggling on a client automatically adds that client's sandbox domains to Metabase's CORS allowlist, which is what lets the client render Metabase's charts in its browser sandbox.
Turning on a client adds its domains to Metabase's CORS allowlist, so the client's browser sandbox can load Metabase's charts.
These toggles only control inline charts; they don't gate whether a client can connect. Any MCP client can connect to your MCP server (subject to [authentication](#authentication)), and clients that run outside the browser (like Claude Code on your own machine) don't need a CORS allowlist entry at all.
These toggles only control inline charts; they don't gate whether a client can connect. Any MCP client can connect (subject to [authentication](#authentication)), and clients that run outside the browser (like Claude Code on your own machine) don't need an allowlist entry at all.
### Custom inline chart origins
### Allowed origins for custom MCP clients
The **Custom inline chart origins** field is for browser-based MCP clients that render [inline charts](#using-the-mcp-server) but aren't in the supported list (like a self-hosted client).
Use the **Allowed origins for custom MCP clients** field for browser-based clients that render [inline charts](#using-the-mcp-server) but aren't in the list above (like a self-hosted client). It adds the origin to the same allowlist as the toggles.
![An inline chart](./images/inline-bar-chart.png)
Adding an origin here puts it on Metabase's CORS allowlist, just like toggling on one of the supported clients. Clients that run outside the browser (like Claude Code on your own machine) don't need an entry here.
Add the client's origin to the field. Separate values with a space, for example:
Add the client's origin, separating multiple origins with spaces:
```
https://mcp.internal.example.com https://*.staging.example.com
```
The field accepts wildcards (`*`) for subdomains. Changes take effect in about a minute. Might be a good time to get up and pour yourself a glass of water.
Wildcards (`*`) work for subdomains. Origins can't include a path. Changes take effect within a minute.
## Authentication
MCP clients authenticate with Metabase using OAuth 2.0. Metabase runs its own embedded OAuth server, so you don't need to set up an external OAuth provider.
Your MCP client should try to connect to your Metabase. You'll see a Metabase-branded consent page asking you to approve the connection to your Metabase.
A first-time connection will go something like this:
1. The client discovers Metabase's OAuth endpoints.
2. The client registers itself with Metabase.
3. You're redirected to Metabase to log in (if you aren't already) and approve the connection.
3. You're redirected to a Metabase consent page to log in (if you aren't already) and approve the connection.
4. The client receives an access token scoped to the permissions you have in Metabase.
Results returned by the MCP server are sent to your MCP client, which may forward them to an AI provider depending on how the client is configured. See [AI privacy](./privacy.md).
### Choose authentication options in Claude
If Claude asks you to choose authentication options when you add the Metabase connector, keep the detected defaults:
- **Authentication**: **Sign in now**. None of the tools work without an account, so **Sign in when needed** just delays the same sign-in, and **No sign-in** won't connect.
- **OAuth client**: **Register automatically**. Metabase supports dynamic client registration, but not **Use Claude's published identity** or **Use your own OAuth client**.
- **Request headers**: leave empty. The OAuth token is all Metabase needs.
### Permission scopes
The consent page lists the permission scopes the client is asking for. Each [tool](./mcp-tools.md) needs one of these:
- `agent:content:read`: see your Metabase content and data structure.
- `agent:content:write`: create, edit, and trash Metabase content.
- `agent:query:run`: run queries against your connected databases and see the results.
- `agent:sql:run`: write and run raw SQL on your connected databases.
- `agent:delivery:write`: create alerts and subscriptions that email or Slack your data. Creating one also needs `agent:query:run`, since it runs a query when it fires.
The consent page may also list `agent:resource:read` (shown as **Read MCP resources**). No tool needs it; it covers the server's own reference material: the list of fields your agent can ask `get_content` to return for each content type.
Most clients ask for all of them. A scope never grants more than your Metabase permissions allow: `agent:sql:run` won't let a client run SQL against a database you can't write native queries on.
## Authorization logs
@@ -159,27 +160,13 @@ Each record includes:
Use the event filter to narrow the list to a single event type.
## With the MCP server, your client provides the AI
## MCP analytics
MCP server requests are handled by whatever AI client you're using (like a desktop AI app or editor plugin). The MCP server just provides tools (like searching for an entity or running the query) for your AI.
On [Pro and Enterprise plans](https://www.metabase.com/pricing), admins can see how people use the MCP server. Go to **Monitor > AI Auditing > MCP analytics**.
For example, if you ask your AI client to use your Metabase's MCP server "what's our q3 revenue," your client will interact with the MCP server to figure out which tools it needs to field your request. Your AI can decide that it needs to use the tools **construct_query** and **execute_query**, and what those queries might be. Then your client will call those tools for Metabase to run.
The **Usage** tab charts calls over time by client, tool, and user, plus errors by type when there are any. The **Tool calls** tab lists each call with its tool, client, user, status, duration, and error type. Error messages only show up if you've turned on PII retention.
You don't need to have an [AI provider](settings.md#choose-ai-provider) configured in Metabase to use your Metabase's MCP server. If you _do_ have an AI provider configured in Metabase to power Metabot, that provider will _not_ be used for MCP server requests. MCP calls by your local client have no effect on token usage for your Metabase's AI connection.
## Using the MCP server
The MCP server will return results as either text or an inline chart, depending on the question you asked.
If you want the MCP server to return an inline chart, ask it to "show" or "visualize" the data:
![Show me the stuff](./images/mcp-chart.png)
Inline charts are limited to bar, line, or table charts (which you can toggle between). You can also drill through the charts or change their time granularity. Depending on which client you're using, drilling through will either let you keep exploring the chart right there in your client, or give you a link to continue your exploration in your Metabase.
If your client is connected to other MCP servers, you can ask questions that combine data from multiple sources. For example, you can ask a question about your customers that combines data from Metabase, your CRM, and your support ticket platform (Though maybe you should put all that data into your Metabase).
See [Available tools](#available-tools) for the list of functionality supported by the MCP server.
MCP calls don't appear in [AI usage auditing](./usage-auditing.md), since they don't go through Metabot.
## Use the MCP server with agent-driven development

View File

@@ -36,7 +36,9 @@ See [full docs for Metabot](metabot.md) and [embedded AI chat](../embedding/ai-c
Connect your favorite third-party AI tool - like Claude or Codex - to the Metabase MCP server. MCP servers are designed for answering in-the-moment questions like "hey btw what's our q3 revenue?" Metabase's MCP server is also useful when combined with other MCP servers. For example, you can ask Claude a question about your customers that combines data from Metabase, your CRM, and your support ticket platform.
**Controls**: Metabase MCP server requires people to authenticate into Metabase, and all the responses it provides will be scoped to their permissions. However, unlike built-in Metabot, MCP server doesn't come with granular control over which _tools_ people can use, or disable MCP server altogether.
Compared to the built-in Metabot, the MCP server has a narrower set of tools. See [MCP server tools](mcp-tools.md) for what it can do.
**Controls**: People log in to Metabase to use the MCP server, and everything it returns is scoped to their permissions. Admins can [turn the MCP server off](mcp.md#turn-on-the-mcp-server) for everyone.
**Provider**: Requests to Metabase's MCP server are handled by the provider you choose to connect to the MCP server (e.g. Claude, Cursor, etc).

View File

@@ -35,9 +35,11 @@ Supported models:
| Claude Opus 4.7 | `claude-opus-4-7` | 1,000,000 |
| Claude Opus 4.8 | `claude-opus-4-8` | 1,000,000 |
| Claude Opus 5 | `claude-opus-5` | 1,000,000 |
| Claude Opus 5.5 | `claude-opus-5-5` | 1,000,000 |
| Claude Sonnet 4.5 | `claude-sonnet-4-5-20250929` | 200,000 |
| Claude Sonnet 4.6 | `claude-sonnet-4-6` | 1,000,000 |
| Claude Sonnet 5 | `claude-sonnet-5` | 1,000,000 |
| Claude Sonnet 5.5 | `claude-sonnet-5-5` | 1,000,000 |
Credentials:
@@ -214,8 +216,10 @@ Supported models:
| Claude Haiku 4.5 | `anthropic/claude-haiku-4-5@20251001` |
| Claude Opus 4.6 | `anthropic/claude-opus-4-6` |
| Claude Opus 5 | `anthropic/claude-opus-5` |
| Claude Opus 5.5 | `anthropic/claude-opus-5-5` |
| Claude Sonnet 4.6 | `anthropic/claude-sonnet-4-6` |
| Claude Sonnet 5 | `anthropic/claude-sonnet-5` |
| Claude Sonnet 5.5 | `anthropic/claude-sonnet-5-5` |
| Gemini 3.5 Flash | `google/gemini-3.5-flash` |
| Gemini 3.6 Flash | `google/gemini-3.6-flash` |
| Gemini 3.7 Flash | `google/gemini-3.7-flash` |
@@ -267,19 +271,21 @@ Credentials:
Supported models:
| Model | Model ID | Context window (tokens) |
| -------------------- | ---------------------------- | ----------------------- |
| Claude Fable 5 | `anthropic.claude-fable-5` | 1,000,000 |
| Claude Haiku 4.5 | `anthropic.claude-haiku-4-5` | 200,000 |
| Claude Opus 4.7 | `anthropic.claude-opus-4-7` | 1,000,000 |
| Claude Opus 4.8 | `anthropic.claude-opus-4-8` | 1,000,000 |
| Claude Opus 5 | `anthropic.claude-opus-5` | 1,000,000 |
| Claude Sonnet 5 | `anthropic.claude-sonnet-5` | 1,000,000 |
| GPT-5.4 | `openai.gpt-5.4` | 272,000 |
| GPT-5.4 (2026-03-05) | `openai.gpt-5.4-2026-03-05` | 272,000 |
| GPT-5.5 | `openai.gpt-5.5` | 272,000 |
| GPT-5.5 (2026-04-23) | `openai.gpt-5.5-2026-04-23` | 272,000 |
| GPT-6 Astra | `openai.gpt-6-astra` | 922,000 |
| Model | Model ID | Context window (tokens) |
| -------------------- | ----------------------------- | ----------------------- |
| Claude Fable 5 | `anthropic.claude-fable-5` | 1,000,000 |
| Claude Haiku 4.5 | `anthropic.claude-haiku-4-5` | 200,000 |
| Claude Opus 4.7 | `anthropic.claude-opus-4-7` | 1,000,000 |
| Claude Opus 4.8 | `anthropic.claude-opus-4-8` | 1,000,000 |
| Claude Opus 5 | `anthropic.claude-opus-5` | 1,000,000 |
| Claude Opus 5.5 | `anthropic.claude-opus-5-5` | 1,000,000 |
| Claude Sonnet 5 | `anthropic.claude-sonnet-5` | 1,000,000 |
| Claude Sonnet 5.5 | `anthropic.claude-sonnet-5-5` | 1,000,000 |
| GPT-5.4 | `openai.gpt-5.4` | 272,000 |
| GPT-5.4 (2026-03-05) | `openai.gpt-5.4-2026-03-05` | 272,000 |
| GPT-5.5 | `openai.gpt-5.5` | 272,000 |
| GPT-5.5 (2026-04-23) | `openai.gpt-5.5-2026-04-23` | 272,000 |
| GPT-6 Astra | `openai.gpt-6-astra` | 922,000 |
Credentials:

View File

@@ -30,7 +30,7 @@ You pick which AI providers Metabase can use:
- If you're **self-hosting Metabase** and want to use Metabot, you'll need to [connect to an AI provider](#connect-to-an-ai-provider) with your own credentials.
- On **Metabase Cloud**, you can [connect to an AI provider](#connect-to-an-ai-provider) with your own credentials, [use the Metabase AI service](#metabase-ai-service), or both.
The providers you set up in AI settings power Metabase's built-in AI functionality, not the MCP server. With the MCP server, [your client provides the AI](mcp.md#with-the-mcp-server-your-client-provides-the-ai).
The providers you set up here power Metabase's built-in AI features. The [MCP server](mcp.md#turn-on-the-mcp-server) doesn't use them; your MCP client brings its own model.
### Metabase AI service
@@ -122,7 +122,7 @@ _Admin > AI_
![Metabot settings](./images/ai-settings.png)
The **Metabot settings** card has two tabs — **Internal** and **Embedded** — so you can configure Metabot for your internal Metabase separately from [embedded](../embedding/introduction.md) Metabase contexts. That way you can, for example, use Metabot in your Metabase while not granting access to Metabot in your embedded Metabase. Each tab has its own enable toggle, verified-content setting, allowed collection, and prompt suggestions, all configured independently.
The **Metabot settings** card has two tabs, **Internal** and **Embedded**, so you can configure Metabot in different contexts. Each tab has its own enable toggle, verified or curated content setting, allowed collection, and prompt suggestions.
### Enable Metabot
@@ -143,13 +143,13 @@ The **Enable Embedded Metabot** toggle turns embedded Metabot on or off. The tog
- [Full-app embedding](../embedding/full-app-embedding.md): The Metabot icon and keyboard shortcuts are only available when Metabot is enabled. Turning off Embedded Metabot will hide these icons and disable the keyboard shortcuts.
- [Modular embedding](../embedding/modular-embedding.md): The toggle doesn't add Metabot anywhere; you have to explicitly include a chat component (like the SDK's [`MetabotQuestion`](../embedding/ai-chat.md)) in your application. If, however, you've added a component, and you turn off the Embedded Metabot toggle, your chat component will stop working, so you should also remove or hide the component in your application.
### Verified content
### Verified or curated content
{% include plans-blockquote.html feature="Verified or curated content" %}
_Available on both the Internal and Embedded tabs, configured independently._
Admins on Pro and Enterprise plans can tell Metabot to only work with [models](../data-modeling/models/models.md) and [metrics](../data-modeling/semantic-layer/metrics.md) that have been [verified](../exploration-and-organization/content-verification.md).
Restricting Metabot to verified models and metrics (and only models and metrics) helps Metabot produce more reliable answers, since you know someone has at least vetted the data Metabot can use.
When turned on, Metabot only uses content that's [verified](../exploration-and-organization/content-verification.md), in an [official collection](../exploration-and-organization/collections.md#official-collections), or published to the [Library](../data-modeling/semantic-layer/library.md). Published tables only count if their [visibility layer](../data-modeling/metadata/managing-tables.md#visibility-layer) is **Final**.
### Collection for natural language querying
@@ -169,7 +169,7 @@ If you're embedding the Metabot component in an app, you can point embedded Meta
Picking **Our analytics** is the same as picking no collection at all, so pick something narrower if you want the scoping to do anything. And once you set a collection, tables drop out of embedded Metabot's search results, so pick a collection with the metrics and models you want people building on.
This setting narrows where embedded Metabot searches; it's _not_ a substitute for setting permissions. Embedded Metabot can still read and query anything the person using it has permissions for. Embedded Metabot can also see the items that person viewed recently, whichever collection those live in. Restricting Metabot to [verified content](#verified-content) narrows those recent items to verified, official, and [Library](../data-modeling/semantic-layer/library.md) content, but it doesn't confine them to the collection you picked. To control what data people can get to in an embed, set [data permissions](../permissions/embedding.md). See also [Set up AI chat in Metabase](../embedding/ai-chat.md#set-up-ai-chat-in-metabase).
This setting narrows where embedded Metabot searches; it's _not_ a substitute for setting permissions. Embedded Metabot can still read and query anything the person using it has permissions for. Embedded Metabot can also see the items that person viewed recently, whichever collection those live in. Restricting Metabot to [verified or curated content](#verified-or-curated-content) narrows those recent items to verified, official, and [Library](../data-modeling/semantic-layer/library.md) content, but it doesn't confine them to the collection you picked. To control what data people can get to in an embed, set [data permissions](../permissions/embedding.md). See also [Set up AI chat in Metabase](../embedding/ai-chat.md#set-up-ai-chat-in-metabase).
### Prompt suggestions

View File

@@ -40,6 +40,10 @@ A REST API for building headless, agentic BI applications on top of Metabase's s
Connect MCP-compatible AI clients to your Metabase.
## [MCP server tools](./mcp-tools.md)
The tools an AI client can call through the MCP server, and the permission each one needs.
## [Metabot in Slack](./metabot-slack.md)
Chat with Metabot directly in Slack — ask questions, get charts, and manage subscriptions without leaving Slack.

6
docs/api.json generated
View File

@@ -32850,7 +32850,7 @@
"post" : {
"operationId" : "post-api-apps-slug-query-table-dependencies",
"summary" : "POST /api/apps/{slug}/query-table-dependencies",
"description" : "Return the tables read by already-saved queries, including ones reached only through an implicit join.\n\n Sync copies models, actions and metrics whose queries it never resolves through `/query`, and only\n this metadata-based lookup sees an implicit join: the id of a table reached through a foreign key\n appears nowhere in the query itself.",
"description" : "Return the tables read by already-saved queries, including ones reached only through an implicit join.\n\n Sync copies actions and metrics whose queries it never resolves through `/query`, and only\n this metadata-based lookup sees an implicit join: the id of a table reached through a foreign key\n appears nowhere in the query itself.",
"parameters" : [ {
"in" : "path",
"name" : "slug",
@@ -74289,7 +74289,7 @@
"description" : "Whether to include the entire metric library."
}, {
"in" : "query",
"name" : "include-models",
"name" : "include-actions",
"required" : false,
"schema" : {
"oneOf" : [ {
@@ -74298,7 +74298,7 @@
"type" : "null"
} ]
},
"description" : "Include models with actions. Database scope filters models; library scope does not. Without a scope, returns models only."
"description" : "Include query actions that belong to no model. Database scope filters them; library scope does not. Without a scope, returns actions only."
} ],
"responses" : {
"2XX" : {

View File

@@ -244,6 +244,8 @@ config:
llm-vllm-api-base-url: null
llm-vllm-api-key: null
llm-vllm-request-timeout-ms: 300000
llm-xai-api-base-url: https://api.x.ai/v1
llm-xai-api-key: null
llm-zai-api-base-url: https://api.z.ai/api/paas/v4
llm-zai-api-key: null
loading-message: doing-science

View File

@@ -1681,6 +1681,26 @@ Backed by the vllm connection in the admin AI settings provider list: reads and
Socket timeout in milliseconds for requests to your vLLM server.
### `MB_LLM_XAI_API_BASE_URL`
- Type: string
- Default: `https://api.x.ai/v1`
- [Configuration file name](./config-file.md): `llm-xai-api-base-url`
The xAI API base URL used for Chat Completions.
Backed by the xai connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection.
### `MB_LLM_XAI_API_KEY`
- Type: string
- Default: `null`
- [Configuration file name](./config-file.md): `llm-xai-api-key`
The xAI API Key.
Backed by the xai connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection.
### `MB_LLM_ZAI_API_BASE_URL`
- Type: string

View File

@@ -86,7 +86,7 @@ Once you're done here simply follow the link to **Take me to Metabase**. And if
If you click **Set up AI**, Metabase walks you through [connecting an AI provider](../ai/settings.md).
The providers you set up in AI settings power Metabase's built-in AI functionality, not the MCP server. With the MCP server, [your client provides the AI](../ai/mcp.md#with-the-mcp-server-your-client-provides-the-ai).
The providers you set up here power Metabase's built-in AI features. The [MCP server](../ai/mcp.md#turn-on-the-mcp-server) doesn't use them; your MCP client brings its own model.
## Getting started with Metabase

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -11,7 +11,7 @@ redirect_from:
_Data Studio > Connected data_
![Data structure](../images/data-structure.png)
![Connected data](../images/data-structure.png)
You can manage table settings and metadata to make it easier for people to work with your data in **Data Studio > Connected data**.

View File

@@ -30,7 +30,7 @@ Once you [create the Library](#create-the-library):
- A **Library** section appears in the navigation sidebar of the main app, listing the tables and metrics in your semantic layer.
- The data picker in the query builder defaults to showing tables and metrics from the Library to encourage people to use your vetted content.
- If you turn on Metabot's [Verified or curated content](../../ai/settings.md#verified-content) setting, Metabot will only use content that's verified, in an official collection, or published to the Library.
- If you turn on Metabot's [Verified or curated content](../../ai/settings.md#verified-or-curated-content) setting, Metabot will only use content that's verified, in an official collection, or published to the Library.
- You can [sync the Library to Git](#versioning-the-library) to version your semantic layer and glossary.
### Semantic layer

View File

@@ -55,7 +55,7 @@ To create a metric:
You can also create a new metric by going to **Browse > Metrics** in the navigation sidebar and clicking on **+**.
If you're creating a [Library metric](./library.md#metrics), you can also [navigate to the Library](./library.md) and click **+ New > Metric**.
If you're creating a [Library metric](./library.md#metrics), you can also go to [Data Studio > Semantic layer](./library.md) and click **+ New > Metric**.
2. Select your starting data. You can start from a table, saved question, model, or another metric.

View File

@@ -11,7 +11,7 @@ _Data Studio > Semantic layer_
Published tables are the tables you add to your [semantic layer](library.md#semantic-layer). They live in the **Data** collection.
Published tables also show up in the **Library** section of the main app's navigation sidebar. When people pick data for a new question, Metabase shows published tables first. You can also tell [Metabot](../../ai/metabot.md) to only use [curated content](../../ai/settings.md#verified-content) like published tables, and you can [sync published tables to Git](library.md#versioning-the-library), along with their metadata, segments, and measures.
Published tables also show up in the **Library** section of the main app's navigation sidebar. When people pick data for a new question, Metabase shows published tables first. You can also tell [Metabot](../../ai/metabot.md) to only use [curated content](../../ai/settings.md#verified-or-curated-content) like published tables, and you can [sync published tables to Git](library.md#versioning-the-library), along with their metadata, segments, and measures.
We use the word "publish" because the tables in your Library should be finished, polished tables. If your tables need to be cleaned or combined before they're ready for analytical queries, check out [transforms](../transforms/transforms-overview.md).

View File

@@ -39,11 +39,11 @@ With basic transforms, you can:
- **Metabase Cloud**: Basic transform functionality on Metabase Cloud - Starter, Pro, or Enterprise - comes with an additional small fee per successful transform run, see [Pricing](https://www.metabase.com/pricing).
Only people logged in with an email of a [Metabase Store admin](../../cloud/accounts-and-billing.md#add-managers) (not just Metabase _instance_ admins) can [enable basic transforms](./transforms-overview.md#enable-transforms).
Only admins can [enable basic transforms](./transforms-overview.md#enable-transforms).
### Cancel basic transforms
Once basic transforms are enabled on your Metabase Cloud instance, they can't be disabled.
Once basic transforms are enabled on your Metabase Cloud instance, you can't cancel the add-on. To stop paying for runs, an admin can [turn transforms off](./transforms-overview.md#turn-transforms-off).
## Advanced transforms

View File

@@ -9,7 +9,7 @@ redirect_from:
> Transform inspector requires the [Advanced transforms add-on](addons.md).
_Data Studio > Transforms > [transform name] > Inspect_
_Data Studio > Data transformation > [transform name] > Inspect_
The transform inspector gives you a diagnostic view of your transforms. Instead of writing your own SQL to check row counts, join match rates, or column distributions, you can open the **Inspect** tab and let Metabase analyze your transform's inputs and outputs for you. The inspector is especially useful for catching data quality issues (like unmatched rows in joins) before they cause problems downstream.

View File

@@ -7,7 +7,7 @@ redirect_from:
# Transform jobs
_Data Studio > Jobs_
_Data Studio > Data transformation > Jobs_
Jobs are scheduled runs of transforms based on the transform's tags.
@@ -20,7 +20,7 @@ Add tags to transforms so that you can use jobs to run the transforms on a sched
To add a tag to a transform:
1. Make sure you have [permissions to edit transforms](./transforms-overview.md#permissions-for-transforms).
2. Visit the transform in **Data studio > Transforms**.
2. Visit the transform in **Data Studio > Data transformation**.
3. On the **Settings** page for a transform, add transform tags.
By default, Metabase comes with hourly, daily, weekly, and monthly tags and jobs that are run on the corresponding schedules, but you can remove or rename those tags, or create new tags. To create a new tag, just type the new tag's name in **Tags** field (either when viewing a transform or when viewing a job) and select **Create a tag**.
@@ -29,8 +29,6 @@ Once you've tagged a transform, you can create a job that uses that tag to run t
## Jobs
_Data Studio > Jobs_
![Transform jobs](../images/jobs.png)
Jobs run one or more transforms on schedule based on transform tags.
@@ -44,15 +42,11 @@ Jobs will run all transforms tagged with any of the tags, plus any transforms th
You can see which transforms a job will run and in which order on the job's page.
### See all jobs
To see all jobs, go to **Data Studio** and click the **Jobs** at the bottom of the left sidebar.
### Create a job
To create a new job:
1. Go to **Data Studio > Jobs**
1. Go to **Data Studio > Data transformation > Jobs**
2. Click the **+ New** button in the top right.
3. Specify the schedule: select one of the built-in schedules or use cron syntax to specify a custom schedule,
@@ -62,17 +56,17 @@ To create a new job:
### Disable jobs
You can disable jobs without deleting them. Unlike deletion (which is permanent), disabling a job just means it won't run until you re-enable it. This is useful when you want to temporarily stop transforms from running - for example, for debugging purposes. This way you don't your lose configuration settings like tags and schedules.
You can disable a job without deleting it. A disabled job keeps its tags and schedule but won't run until you re-enable it, which is handy when you're debugging.
To disable a specific job:
1. Go to **Data studio > Jobs**.
1. Go to **Data Studio > Data transformation > Jobs**.
2. Find the job you want to disable and click the **three dots** icon to the right of the job's name.
3. Select **Disable**
To disable all jobs:
1. Go to **Data studio > Jobs**.
1. Go to **Data Studio > Data transformation > Jobs**.
2. Click the **three dots** icon above the table with all the jobs, and select **Disable all**.
![Disable all jobs](../images/disable-all-jobs.png)
@@ -81,18 +75,7 @@ Even if you disable all jobs, new jobs will still be created enabled by default.
### Re-enable jobs
If you [disabled any jobs](#disable-jobs), you can later re-enable them:
To re-enable a specific job:
1. Go to **Data studio > Jobs**.
2. Find the job you want to re-enable and click the **three dots** icon to the right of the job's name.
3. Select **Re-enable**
To re-enable all jobs:
1. Go to **Data studio > Jobs**.
2. Click the **three dots** icon above the table with all the jobs, and select **Re-enable all**.
To re-enable a job, follow the [steps for disabling jobs](#disable-jobs) and select **Re-enable** (or **Re-enable all**).
### Delete a job
@@ -102,7 +85,7 @@ Deleted jobs can't be restored. If you want to temporarily stop a job from runni
To delete a job:
1. Go to **Data Studio > Jobs**.
1. Go to **Data Studio > Data transformation > Jobs**.
2. Find the job you want to delete and click the **three dots** icon to the right of the job's name.
3. Select **Delete**.
@@ -121,15 +104,15 @@ Metabase skips a dependency when the dependency:
## See which transforms a job will run
The job's page in **Data Studio > Jobs** lists every transform the job will run and in which order. The **Notes** column tells you when Metabase will skip a dependency, and flags dependencies that have no schedule of their own.
The job's page in **Data Studio > Data transformation > Jobs** lists every transform the job will run and in which order. The **Notes** column tells you when Metabase will skip a dependency, and flags dependencies that have no schedule of their own.
## Runs
![Transform runs](../images/runs.png)
You can see all past and current transform runs (both manual and scheduled) by going to **Data Studio** and clicking on **Runs** at the bottom of the left sidebar. The transform run times will be given in Greenwich Mean Time (GMT).
You can see all past and current transform runs (both manual and scheduled) in **Data Studio > Data transformation > Runs**. Run times are in your Metabase server's timezone (hover the info icon next to **Started at** to see which).
You can click on any transform run to see more details about the run, like the error logs. To go to the transform definition from the transform run page, click on the icon next the transform name in the right sidebar.
You can click on any transform run to see more details about the run, like the error logs. To go to the transform definition from the transform run page, click on the icon next to the transform name in the right sidebar.
The "Tags" column in the **Runs** table will only show the transform's specific tags. But the run might not have anything to do with those tags. Another job with different tags could have run the transform because [jobs include all dependent transforms](#jobs-include-all-dependent-transforms).

View File

@@ -200,6 +200,6 @@ Then bring everything up and try a transform:
docker compose up -d
```
3. You'll need a Pro or Enterprise license with the [Advanced transforms add-on](addons.md). In Metabase, [enable transforms](transforms-overview.md#enable-transforms), then [create a Python transform](python.md#create-a-python-transform) and click **Run**.
3. You'll need a Pro or Enterprise license with the [Advanced transforms add-on](addons.md). In Metabase, [enable transforms](transforms-overview.md#enable-transforms), then [create a Python transform](python.md#create-a-python-transform) and click **Run now** on its **Run** tab.
4. Open **Data Studio > Jobs > Runs** and find your run. If it failed, the run's logs will usually tell you whether Metabase couldn't reach the runner, or the runner couldn't reach MinIO.
4. Open **Data Studio > Data transformation > Runs** and find your run. If it failed, the run's logs will usually tell you whether Metabase couldn't reach the runner, or the runner couldn't reach MinIO.

View File

@@ -37,7 +37,7 @@ To execute Python transforms, you'll need a _Python runner_ - a dedicated enviro
Once you've [set up the Python runner](#set-up-a-python-runner):
1. Go to **Data studio > Transforms**.
1. Go to **Data Studio > Data transformation**.
2. Click on **+ New** and select **Python script**.
3. Select a database that has the data you want to transform. See [Databases that support transforms](transforms-overview.md#databases-that-support-transforms).
@@ -95,7 +95,7 @@ If you have functions or classes you'd like to reuse across multiple transforms,
To add things to the common Python library:
1. Go to **Data studio > Transforms**.
1. Go to **Data Studio > Data transformation**.
2. Scroll to the very bottom of the transforms list and click on **Python library**.
3. Add a Python function or class.
@@ -120,10 +120,11 @@ To update a table incrementally, your data has to have a certain structure. See
To make a Python transform incremental:
1. Go to the transform's page in **Data studio > Transforms**.
1. Go to the transform's page in **Data Studio > Data transformation**.
2. Switch to **Settings** tab.
3. In **Field to check for new values**, select the field in one of the source tables that Metabase should scan to determine which records are new or changed. Only some fields are eligible. See [prerequisites for incremental transforms](./transforms-overview.md#prerequisites-for-incremental-transforms).
4. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
4. (Optional) To catch late-arriving rows, [set a lookback window](./transforms-overview.md#catch-late-arriving-rows-with-a-lookback-window).
5. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
## Current limitations of Python transforms

View File

@@ -28,9 +28,9 @@ For general information about Metabase transforms, see [Transforms](transforms-o
On Metabase Cloud, you need the [**Basic transforms** add-on](addons.md) to create query-based transforms.
1. [Enable transforms](transforms-overview.md#enable-transforms).
2. Go to **Data studio > Transforms**.
2. Go to **Data Studio > Data transformation**.
3. Click **+ New** and pick "Query builder", "SQL", or "Copy of existing question".
3. Click **+ New** and pick **Query builder**, **SQL query**, or **Copy of a saved question**.
Currently, you can't convert between different transform types (like converting a query builder transform to a SQL-based transform, or a SQL transform into a Python transform). If you want to change your transform built with the query builder into a SQL transform, you'll need to create a new transform with the same target and tags, and delete the old transform.
@@ -154,10 +154,11 @@ Once your query has table aliases, you can mark the transform as incremental usi
To make a query transform incremental:
1. Go to the transform's page in **Data studio > Transforms**.
1. Go to the transform's page in **Data Studio > Data transformation**.
2. Switch to **Settings** tab.
3. In **Field to check for new values**, select the field in one of the source tables that Metabase should scan to determine which records are new or changed. Only some fields are eligible. See [prerequisites for incremental transforms](./transforms-overview.md#prerequisites-for-incremental-transforms).
4. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
4. (Optional) To catch late-arriving rows, [set a lookback window](./transforms-overview.md#catch-late-arriving-rows-with-a-lookback-window).
5. (Optional) To update matching rows instead of appending new ones, [add a merge key](./transforms-overview.md#add-merge-keys-to-upsert-rows).
## Convert models to transforms
@@ -175,7 +176,7 @@ You must be an admin to convert models, and the model's database must [support t
To convert a model into a transform:
1. Review [Replacing data sources](../tools/replace-data-sources.md) docs for overview and limitations of the process.
2. Open **Data Studio** and select **Transforms** in the sidebar.
2. Go to **Data Studio > Data transformation**.
3. Click **Tools > Migrate models**.
4. Find the model you want to convert and click it to open its details panel. The panel shows the model's name, database, and collection, and a list of items that depend on it.
5. Click **Convert to a transform**.

View File

@@ -7,14 +7,12 @@ redirect_from:
# Transforms
_Data Studio > Transforms_
_Data Studio > Data transformation_
Transforms can be used to, well, transform your data - do stuff like preprocessing, cleaning, joining tables, pre-computing metrics. Transforms give you the ability to do the "T" of "ETL" within Metabase.
You'll write a query or a Python script in Metabase, a transform will run this query or script, create a table in your target database containing the results, and sync that table to Metabase, so it can be used as a data source for questions or other transforms.
![Transforms](../images/transforms.png)
## Transforms overview
- **Transforms** are queries (created either with SQL or the query builder) or Python scripts that write back to your database and create a new, persistent table. Use transforms to clean, join, or pre-aggregate data.
@@ -36,9 +34,9 @@ Currently, Metabase can create transforms on the following databases:
- Snowflake
- SQL Server
You can't create transforms on databases that have [Database routing](../../permissions/database-routing.md) enabled, or on Metabase's Sample Database.
You can't create transforms on databases that have [Database routing](../../permissions/database-routing.md) enabled, or on Metabase's Sample Database (which is SQLite).
Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../../databases/users-roles-privileges.md). We suggest using a [Writable connection](../../databases/writable-connection.md) for your database.
Transforms will create tables in your database, so the database user you use for your connection must have [appropriate privileges](../../databases/users-roles-privileges.md). We suggest using a [Writable connection](../../databases/writable-connection.md) for your database.
## Types of transforms
@@ -56,7 +54,7 @@ Permission configuration for transform depends on your plan.
- **Metabase Pro/Enterprise** comes with additional permission controls for transforms: a special [Data Analysts](../../people-and-groups/managing.md) group for non-Admins with potential transform access, and granular transform permissions for each database:
- To **see** the list of transforms on your instance, people need to be able to access Data Studio, so they need to be either an Admin or a member of the special [Data Analyst group](../../people-and-groups/managing.md).
- To **execute** transforms on a database, people need to be either Admins on belong to the special [Data Analyst group](../../people-and-groups/managing.md). Additionally people need to have the [Transform permissions](../../permissions/data.md) for that database.
- To **execute** transforms on a database, people need to be either Admins or belong to the [Data Analyst group](../../people-and-groups/managing.md). Data Analysts also need [Transform permissions](../../permissions/data.md) for that database.
## Set up transforms on a self-hosted Metabase
@@ -72,32 +70,27 @@ If you run Metabase yourself, here's the path to working transforms:
Before you can start writing transforms, you'll need to enable transforms in your Metabase instance.
If you're on a Metabase Cloud plan, only people logged in with an email of a [Metabase Store admin](../../cloud/accounts-and-billing.md#add-managers) (not just Metabase _instance_ admins) can enable basic transforms, because transforms incur a cost per run on Metabase Cloud.
If you're on a Metabase Cloud plan, only admins can enable basic transforms, because transforms incur a cost per run on Metabase Cloud.
To enable transforms:
1. Navigate to [**Data Studio**](../data-studio.md) by click the **grid icon** in top right corner of your Metabase and selecing **Data Studio**.
2. In Data Studio, click **Transforms** in the right sidebar.
3. If the transforms have not been enabled on your instance yet, you'll see a prompt to enable them. You can do just that.
1. Navigate to [**Data Studio**](../data-studio.md) by clicking the **grid icon** in the top right corner of your Metabase and selecting **Data Studio**.
2. Click **Data transformation**.
3. Click **Enable transforms**. On Metabase Cloud, you'll then need to agree to the Basic transforms add-on pricing.
Once you've enabled transforms, you can [configure permissions](#permissions-for-transforms) and start [creating transforms](#create-a-transform).
## See all transforms
### Turn transforms off
_Data Studio > Transforms_
_Data Studio > Settings_
See [permissions needed to see transforms](#permissions-for-transforms).
Admins can turn transforms off with the **Transforms** toggle. While transforms are off, **Data transformation** disappears from Data Studio and no transforms run, manually or on a schedule. Metabase keeps your transforms, jobs, and tables.
You can see all your Metabase's transforms:
1. Click the **grid** icon on top right and go to **Data Studio**.
2. In the left sidebar, select **Transforms**.
![Transforms](../images/transforms.png)
If you've set [`MB_TRANSFORMS_ENABLED`](../../configuring-metabase/environment-variables.md#mb_transforms_enabled), the environment variable wins: you can still flip the toggle, but it won't do anything.
## Create a transform
_Data Studio > Transforms_
_Data Studio > Data transformation_
> If you're using remote sync, you won't be able to create transforms if your instance is in ["read-only" sync mode](../../installation-and-operation/remote-sync.md).
@@ -107,7 +100,7 @@ To create a transform:
1. [Enable transforms](#enable-transforms).
2. Click the **grid** icon on top right and go to **Data Studio**.
3. In the left sidebar, select **Transforms**.
3. Click **Data transformation**.
4. Click **+ New** and select a source for your transform.
![New transform](../images/new_transform.png)
@@ -134,13 +127,11 @@ To create a transform:
- **Folder** (optional): The folder where the transform should live. Click on the field to pick a different folder or create a new one.
- **Incremental transformation** (optional): see [Incremental query-based transforms](query.md#incremental-query-transforms) or [Incremental Python transforms](python.md)
![Transform settings](../images/transform-settings.png)
7. Optionally, once the transform is saved, assign tags to your transform. Tags are used by [jobs](./jobs-and-runs.md) to run transforms on schedule.
## Edit a transform
_Data Studio > Transforms_
_Data Studio > Data transformation_
You can edit the transform's name and description, query/script, target table, and incremental settings. You can edit the transform even if it already ran or is scheduled to run.
@@ -150,13 +141,13 @@ If you're using remote sync, you won't be able to edit transforms if your instan
### Edit transform's query or script
_Data Studio > Transforms > Definition_
_Data Studio > Data transformation > [transform name] > Definition_
See [permissions to edit transforms](#permissions-for-transforms).
To edit the transform's query or script:
1. Go to **Data Studio > Transforms**.
1. Go to **Data Studio > Data transformation**.
2. Find the transform you'd like to edit and click on **Edit definition** above the transform definition.
3. Edit the query or script.
@@ -166,7 +157,7 @@ Once you change the transform's query or script, the next transform run (manual
### Edit transform's target
_Data Studio > Transforms > Settings_
_Data Studio > Data transformation > [transform name] > Settings_
To edit transform's target table, i.e., the table where the query results are written, go the transforms **Settings** tab and click on **Change target**. You'll need to select whether you want to keep the old target table, or delete it. Deletion can't be undone.
@@ -176,7 +167,7 @@ To edit transform's target table, i.e., the table where the query results are wr
You can run a transform manually or schedule the transform using tags and jobs.
- To run a transform manually, visit the transform in **Data Studio > Transforms > Runs** and click **Run**.
- To run a transform manually, open the transform in **Data Studio > Data transformation**, go to its **Run** tab, and click **Run now**.
- To schedule a transform, you'll need to assign one or more tags to it, then create a [scheduled job](./jobs-and-runs.md) that picks up those tags.
@@ -186,9 +177,31 @@ You can see the time and status of the latest transform run on the transform's p
For Python transforms, you'll also see the transform's execution logs.
## Add indexes to a transform's table
_Data Studio > Data transformation > [transform name] > Indexes_
Indexes can speed up queries on a transform's table. Metabase saves the indexes you add here and recreates indexes whenever the transform rebuilds its table. Indexes created directly in your database show as **Unmanaged**: you can't edit or delete them in Metabase, and they disappear the next time the transform rebuilds its table. To keep an index, add it here.
![Transform indexes tab](../images/index-tab.png)
To add an index:
1. Run the transform at least once, so its table exists.
2. On the transform's **Indexes** tab, click **Create index**.
3. Pick an index type and the columns to index. Types depend on your database: for example, B-Tree indexes on PostgreSQL, sort keys on Redshift, clustering keys on Snowflake, or clustering on BigQuery.
![Create an index](../images/create-index.png)
4. Run the transform (or wait for its next scheduled run).
Index edits and deletions take effect on the next run. On an incremental transform, the next run after _any_ index change _rebuilds the whole table_.
If Metabase can't create an index, the index shows as **Failed** and the run usually fails too. Every run retries the index until it succeeds, or until you edit or delete it. On an incremental transform, each of these runs rebuilds the whole table.
## Inspect a transform
_Data Studio > Transforms > [transform name] > Inspect_
_Data Studio > Data transformation > [transform name] > Inspect_
> Transform inspector requires the [Advanced transforms add-on](addons.md)
@@ -198,7 +211,7 @@ The [transform inspector](./inspector.md) lets you poke at the input and outputs
{% include plans-blockquote.html feature="Transform dependencies" is_plural=true%}
_Data Studio > Transforms > Dependencies_
_Data Studio > Data transformation > [transform name] > Dependencies_
![Transforms dependencies](../images/transform-dependencies.png)
@@ -206,13 +219,11 @@ Transform queries can use the data from other transforms, and query-based transf
Metabase will track transform dependencies and execute transforms in a reasonable order. So for example, if transform B relies on a table created by transform A, Metabase will run transform A first, then run transform B.
On Metabase Pro or Enterprise plans, you can see the transform dependencies graph by going to **Data Studio > Transforms** and go to the **Dependencies** tab.
If a job includes a transform that depends on a table created by another transform, then the job will run all the tagged transforms, plus any of their [dependencies that aren't already up to date](jobs-and-runs.md#jobs-include-all-dependent-transforms).
## Incremental transforms
_Data Studio > Transforms > Settings_
_Data Studio > Data transformation > [transform name] > Settings_
Incremental transforms only process the data that's new since the previous transform run. For example, you might have new transaction data coming in every day, and run the transform nightly. With each run, the incremental transform would only handle the rows added after the previous run the night before.
@@ -223,7 +234,7 @@ In this case, the checkpoint field decides which rows count as new, and the merg
### Prerequisites for incremental transforms
- There is a column in your data that Metabase can check for new values to determine which data is new. We'll refer to this as a "Checkpoint" column.
- The checkpoint column has to have increasing values, like a sequential ID or timestamp column. Metabase will determine what "new" data is by looking for values that are _greater than_ already-written checkpoint values.
- The checkpoint column has to have increasing values, like a sequential ID or timestamp column. Metabase will determine what "new" data is by looking for values that are _greater than_ already-written checkpoint values. Columns that only store a time of day can't be checkpoints, since their values start over every day.
- Your schema is stable, meaning that the structure of the tables is not going to change from run to run.
### Add merge keys to upsert rows
@@ -234,7 +245,7 @@ Why you'd want to add a merge key: some source tables get a new row each time a
To add a merge key:
1. Go to the transform's page in **Data studio > Transforms**.
1. Go to the transform's page in **Data Studio > Data transformation**.
2. Switch to the **Settings** tab and turn on **Only process new data**.
3. In **Merge key**, choose the columns that identify a record. If the target table already exists, you'll pick from a list of its columns. If the transform hasn't run yet, there's no table to pick from, so type each column name and press comma or enter. In that case, type the column's name in the database, which may differ from the display name you'd see in the list.
@@ -242,6 +253,22 @@ The merge key refers to columns in the _target_ table (the columns your transfor
Merge keys need a checkpoint field that increases every time a row is written, like an `updated_at` timestamp, otherwise the transform never sees the change.
### Catch late-arriving rows with a lookback window
If, for example, a row stamped 9:55 is added to a table _after_ a transform run has already processed everything through 10:00, later runs won't pick up the row. To catch late-arriving rows, set a lookback window, so each run also reprocesses some data it's already seen.
To set a lookback window:
1. Go to the transform's **Settings** tab and turn on **Only process new data**.
2. In **Field to check for new values**, pick a date or datetime column, like `updated_at`. The **Lookback window** field only shows up for date or datetime checkpoint columns, not number columns like IDs.
3. In **Lookback window**, enter a number and pick a unit, like 4 hours. Each run then also reprocesses rows from the four hours before the checkpoint. Date-only columns can't use units smaller than days.
![Lookback window](../images/lookback-window.png)
If you later switch the checkpoint to a column that doesn't support a lookback, Metabase clears the lookback window. If you switch to a date-only column, Metabase changes a minutes or hours unit to days, so a 4-hour window becomes 4 days.
You should pair a lookback window with a [merge key](#add-merge-keys-to-upsert-rows). Otherwise, every run appends the reprocessed rows again as duplicates.
### Reprocess all data to rebuild the target table
Once an incremental transform has run, its **Settings** tab shows **Last processed [checkpoint field]**, followed by the highest checkpoint value Metabase has written so far. That value is how Metabase knows where to pick up, since each run only handles rows past it.
@@ -256,7 +283,7 @@ Incremental transforms work differently for query-based transforms and Python tr
## Versioning transforms
_Admin > General > Remote sync_
_Admin > Settings > Remote sync_
{% include plans-blockquote.html feature="Versioning transforms" %}
@@ -265,8 +292,8 @@ You can check your transforms into git with [Remote Sync](../../installation-and
To enable git sync of transforms:
1. Go to Admin settings by clicking the **grid** icon in top right and select **Admin**.
2. On the **General** tab, pick **Remote sync** in the left sidebar.
3. Follow the steps to [Set up Remote Sync](../../installation-and-operation/remote-sync.md), and toggle "Transforms" sync on.
2. On the **Settings** tab, pick **Remote sync** in the left sidebar.
3. Follow the steps to [Set up Remote Sync](../../installation-and-operation/remote-sync.md), and toggle **Transforms** sync on.
Keep in mind that this setting only controls whether transforms are checked _into_ the git repo. The transform sync setting does _not_ affect how the instance behaves in read-only mode. If your instance is in read-only mode, you will not be able to create or edit transforms.

View File

@@ -178,7 +178,7 @@ The first rule of frontend style, is we want to avoid talking about frontend sty
We use [oxfmt](https://oxc.rs/) to format our JavaScript and TypeScript code, and it is enforced by CI. We recommend setting your editor to "format on save". You can also format code using `bun run format`, and verify it has been formatted correctly using `bun run lint-format-pure`. Oxfmt also sorts imports, so `bun run format` fixes import order too.
We use [oxlint](https://oxc.rs/) to enforce JavaScript and TypeScript lint rules, including module boundaries. Run `bun run lint-oxlint` to lint, or `bun run lint-oxlint-fix` to apply lint fixes. Both commands build the CLJS output first. If you've already built it, `bun run lint-oxlint-pure` skips that step. For how the lint configuration works, see `frontend/lint/OXLINT.md`.
We use [oxlint](https://oxc.rs/) to enforce JavaScript and TypeScript lint rules, including module boundaries. Run `bun run lint-oxlint` to lint, or `bun run lint-oxlint-fix` to apply lint fixes. For how the lint configuration works, see `frontend/lint/OXLINT.md`.
### Miscellaneous notes on coding style

View File

@@ -82,6 +82,7 @@ These components are available via the `InteractiveQuestion` namespace (like `<I
- [InteractiveQuestion.QuestionSettings](./sdk/api/InteractiveQuestion.html#questionsettings)
- [InteractiveQuestion.QuestionSettingsDropdown](./sdk/api/InteractiveQuestion.html#questionsettingsdropdown)
- [InteractiveQuestion.QuestionVisualization](./sdk/api/InteractiveQuestion.html#questionvisualization)
- [InteractiveQuestion.RefreshButton](./sdk/api/InteractiveQuestion.html#refreshbutton)
- [InteractiveQuestion.ResetButton](./sdk/api/InteractiveQuestion.html#resetbutton)
- [InteractiveQuestion.SaveButton](./sdk/api/InteractiveQuestion.html#savebutton)
- [InteractiveQuestion.SaveQuestionForm](./sdk/api/InteractiveQuestion.html#savequestionform)

File diff suppressed because one or more lines are too long

View File

@@ -92,6 +92,7 @@ type IconName =
| "connections"
| "contract"
| "copy"
| "corner_down_right"
| "corner_up_right"
| "curved"
| "database"

View File

@@ -30,7 +30,7 @@ Metabase will keep track of a version each time you save, move, revert, [move to
### Viewing tracked changes
1. Go to your transform in [Data studio](../data-modeling/transforms/transforms-overview.md).
1. Go to your transform in [Data Studio > Data transformation](../data-modeling/transforms/transforms-overview.md).
2. Click the **three dots** icon and select **History**
3. The History tab will display the item's history of up to 15 versions.
@@ -38,12 +38,12 @@ Any change to the transform's query or script will be recorded as "changed sourc
### Reverting to previous versions
1. Go to your transform in [Data studio > Transforms](../data-modeling/transforms/transforms-overview.md).
1. Go to your transform in [Data Studio > Data transformation](../data-modeling/transforms/transforms-overview.md).
2. Click the **three dots** icon and select **History**.
3. Click the **back arrow** beside a version to revert your item to that point in time.
## History for segments and measures
1. Go to the table source for the segment or measure in [Data Studio > Tables](../data-modeling/metadata/managing-tables.md).
1. Go to the table source for the segment or measure in [Data Studio > Connected data](../data-modeling/metadata/managing-tables.md).
2. Pick the segment or measure in the right sidebar.
3. On the segment or measure's page, go to **Revision history** tab.

View File

@@ -89,6 +89,10 @@ Options:
Transfer data from existing H2 database to the newly created MySQL or Postgres DB specified by env vars.
## `mcp-tools-documentation`
Generates `docs/ai/mcp-tools.md`, the reference for the tools Metabase's MCP server exposes to AI clients.
## `migrate direction`
Run database migrations. Valid options for `direction` are `up`, `force`, `down`, `down-force`, `print`, or `release-locks`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

View File

@@ -10,11 +10,10 @@ description: Version control your dashboards, questions, and transforms. Sync yo
Remote Sync lets you develop analytics content in your Metabase and automatically deploy it to a read-only production Metabase through Git. Remote Sync can sync:
- Top-level collections (dashboards, questions, models, metrics)
- Library content (published tables and their segments and measures)
- Snippets
- Library content (published tables with their segments and measures, plus metrics, snippets, and the glossary)
- Transforms
Metabase doesn't sync any of your data. What it stores in Git are [YAML files](./serialization.md#example-of-a-serialized-question) describing your analytics content. Your actual data stays in your databases and never leaves your Metabase.
Metabase doesn't sync any of your data. All Metabase stores in Git are [YAML files](./serialization.md#example-of-a-serialized-question) that describe your analytics content. Your actual data stays in your databases and never leaves your Metabase.
## How Remote Sync works
@@ -28,6 +27,12 @@ Here's a basic remote-sync workflow:
We'll cover [setting up Remote Sync](#setting-up-remote-sync), an [example dev-to-production workflow](#an-example-dev-to-production-workflow), [branch management](#branch-management), and some other odds and ends.
## Treat write access to your repository like admin access
When a Metabase pulls from your repository, it loads the YAML files on its branch _without_ checking who wrote them. Anyone who can push to that branch can change that Metabase's synced content. If you turn on [auto-sync](#pulling-changes-automatically), changes merged to the branch show up in your Metabase within minutes, and no one in Metabase approves them first.
Your repository can't change users, groups, or permissions, because those [don't sync](#what-metabase-syncs). But when a pull creates a new collection, that collection gets the same permissions as its parent. A new top-level collection gets the same permissions as **Our analytics**.
## Key concepts
### Remote Sync has two modes for different roles
@@ -90,6 +95,8 @@ Before you connect Metabase to your Git repository, create a new repository. Sup
Initialize the repo with a README.md.
Anyone who can push to this repository can change content in your synced Metabases, so [limit who has write access](#treat-write-access-to-your-repository-like-admin-access).
### 2. Create a token for your read-and-write Metabase
> Skip this step if you're using a [local repo](#local-file-repositories).
@@ -138,21 +145,21 @@ In the Metabase instance that you use for development:
6. (Optional) If you have [multi-tenant user strategy enabled](../embedding/tenants.md#enable-multi-tenant-strategy), you can also choose which [shared collection](../embedding/tenants.md#changing-tenant-strategy) to sync.
> If you only want to sync your Library and transforms, you can set up remote sync from Data Studio instead: click **Set up remote sync** at the bottom of the Data Studio sidebar.
### 4. Select what to sync
Select what you [want to sync](#you-choose-what-to-sync).
### 5. Push your changes to your repository
![Push your changes](./images/push-changes.png)
Once you've added content, you'll see a yellow dot on the synced collections indicating uncommitted changes.
1. At the top of the screen, choose which branch to push to and click the up arrow (push) icon.
1. At the top of the screen, click the branch name and select **Push changes**.
2. Enter a commit message describing your changes (e.g., "Added dashboard on mammoth populations").
2. (Optional) Describe your changes (e.g., "Added dashboard on mammoth populations").
3. Click "Push changes" to commit and push your changes to your repository.
3. Click **Push changes** to commit and push your changes to your repository.
Check your repository; you should see the collection.
@@ -199,8 +206,8 @@ In your production Metabase instance:
- Click "Save changes". Metabase will verify it can reach your repository. If the connection fails, verify your token has the appropriate permissions and hasn't expired.
6. Sync your content:
- Click "Pull changes" to immediately sync content from your repository.
- To keep your production instance automatically updated, toggle on "Auto-sync with Git". Metabase will pull changes from your main branch every five minutes.
- Click "Pull changes now" to immediately sync content from your repository.
- To keep your production instance automatically updated, toggle on "Auto-sync with git". Metabase will pull changes from your main branch every five minutes.
In Read-only mode, synced collections appear in the regular collections list with a special icon to indicate that they're versioned and read-only.
@@ -267,7 +274,7 @@ Let's say your team wants to build a new analytics dashboard. Here's a workflow
### Step 1: Create a new branch
In your development Metabase, click the branch dropdown at the top of the screen to [create a new branch](#branch-management) for your work, like `feature/megafauna-dashboard`.
In your development Metabase, go to **Admin** > **Settings** > **Remote sync** and [create a new branch](#creating-a-branch) for your work, like `feature/megafauna-dashboard`.
### Step 2: Create content in your development Metabase
@@ -275,10 +282,7 @@ Create a dashboard called "Megafauna Analytics" and add some questions. Save the
### Step 3: Push to your development branch
1. You should see a yellow dot on your synced collection (indicating local changes).
2. Click the up arrow (push) icon at the top of the screen.
3. Enter a commit message: "Add Megafauna Analytics dashboard".
4. Metabase commits your changes to the branch you're working on and pushes them to your repo.
[Push your changes](#committing-and-pushing-your-changes) to `feature/megafauna-dashboard` with a commit message like "Add Megafauna Analytics dashboard".
### Step 4: Create a pull request
@@ -304,14 +308,12 @@ On your production Metabase instance:
### Synced collections in the UI
You can select any top-level collection under Our Analytics to sync with Git. Synced collections show their current state with visual indicators: a yellow dot indicates unsynced local changes that need to be committed. At the top of the screen, you'll see up/down arrows that provide sync controls for pulling and pushing changes.
You can select any top-level collection under Our Analytics to sync with Git. Synced collections show their current state with visual indicators: a yellow dot indicates unsynced local changes that need to be committed. At the top of the screen (in Data Studio, at the top of the sidebar), click the branch name to push or pull changes.
If you are using [tenants](../embedding/tenants.md), you can also sync shared collections. You'll see the same yellow dot indicator for shared collections.
In Read-only mode, synced collections appear in the regular collections list (not in a separate "Synced Collections" section) with a special icon to indicate they're versioned and read-only.
When transforms syncing is enabled, you'll find your transforms in the Transforms section of the Admin settings. Synced transforms are also read-only in Read-only mode.
### Moving and deleting content in synced collections
When you remove content from a synced collection in Read-write mode and push that change, the content will also be removed from your production instance when it syncs. This applies to moving content out of a synced collection or deleting it entirely.
@@ -361,7 +363,7 @@ Remote Sync uses the same serialization format as the [Metabase CLI serializatio
- Documents
- Timelines and events
- Collection structure and metadata
- Library content (published tables, metrics, snippets, segments, measures)
- Library content (published tables, metrics, snippets, segments, measures, glossary)
- Transforms (including jobs and folders)
**What doesn't sync:**
@@ -378,48 +380,42 @@ Branching is only available in Read-write mode.
### Creating a branch
You can create branches in Metabase or directly in your Git repository. Branches created in Git will appear in the Metabase branch dropdown once Metabase syncs with your repository.
You can create branches in Metabase or directly in your Git repository. Branches created in Git will appear in Metabase once Metabase syncs with your repository.
Before creating branches, push an initial commit to your main branch.
To create a new branch in Metabase:
1. Click the branch dropdown at the top of the screen.
2. Type a name for the new branch in the search box.
3. Press Enter to create the branch.
1. Go to **Admin** > **Settings** > **Remote sync**.
2. Under **Sync branch**, click the current branch.
3. Type a name for the new branch and click **Create branch "[name]"**.
The new branch is created from your current commit (not the latest commit from the remote).
The new branch is created from your current commit (not the latest commit from the remote), and Metabase switches to it.
### Switching branches
At the top of the screen, you'll see a branch dropdown:
![Choose branches from the dropdown](./images/branch-dropdown.png)
1. Click the branch dropdown to see available branches.
2. Select a different branch. This will change the branch for everyone in your Metabase (see [limitations](#branching-limitations)).
1. Go to **Admin** > **Settings** > **Remote sync**.
2. Under **Sync branch**, click the current branch and select a different branch. This will change the branch for everyone in your Metabase (see [limitations](#branching-limitations)).
If the branch doesn't appear, ensure it exists in your Git repository and that the name matches exactly (branch names are case-sensitive).
If you have unsynced changes, Metabase will show a dialog asking what you want to do:
If you have unsynced changes, Metabase lists them and asks what to do:
- **Push changes to the current branch:** Commits your changes to the current branch before switching.
- **Create a new branch and push changes there:** Saves your work to a new branch, keeping the original branch clean.
- **Discard these changes:** Throws away your uncommitted changes (can't be undone).
- **Push changes to the current branch, [branch]:** Commits your changes to the current branch. You stay on that branch, so pick the other branch again afterward.
- **Create a new branch and push changes there:** Commits your changes to a new branch and switches you to it.
- **Delete unsynced changes (can't be undone):** Throws away your uncommitted changes and switches you to the branch you picked.
The dialog shows you exactly which items have changed, so you can make an informed decision.
If you switch modes (from Read-write to Read-only or vice versa) with unpushed changes, you'll be prompted to save or discard them. You cannot switch to Read-only mode with uncommitted changes.
If changes don't appear after switching modes: Hard refresh your browser (Cmd/Ctrl + Shift + R).
You can't switch to Read-only mode while you have unpushed changes.
### Branching limitations
- **Only admins can manage branches**. Non-admins won't see the branch dropdown at all.
- **Only admins can manage branches**. Non-admins don't see the branch controls.
- **The current branch is instance-wide**. This means that everyone on the same Metabase is on the same branch at the same time. You can't have different people working on different branches in a single Metabase.
Limiting a Metabase to a single branch at a time means that the typical Git feature-branch workflow is a bit different here: only admins can switch branches, create new branches, or push and pull changes. If you need multiple branches worked on simultaneously, the workaround is to spin up multiple Metabases in Read-write mode, one Metabase per branch.
These limits only apply in Metabase. In your Git repository, anyone with write access can create branches and push changes, and Metabase loads those changes when it pulls. See [Treat write access to your repository like admin access](#treat-write-access-to-your-repository-like-admin-access).
## Pushing changes to Git
You can only push changes in a Metabase with Remote Sync set to Read-write mode.
@@ -428,22 +424,19 @@ You can only push changes in a Metabase with Remote Sync set to Read-write mode.
When you make changes to items in a synced collection, a yellow dot appears on your synced collection (indicating uncommitted changes). To commit and push your changes:
1. Click the up arrow (push) icon at the top of the screen (make sure you're pushing to the right branch).
2. Enter a descriptive commit message explaining your changes.
3. Click "Continue" to push your changes to Git.
1. At the top of the screen, click the branch name and select **Push changes**.
2. (Optional) Describe your changes.
3. Click **Push changes** to push your changes to Git.
If you see a message that "Remote is ahead of local", that means someone else pushed to the branch from another Metabase in Read-write mode. Pull the latest changes before pushing again.
If the branch has new commits since you last pulled, Metabase asks how to handle them before it pushes yours: merge them with yours (when they don't conflict), push your changes to a new branch, or force push over them.
## Pulling changes from Git
You can pull changes when in Read-write or Read-only mode.
In Read-write mode, you can get the latest changes from your Git repository:
In Read-write mode, click the branch name at the top of the screen and select **Pull changes**. Metabase imports the latest content from Git.
1. Click the down arrow (pull) icon at the top of the screen.
2. Review any summary of incoming changes if shown.
3. Confirm the import.
4. Metabase updates your collections with the latest content from Git.
In Read-only mode, go to **Admin** > **Settings** > **Remote sync** and click **Pull changes now**.
If changes don't appear after pulling:
@@ -451,15 +444,14 @@ If changes don't appear after pulling:
- Hard refresh your browser (Cmd/Ctrl + Shift + R).
- If you encounter sync errors, review error messages in the sync dialog, manually resolve conflicts in your Git repository, then pull again.
In Read-only mode, go to **Admin** > **Settings** > **Remote sync** and click **Pull changes**.
### Handling unsynced changes when you pull
### Handling unsynced changes
If you have local uncommitted changes when you pull, Metabase lists them and asks what to do:
If you have local uncommitted changes when trying to pull or switch branches, Metabase will prompt you with options:
- **Push changes to the current branch:** Commit your changes first, then proceed.
- **Create a new branch and push changes there:** Preserve your work on a new branch.
- **Discard these changes:** Throw away your uncommitted changes to accept what's in Git.
- **Merge the remote changes into your local content:** Only offered when the changes merge cleanly.
- **Create a new branch and push changes there:** Saves your work to a new branch.
- **Force push to [branch], discarding and overwriting everything (can't be undone):** Overwrites the remote branch with your local content.
- **Delete unsynced changes (can't be undone):** Throws away your uncommitted changes to accept what's in Git.
When in doubt, create a new branch and push changes to that branch. That way you won't lose any work.
@@ -468,7 +460,7 @@ When in doubt, create a new branch and push changes to that branch. That way you
In Read-only mode, you can set Metabase to auto-sync changes from your main branch.
1. Navigate to **Admin** > **Settings** > **Remote sync**.
2. Enable Auto-sync with Git.
2. Enable **Auto-sync with git**.
By default, Metabase will check for and pull changes from the branch you specify every five minutes. You can also manually sync as needed.

View File

@@ -306,7 +306,7 @@ Type: Boolean.
Default: Data model is included.
Excludes [table metadata](../data-modeling/metadata/managing-tables.md) settings, which admins define in **Data Studio > Data structure**. Excluding the data model is useful for subsequent exports where you only want content changes.
Excludes [table metadata](../data-modeling/metadata/managing-tables.md) settings, which admins define in **Data Studio > Connected data**. Excluding the data model is useful for subsequent exports where you only want content changes.
### Include field values

View File

@@ -40,7 +40,7 @@ To return to the metric's home page, click the metric card in the search bar and
To open a **measure** in the metrics explorer:
1. Navigate to the measure in **Data studio > Tables > [Your table] > Measures**.
1. Navigate to the measure in **Data Studio > Connected data > [Your table] > Measures**.
2. Select your measure.
3. On the measure's page, click **three dots** next to the measure's name and select **Explore**.

View File

@@ -138,6 +138,8 @@ categories:
url: "ai/settings"
- name: "MCP server"
url: "ai/mcp"
- name: "MCP tools"
url: "ai/mcp-tools"
- name: "Agent API"
url: "ai/agent-api"
- name: "Agent-driven development"

View File

@@ -0,0 +1,18 @@
description: null
archived: false
slug: stub_database_collection
archive_operation_id: null
name: Stub Database Collection
personal_owner_id: null
type: null
is_sample: false
parent_id: null
serdes/meta:
- model: Collection
id: StubDbCollection12345
label: stub_database_collection
archived_directly: null
entity_id: StubDbCollection12345
namespace: null
created_at: '2026-10-05T09:00:00.000000-07:00'
is_remote_synced: true

View File

@@ -0,0 +1,58 @@
description: null
archived: false
collection_position: null
source_card_id: null
table_id:
- Stub Postgres
- public
- products
result_metadata: null
card_schema: 22
database_id: Stub Postgres
enable_embedding: false
collection_id: StubDbCollection12345
query_type: query
name: Stub Database Question
document_id: null
type: question
creator_id: admin@metabase.test
made_public_by_id: null
embedding_params: null
dataset_query:
database: Stub Postgres
type: query
query:
source-table:
- Stub Postgres
- public
- products
fields:
- - field
- - Stub Postgres
- public
- products
- title
- null
order-by:
- - asc
- - field
- - Stub Postgres
- public
- products
- id
- null
limit: 1
parameter_mappings: []
serdes/meta:
- model: Card
id: StubDbQuestion1234567
label: stub_database_question
display: table
archived_directly: false
entity_id: StubDbQuestion1234567
collection_preview: true
visualization_settings: {}
parameters: []
dashboard_id: null
created_at: '2026-10-05T09:00:00.000000-07:00'
public_uuid: null

View File

@@ -6,6 +6,7 @@ import type {
CollectionPermission,
CollectionPermissionsGraph,
DataApp,
Group,
WritebackAction,
} from "metabase-types/api";
@@ -33,7 +34,7 @@ export const fakeDataApp = (overrides: Partial<DataApp> = {}): DataApp => ({
bundle_path: "dist/index.js",
enabled: true,
draft: false,
resource_collection_id: null,
resource_collection_id: 1,
permission_group_id: null,
table_ids: [],
allowed_hosts: [],
@@ -178,23 +179,47 @@ export function dataAppIframe(displayName: string) {
return getIframeBody(`iframe[title="${displayName}"]`);
}
/**
* Sets every non-admin group's access to a collection through the permission graph of
* the collection's namespace: an app's own collection lives in `data-apps`, whose graph
* is separate from the default one. A namespace graph lists only the groups holding a
* grant in it, so the groups come from the groups API, not from the graph.
*/
export function setDataAppCollectionAccess(
collectionId: CollectionId,
access: CollectionPermission,
) {
return cy
.request<CollectionPermissionsGraph>("GET", "/api/collection/graph")
.then(({ body: graph }) => {
const groups = Object.fromEntries(
Object.entries(graph.groups).map(([groupId, collections]) => [
groupId,
Number(groupId) === USER_GROUPS.ADMIN_GROUP
? collections
: { ...collections, [collectionId]: access },
]),
);
.request<Collection>("GET", `/api/collection/${collectionId}`)
.then(({ body: collection }) => {
const namespace = collection.namespace ?? undefined;
const graphUrl =
namespace === undefined
? "/api/collection/graph"
: `/api/collection/graph?namespace=${namespace}`;
cy.request("PUT", "/api/collection/graph", { ...graph, groups });
cy.request<Group[]>("GET", "/api/permissions/group").then(
({ body: allGroups }) => {
cy.request<CollectionPermissionsGraph>("GET", graphUrl).then(
({ body: graph }) => {
const groups = Object.fromEntries(
allGroups
.filter((group) => group.id !== USER_GROUPS.ADMIN_GROUP)
.map((group) => [
group.id,
{ ...graph.groups[group.id], [collectionId]: access },
]),
);
cy.request("PUT", "/api/collection/graph", {
...graph,
...(namespace === undefined ? {} : { namespace }),
groups,
});
},
);
},
);
});
}

View File

@@ -155,7 +155,6 @@ export const DataModel = {
getReplaceButton: getSourceReplacementReplaceButton,
getCancelButton: getSourceReplacementCancelButton,
getTargetPickerButton: getSourceReplacementTargetPickerButton,
getDependentsTab: getSourceReplacementDependentsTab,
getFindAndReplaceButton: getSourceReplacementFindAndReplaceButton,
},
};
@@ -984,12 +983,6 @@ function getSourceReplacementTargetPickerButton() {
);
}
function getSourceReplacementDependentsTab(count: number) {
return getSourceReplacementModal().findByRole("tab", {
name: new RegExp(`${count} items? will be changed`),
});
}
function getSourceReplacementFindAndReplaceButton() {
return cy.findByRole("menuitem", { name: /Find and replace/ });
}

View File

@@ -119,10 +119,13 @@ export function downloadAndAssert({
expect(res.statusCode).to.eq(200);
expect(res.headers["content-type"]).to.include(expectedContentType);
if (assertParameters && req.body?.parameters) {
expect(req.body.parameters).to.have.lengthOf(assertParameters.length);
if (assertParameters) {
const parameters = JSON.parse(
new URLSearchParams(req.body).get("parameters") ?? "null",
);
expect(parameters).to.have.lengthOf(assertParameters.length);
assertParameters.forEach((expectedParam, index) => {
expect(req.body.parameters[index]).to.deep.include(expectedParam);
expect(parameters[index]).to.deep.include(expectedParam);
});
}

View File

@@ -52,25 +52,6 @@ export function selectAllMetricsTab(): void {
cy.findByRole("dialog").findByRole("tab", { name: "All" }).click();
}
export interface AddMetricsToExplorationOptions {
metrics: string[];
}
/**
* Pick metrics through the "+ Metrics" picker.
*/
export function addMetricsToExploration({
metrics,
}: AddMetricsToExplorationOptions): void {
cy.findByRole("button", { name: /Metrics/ }).click();
cy.wait("@getDimensions");
selectAllMetricsTab();
for (const name of metrics) {
cy.findByRole("checkbox", { name }).check({ force: true });
}
cy.findByRole("button", { name: "Add" }).click();
}
/**
* Pick the named timelines (one or many) through the "+ Events" modal.
*/

View File

@@ -21,6 +21,9 @@ export const SYNCED_TRANSFORMS_COLLECTION_FIXTURE_PATH =
"/e2e/support/assets/example_synced_transforms_collection";
export const SYNCED_LIBRARY_FIXTURE_PATH =
Cypress.config("projectRoot") + "/e2e/support/assets/example_synced_library";
export const SYNCED_STUB_DATABASE_FIXTURE_PATH =
Cypress.config("projectRoot") +
"/e2e/support/assets/example_synced_stub_database";
// Copy the sample synced collection from the fixture folder to the working directory
export const copySyncedCollectionFixture = () => {
@@ -44,6 +47,14 @@ export const copySyncedLibraryFixture = () => {
});
};
// Copy a synced collection whose card references a database the instance doesn't have
export const copySyncedStubDatabaseFixture = () => {
cy.task("copyDirectory", {
source: SYNCED_STUB_DATABASE_FIXTURE_PATH,
destination: LOCAL_GIT_PATH,
});
};
export const checkoutSyncedCollectionBranch = (branch: string) => {
cy.exec("git -C " + LOCAL_GIT_PATH + ` checkout -b '${branch}'`);
};

View File

@@ -43,9 +43,9 @@ export function expectSearchResultContent({
}
if (expectedSearchResult.collection) {
cy.findAllByTestId("result-link-wrapper").first(() => {
cy.findByText(expectedSearchResult.collection).should("exist");
});
cy.findAllByTestId("result-link-wrapper")
.first()
.should("contain.text", expectedSearchResult.collection);
}
if (expectedSearchResult.timestamp) {
cy.findByTestId("revision-history-text").findByText(

View File

@@ -111,21 +111,38 @@ describe("Embedding SDK: data-app dev diagnostics", () => {
});
it("serves the report to shell agents, with cursor filtering", () => {
// Retry the full read-then-cursor pair until no POST sneaks between
// the two GETs. Under CPU throttling the reporter's batched timer can
// jitter enough to land a POST in that gap, so a single-pass idle
// guard before the first GET is not enough.
const verifyStableCursor = (attempt = 0): Cypress.Chainable<void> => {
return cy.request(DIAGNOSTICS_URL).then(({ body: report }) => {
expect(report.manifest?.errors).to.have.length(0);
expect(report.clients).to.be.gte(1);
return cy
.request(`${DIAGNOSTICS_URL}?startEventId=${report.nextEventId}`)
.then(({ body: filtered }) => {
if (filtered.entries.length === 0) {
return;
}
if (attempt >= 20) {
throw new Error("Cursor never stabilized");
}
return cy
.wait(250, { log: false })
.then(() => verifyStableCursor(attempt + 1));
});
});
};
readDiagnosticsUntil(
DIAGNOSTICS_URL,
"a blocked-network entry and a healthy connection",
(report) =>
report.entries.some((entry) => entry.kind === "blocked-network") &&
report.connection?.reachable === true,
).then((report) => {
expect(report.manifest?.errors).to.have.length(0);
expect(report.clients).to.be.gte(1);
// A reader that has consumed everything sees an empty page, not a replay.
cy.request(`${DIAGNOSTICS_URL}?startEventId=${report.nextEventId}`)
.its("body.entries")
.should("be.empty");
});
).then(() => verifyStableCursor());
});
it("keeps the buffer across a page reload, with continuous event ids", () => {

View File

@@ -300,7 +300,6 @@ function assertSuccessfullUpdateToast() {
H.undoToastList()
.last()
.should("be.visible")
.should("have.attr", "color", "feedback-positive")
.should("contain.text", "Successfully updated");
}
@@ -310,7 +309,6 @@ function assertSuccessfullDeleteToast() {
H.undoToastList()
.last()
.should("be.visible")
.should("have.attr", "color", "feedback-positive")
.should("contain.text", "Successfully deleted");
}

View File

@@ -285,6 +285,9 @@ describe(
beforeEach(() => {
cy.intercept("GET", "/api/card/*").as("getModel");
cy.intercept("GET", "/api/card/*/query_metadata").as(
"getModelQueryMetadata",
);
cy.intercept("GET", "/api/action/*").as("getAction");
cy.intercept("PUT", "/api/action/*").as("updateAction");
@@ -318,6 +321,10 @@ describe(
cy.wait("@getModel");
});
// The Actions menu renders only once the source table's metadata has
// arrived, because it is gated on `supportsImplicitActions()`.
cy.wait("@getModelQueryMetadata");
cy.findByTestId("model-actions-header")
.findByLabelText("Actions")
.click();

View File

@@ -1,6 +1,6 @@
import yaml from "js-yaml";
import { WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { QA_POSTGRES_PORT, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
ADMIN_PERSONAL_COLLECTION_ID,
@@ -8,6 +8,7 @@ import {
} from "e2e/support/cypress_sample_instance_data";
import type {
Collection,
Database,
RemoteSyncDependencyErrorResponse,
} from "metabase-types/api";
@@ -31,6 +32,8 @@ const MONTHLY_DEPENDENT_NAME = "Monthly Summary";
const SNIPPET_NAME = "active_users";
const SNIPPET_QUESTION_NAME = "Snippet Dependent Question";
const PERSONAL_QUESTION_NAME = "Personal Source Question";
const STUB_DATABASE_NAME = "Stub Postgres";
const STUB_QUESTION_NAME = "Stub Database Question";
const setup = (snapshot = "default") => {
H.restore(snapshot);
@@ -1289,6 +1292,64 @@ describe("Remote Sync", () => {
});
});
});
describe("stub databases", { tags: ["@external"] }, () => {
beforeEach(() => {
setup();
});
it("imports a card on a missing database as a stub that runs once connected", () => {
H.copySyncedStubDatabaseFixture();
H.commitToRepo();
H.configureGitAndPullChanges("read-write");
cy.log("the missing database is created as a stub");
cy.visit("/admin/databases");
cy.findByRole("link", { name: STUB_DATABASE_NAME })
.closest("tr")
.findByText("Stubbed")
.should("be.visible");
cy.findByRole("link", { name: STUB_DATABASE_NAME }).click();
cy.findByTestId("database-connection-info-section")
.findByText(
"This database has placeholder connection details. Replace that with actual connection details to make this connection Active.",
)
.should("be.visible");
cy.log("connect the stub database");
cy.intercept("PUT", "/api/database/*").as("updateDatabase");
cy.button("Edit connection details").click();
cy.findByTestId("database-form").within(() => {
cy.findByLabelText(/Host/).type("localhost");
cy.findByLabelText(/Port/).type(String(QA_POSTGRES_PORT));
cy.findByLabelText(/Database name/).type("sample");
cy.findByLabelText(/Username/).type("metabase");
cy.findByLabelText(/Password/).type("metasample123");
});
cy.button("Save changes").click();
cy.wait("@updateDatabase");
cy.request<{ data: Database[] }>("GET", "/api/database").then(
({ body }) => {
const database = body.data.find(
({ name }) => name === STUB_DATABASE_NAME,
);
expect(database?.is_stub).to.equal(false);
H.waitForSyncToFinish({
dbId: database?.id,
tableName: "products",
tableAlias: "productsTable",
});
},
);
cy.log("the imported card now runs against the connected database");
cy.visit("/collection/root");
H.goToSyncedCollection("Stub Database Collection");
H.collectionTable().findByText(STUB_QUESTION_NAME).click();
H.tableInteractive().findByText("Rustic Paper Wallet").should("exist");
});
});
});
const ensureSyncedCollectionIsVisible = () => {

View File

@@ -174,9 +174,11 @@ describe("scenarios > dashboard > chained filter", () => {
});
});
}
});
describe("scenarios > dashboard > chained filter > UUID primary key", () => {
it(
"should work for all field types (metabase#15170)",
"should offer a UUID primary key column for an ID filter in 'Update a dashboard filter' click behavior (metabase#15170)",
{ tags: "@external" },
() => {

View File

@@ -1,32 +1,22 @@
const { H } = cy;
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
function filterDashboard(suggests = true) {
function filterDashboard() {
H.visitDashboard(ORDERS_DASHBOARD_ID);
cy.contains("Orders");
cy.contains("Text").click();
H.filterWidget().click();
// We should get a suggested response and be able to click it if we're an admin
if (suggests) {
cy.findByPlaceholderText("Search the list").type("Main Street");
cy.contains("100 Main Street").click();
} else {
cy.findByPlaceholderText("Search the list").type("100 Main Street").blur();
cy.wait("@search").should(({ response }) => {
expect(response.statusCode).to.equal(403);
});
}
cy.contains("Add filter").click({ force: true });
cy.contains("100 Main Street");
cy.findByPlaceholderText("Search the list").type("Main Street");
cy.contains("100 Main Street").click();
H.dashboardParametersPopover().button("Add filter").click();
H.filterWidget().should("contain", "100 Main Street");
cy.location("search").should((search) =>
expect(new URLSearchParams(search).get("text")).to.eq("100 Main Street"),
);
}
describe("support > permissions (metabase#8472)", () => {
beforeEach(() => {
cy.intercept(
"GET",
`/api/dashboard/${ORDERS_DASHBOARD_ID}/params/*/search/*").as("search`,
);
H.restore();
cy.signInAsAdmin();
@@ -35,6 +25,11 @@ describe("support > permissions (metabase#8472)", () => {
H.editDashboard();
H.getDashboardCard(0)
.realHover()
.findByLabelText("Add a filter")
.should("be.visible");
H.setFilter("Text or Category", "Is");
// Filter the first card by User Address
@@ -47,16 +42,23 @@ describe("support > permissions (metabase#8472)", () => {
cy.contains("Done").click();
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.contains("Save").click();
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.contains("Orders in a dashboard").click();
});
it("should allow an admin user to select the filter", () => {
it("should let admin and nodata users use the filter, and hide filter mapping from nodata users in edit mode", () => {
filterDashboard();
});
it("should allow a nodata user to select the filter", () => {
cy.signIn("nodata");
filterDashboard();
H.editDashboard();
H.getDashboardCard(0)
.realHover()
.findByTestId("dashboardcard-actions-panel")
.should("be.visible");
H.getDashboardCard(0).findByLabelText("Add a filter").should("not.exist");
H.filterWidget({ isEditing: true }).click();
H.getDashboardCard(0).icon("key").should("be.visible");
});
});

View File

@@ -41,7 +41,7 @@ describe("scenarios > dashboard > filters > reset", () => {
cy.signInAsAdmin();
});
it("should reset a filters value when editing the default", () => {
it("should reset a filters value when editing the default, and leave other filters alone", () => {
H.createQuestionAndDashboard({
questionDetails: QUESTION,
dashboardDetails: DASHBOARD,
@@ -82,9 +82,7 @@ describe("scenarios > dashboard > filters > reset", () => {
cy.location("search").should("eq", "?filter_one=&filter_two=");
cy.log(
"Finally, when we remove dashboard filter's default value, the url should reflect that by removing the placeholder",
);
cy.log("Replacing Filter Two's default value updates its value in the url");
H.editDashboard();
H.filterWidget({ name: "Filter Two", isEditing: true }).click();
@@ -104,47 +102,9 @@ describe("scenarios > dashboard > filters > reset", () => {
H.filterWidget().contains("Filter One").should("be.visible");
H.filterWidget().contains("Foo").should("be.visible");
});
it("should reset a filters value when editing the default, and leave other filters alone", () => {
H.createQuestionAndDashboard({
questionDetails: QUESTION,
dashboardDetails: DASHBOARD,
}).then(({ body: dashboardCard }) => {
const { card_id, dashboard_id } = dashboardCard;
H.editDashboardCard(dashboardCard, {
parameter_mappings: [
{
parameter_id: FILTER_ONE.id,
card_id,
target: ["dimension", ["field", PRODUCTS.CATEGORY, null]],
},
{
parameter_id: FILTER_TWO.id,
card_id,
target: ["dimension", ["field", PRODUCTS.TITLE, null]],
},
],
}).then(() => {
H.visitDashboard(dashboard_id, {
params: {
filter_one: "",
filter_two: "Bar",
},
});
});
});
cy.log("Default dashboard filter");
H.filterWidget().contains("Filter One").should("be.visible");
H.filterWidget().contains("Bar").should("be.visible");
cy.location("search").should("eq", "?filter_one=&filter_two=Bar");
cy.log(
"Finally, when we remove dashboard filter's default value, the url should reflect that by removing the placeholder",
"Setting Filter One's default value leaves Filter Two's value alone",
);
H.editDashboard();
@@ -154,15 +114,15 @@ describe("scenarios > dashboard > filters > reset", () => {
cy.findByLabelText("Input box").click();
});
setDefaultFilterValue("Foo");
setDefaultFilterValue("Baz");
cy.location("search").should("eq", "?filter_one=Foo&filter_two=Bar");
cy.location("search").should("eq", "?filter_one=Baz&filter_two=Foo");
H.saveDashboard();
cy.location("search").should("eq", "?filter_one=Foo&filter_two=Bar");
cy.location("search").should("eq", "?filter_one=Baz&filter_two=Foo");
H.filterWidget().contains("Filter One").should("be.visible");
H.filterWidget().contains("Baz").should("be.visible");
H.filterWidget().contains("Foo").should("be.visible");
});
});

View File

@@ -17,7 +17,6 @@ describe("scenarios > dashboard > filters > query stages", () => {
cy.intercept("POST", "/api/dataset").as("dataset");
cy.intercept("GET", "/api/dashboard/**").as("getDashboard");
cy.intercept("PUT", "/api/dashboard/**").as("updateDashboard");
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
"dashboardData",
);
@@ -37,7 +36,7 @@ describe("scenarios > dashboard > filters > query stages", () => {
);
});
it("allows to map to all relevant columns", () => {
it("allows to map to all relevant columns and applies a 1st stage implicit join (data source) filter", () => {
H.editDashboard();
cy.log("## date columns");
@@ -52,6 +51,25 @@ describe("scenarios > dashboard > filters > query stages", () => {
QSHelpers.getFilter("Number").click();
verifyNumberMappingOptions();
H.dashboardCancelButton().click();
cy.log("## 1st stage implicit join (data source)");
QSHelpers.setup1stStageImplicitJoinFromSourceFilter();
QSHelpers.verifyDashcardRowsCount({
dashcardIndex: 0,
dashboardCount: 1044,
queryBuilderCount: "Showing 1,044 rows",
});
QSHelpers.goBackToDashboard();
QSHelpers.verifyDashcardRowsCount({
dashcardIndex: 1,
dashboardCount: 1044,
queryBuilderCount: "Showing 1,044 rows",
});
function verifyDateMappingOptions() {
QSHelpers.verifyDashcardMappingOptions(
QSHelpers.QUESTION_BASED_QUESTION_INDEX,
@@ -311,24 +329,6 @@ describe("scenarios > dashboard > filters > query stages", () => {
});
});
it("1st stage implicit join (data source)", () => {
QSHelpers.setup1stStageImplicitJoinFromSourceFilter();
QSHelpers.verifyDashcardRowsCount({
dashcardIndex: 0,
dashboardCount: 1044,
queryBuilderCount: "Showing 1,044 rows",
});
QSHelpers.goBackToDashboard();
QSHelpers.verifyDashcardRowsCount({
dashcardIndex: 1,
dashboardCount: 1044,
queryBuilderCount: "Showing 1,044 rows",
});
});
it("1st stage implicit join (joined data source)", () => {
QSHelpers.setup1stStageImplicitJoinFromJoinFilter();
@@ -369,7 +369,7 @@ describe("scenarios > dashboard > filters > query stages", () => {
QSHelpers.setup1stStageAggregationFilter();
QSHelpers.verifyDashcardRowsCount({
dashcardIndex: 1,
dashcardIndex: 0,
dashboardCount: 3,
queryBuilderCount: "Showing 3 rows",
});

View File

@@ -31,8 +31,6 @@ function createDashboardDetails({ parameters }) {
};
}
const TOAST_TIMEOUT = 16000;
const filterToggleLabel = "Auto-apply filters";
describe(
@@ -117,7 +115,7 @@ describe(
cy.get("@cardQuery.all").should("have.length", 4);
cy.log(
"last applied parameter values should be used when disabling auto applying filters, even if previously there were draft parameter values",
"parameter values applied while auto-applying filters should be preserved when disabling auto applying filters",
);
H.filterWidget().findByText("Widget").click();
H.popover().within(() => {
@@ -134,13 +132,14 @@ describe(
H.closeDashboardSettingsSidebar();
H.filterWidget().findByText("2 selections").should("be.visible");
H.assertTableRowsCount(107);
cy.get("@cardQuery.all").should("have.length", 5);
cy.get("@updateDashboardSpy").should("have.callCount", 3);
});
});
it("should not save filter state for dashboard parameter w/o auto-apply enabled", () => {
it("should not save unapplied filter state and allow resetting it", () => {
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
openDashboard();
@@ -156,16 +155,16 @@ describe(
cy.log("verify filter value is not saved");
H.visitDashboard("@dashboardId");
H.filterWidget().should("not.contain", "Gadget");
});
H.filterWidget()
.should("contain", FILTER.name)
.and("not.contain", "Gadget");
it("should allow resetting unapplied filter state", () => {
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
openDashboard();
cy.log("verify unapplied filter value can be reset");
H.filterWidget().findByText(FILTER.name).click();
H.popover().within(() => {
cy.findByText("Gadget").click();
cy.findByLabelText("Gadget").click();
cy.findByLabelText("Gadget").should("be.checked");
cy.button("Add filter").click();
});
@@ -177,12 +176,12 @@ describe(
H.filterWidget().findByText(FILTER.name).click();
H.popover().within(() => {
cy.findByText("Gadget").should("not.be.checked");
cy.findByLabelText("Gadget").should("not.be.checked");
});
});
describe("modifying dashboard and dashboard cards", () => {
it("should not preserve draft parameter values when editing the dashboard", () => {
it("should preserve draft parameter values when editing is cancelled but not when the dashboard is saved", () => {
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
openDashboard();
@@ -193,6 +192,13 @@ describe(
});
H.applyFilterButton().should("be.visible");
cy.log("cancel editing");
H.editDashboard();
cy.findByTestId("edit-bar").button("Cancel").click();
H.filterWidget().findByText("Gadget").should("be.visible");
H.applyFilterButton().should("be.visible");
cy.log("edit and save the dashboard");
H.editDashboard();
H.setFilter("Text or Category", "Is");
@@ -213,25 +219,6 @@ describe(
});
});
describe("modify nothing", () => {
it("should preserve draft parameter values when editing of the dashboard was cancelled", () => {
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
openDashboard();
H.filterWidget().findByText(FILTER.name).click();
H.popover().within(() => {
cy.findByText("Gadget").click();
cy.button("Add filter").click();
});
H.applyFilterButton().should("be.visible");
H.editDashboard();
cy.findByTestId("edit-bar").button("Cancel").click();
H.filterWidget().findByText("Gadget").should("be.visible");
H.applyFilterButton().should("be.visible");
});
});
describe("parameter with default values", () => {
beforeEach(() => {
createDashboard({ parameter: FILTER_WITH_DEFAULT_VALUE });
@@ -293,12 +280,12 @@ describe(
cy.signIn("readonly");
});
it("should not be able to toggle auto-apply filters toggle", () => {
it("should not show dashboard settings with the auto-apply filters toggle", () => {
openDashboard();
cy.wait("@cardQuery");
// shouldn't even show settings as an option for this user
H.dashboardHeader().icon("ellipsis").click();
H.popover().findByText("Enter fullscreen").should("be.visible");
H.popover().findByText("Edit settings").should("not.exist");
});
});
@@ -329,17 +316,6 @@ describe(
H.assertTableRowsCount(54);
});
});
it("should not show toast", () => {
createDashboard();
cy.clock();
openSlowPublicDashboard({ [FILTER.slug]: "Gadget" });
H.filterWidget().findByText("Gadget").should("be.visible");
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
});
});
describe("signed embeds", () => {
@@ -375,25 +351,6 @@ describe(
H.assertTableRowsCount(54);
});
});
it("should not show toast", () => {
createDashboard({
dashboardDetails: {
enable_embedding: true,
embedding_params: {
[FILTER.slug]: "enabled",
},
},
});
cy.clock();
openSlowEmbeddingDashboard({ [FILTER.slug]: "Gadget" });
H.filterWidget().findByText("Gadget").should("be.visible");
cy.tick(TOAST_TIMEOUT);
cy.wait("@cardQuery");
H.undoToast().should("not.exist");
});
});
describe("full-app embeddings", () => {
@@ -452,7 +409,7 @@ describe("scenarios > dashboards > filters > auto apply", () => {
H.expectNoBadSnowplowEvents();
});
it("should send snowplow events when disabling auto-apply filters", () => {
it("should send snowplow events only when disabling auto-apply filters", () => {
createDashboard();
openDashboard();
cy.wait("@cardQuery");
@@ -465,20 +422,12 @@ describe("scenarios > dashboards > filters > auto apply", () => {
H.expectUnstructuredSnowplowEvent({
event: "auto_apply_filters_disabled",
});
});
});
it("should not send snowplow events when enabling auto-apply filters", () => {
createDashboard({ dashboardDetails: { auto_apply_filters: false } });
openDashboard();
cy.wait("@cardQuery");
H.openDashboardSettingsSidebar();
H.sidesheet().within(() => {
cy.log("enabling auto-apply filters should not send an event");
cy.findByLabelText(filterToggleLabel).click();
cy.wait("@updateDashboard");
cy.findByLabelText(filterToggleLabel).should("be.checked");
H.assertNoUnstructuredSnowplowEvent({
H.expectUnstructuredSnowplowEvent({
event: "auto_apply_filters_disabled",
});
});
@@ -520,36 +469,6 @@ const openDashboard = (params = {}) => {
H.visitDashboard("@dashboardId", { params });
};
const openSlowPublicDashboard = (params = {}) => {
cy.intercept("GET", "/api/public/dashboard/*/dashcard/*/card/*").as(
"cardQuery",
);
cy.get("@dashboardId").then((dashboardId) => {
H.visitPublicDashboard(dashboardId, { params });
});
H.getDashboardCard().should("be.visible");
};
const openSlowEmbeddingDashboard = (params = {}) => {
cy.intercept("GET", "/api/embed/dashboard/*/dashcard/*/card/*").as(
"cardQuery",
);
cy.get("@dashboardId").then((dashboardId) => {
const embeddingPayload = {
resource: { dashboard: dashboardId },
params: {},
};
H.visitEmbeddedPage(embeddingPayload, {
setFilters: params,
});
});
H.getDashboardCard().should("be.visible");
};
const visitFullAppEmbeddingUrl = ({ url, qs }) => {
cy.visit({
url,

View File

@@ -29,7 +29,6 @@ describe("dashboard filters auto-wiring", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
cy.intercept("GET", "/api/dashboard/**").as("dashboard");
});
describe("parameter mapping", () => {
@@ -48,6 +47,7 @@ describe("dashboard filters auto-wiring", () => {
cy.findByText("User.Name").should("exist");
});
H.getDashboardCard(1).findByText("Select…").should("exist");
H.getDashboardCard(1).findByText("User.Name").should("not.exist");
H.undoToast()
@@ -77,9 +77,35 @@ describe("dashboard filters auto-wiring", () => {
cy.findByText("Auto-connected").should("not.exist");
cy.icon("sparkles").should("not.exist");
});
cy.log("verify auto-wire suggestion when a card is replaced");
goToFilterMapping();
H.getDashboardCard(1)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Replace")
.click();
H.modal().findByText("Orders, Count").click();
H.undoToastList()
.contains("Auto-connect “Orders, Count” to “Text”?")
.closest("[data-testid='toast-undo']")
.button("Auto-connect")
.click();
H.undoToastList().should(
"contain",
"“Orders, Count” was auto-connected to “Text”.",
);
goToFilterMapping();
H.getDashboardCard(1).findByText("User.Name").should("exist");
});
it("should not wire parameters to cards that already have a parameter, despite matching fields", () => {
it("should undo auto-wiring and not wire parameters to cards that already have a parameter, despite matching fields", () => {
createDashboardWithCards({ cards }).then((dashboardId) => {
H.visitDashboard(dashboardId);
});
@@ -102,9 +128,14 @@ describe("dashboard filters auto-wiring", () => {
.findByRole("button", { name: "Auto-connect" })
.click();
H.getDashboardCard(1).within(() => {
cy.findByLabelText("close icon").click();
});
H.getDashboardCard(0).findByText("User.Name").should("exist");
H.getDashboardCard(1).findByText("User.Name").should("exist");
cy.log("verify undo functionality");
H.undoToast().findByRole("button", { name: "Undo" }).click();
H.getDashboardCard(0).findByText("User.Name").should("exist");
H.getDashboardCard(1).findByText("Select…").should("exist");
H.selectDashboardFilter(H.getDashboardCard(1), "Address");
@@ -116,7 +147,7 @@ describe("dashboard filters auto-wiring", () => {
cy.findByText("User.Address").should("exist");
});
H.undoToast().should("contain", "Undo");
H.undoToast().should("not.exist");
});
it("should not suggest to wire parameters to cards that don't have a matching field", () => {
@@ -152,41 +183,12 @@ describe("dashboard filters auto-wiring", () => {
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
H.getDashboardCard(0).findByText("User.Name").should("exist");
H.undoToast().should("not.exist");
});
it("should undo parameter wiring when 'Undo' is clicked", () => {
createDashboardWithCards({ cards }).then((dashboardId) => {
H.visitDashboard(dashboardId);
});
H.editDashboard();
H.setFilter("Text or Category", "Is");
addCardToDashboard();
goToFilterMapping();
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
H.getDashboardCard(0).findByText("User.Name").should("exist");
for (let i = 0; i < cards.length; i++) {
H.getDashboardCard(i).findByText("User.Name").should("exist");
}
H.undoToast().findByRole("button", { name: "Undo" }).click();
H.getDashboardCard(0).findByText("User.Name").should("exist");
for (let i = 1; i < cards.length; i++) {
H.getDashboardCard(i).findByText("Select…").should("exist");
}
});
it("in case of two auto-wiring undo toast, the second one should last the default timeout of 12s", () => {
// The auto-wiring undo toasts use the same id, a bug in the undo logic caused the second toast to be dismissed by the
// timeout set by the first. See https://github.com/metabase/metabase/pull/35461#pullrequestreview-1731776862
it("should dismiss suggestion toasts after 12s, restarting for a second suggestion, and the undo toast after 8s", () => {
// A second auto-wiring suggestion toast must not be dismissed by the timeout set for the first one.
const cardTemplate = {
card_id: ORDERS_BY_YEAR_QUESTION_ID,
row: 0,
@@ -234,6 +236,21 @@ describe("dashboard filters auto-wiring", () => {
cy.tick(2000);
H.undoToast().should("not.exist");
cy.log("verify the undo toast after auto-connect lasts 8s");
removeFilterFromDashCard(0);
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
cy.tick(1000);
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
cy.tick(7000);
H.undoToast().should("exist");
cy.tick(2000);
H.undoToast().should("not.exist");
});
describe("multiple tabs", () => {
@@ -274,7 +291,7 @@ describe("dashboard filters auto-wiring", () => {
});
describe("add a card", () => {
it("should wire parameters to cards that are added to the dashboard", () => {
it("should wire parameters to cards that are added to the dashboard and undo it", () => {
createDashboardWithCards({ cards }).then((dashboardId) => {
H.visitDashboard(dashboardId);
});
@@ -291,8 +308,7 @@ describe("dashboard filters auto-wiring", () => {
}
addCardToDashboard();
cy.log("verify toast text and enable auto-connect");
goToFilterMapping();
H.undoToastList()
.eq(1)
@@ -300,49 +316,10 @@ describe("dashboard filters auto-wiring", () => {
.findByRole("button", { name: "Auto-connect" })
.click();
cy.log("verify toast text after auto-connect");
H.undoToastList()
.eq(1)
.should("contain", "“Orders Model” was auto-connected to “Text”.");
goToFilterMapping();
for (let i = 0; i < cards.length + 1; i++) {
H.getDashboardCard(i).findByText("User.Name").should("exist");
}
H.undoToastList()
.eq(1)
.findByText("“Orders Model” was auto-connected to “Text”.")
.should("be.visible");
});
it("should undo parameter wiring when 'Undo' is clicked", () => {
createDashboardWithCards({ cards }).then((dashboardId) => {
H.visitDashboard(dashboardId);
});
H.editDashboard();
H.setFilter("Text or Category", "Is");
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
for (let i = 0; i < cards.length; i++) {
H.getDashboardCard(i).findByText("User.Name").should("exist");
}
addCardToDashboard();
goToFilterMapping();
H.undoToastList()
.eq(1)
.should("contain", "Auto-connect “Orders Model” to “Text”?")
.findByRole("button", { name: "Auto-connect" })
.click();
for (let i = 0; i < cards.length + 1; i++) {
H.getDashboardCard(i).findByText("User.Name").should("exist");
}
@@ -414,49 +391,8 @@ describe("dashboard filters auto-wiring", () => {
});
});
describe("replace a card", () => {
it("should show auto-wire suggestion toast when a card is replaced", () => {
createDashboardWithCards({ cards }).then((dashboardId) => {
H.visitDashboard(dashboardId);
});
H.editDashboard();
H.setFilter("Text or Category", "Is");
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
H.undoToast().findByText("Auto-connect").click();
goToFilterMapping();
H.getDashboardCard(1)
.realHover({ scrollBehavior: "bottom" })
.findByLabelText("Replace")
.click();
H.modal().findByText("Orders, Count").click();
H.undoToastList()
.eq(2)
.should("contain", "Auto-connect “Orders, Count” to “Text”?")
.button("Auto-connect")
.click();
H.undoToastList()
.eq(2)
.should("contain", "“Orders, Count” was auto-connected to “Text”.");
});
});
describe("adding cards with foreign keys to the dashboard (metabase#36275)", () => {
beforeEach(() => {
cy.intercept(
"POST",
"/api/dashboard/*/dashcard/*/card/*/query",
cy.spy().as("cardQueryRequest"),
).as("cardQuery");
H.createQuestion({
name: "Products Question",
query: { "source-table": PRODUCTS_ID, limit: 1 },
@@ -497,8 +433,6 @@ describe("dashboard filters auto-wiring", () => {
addCardToDashboard(["Orders Question", "Reviews Question"]);
cy.wait("@cardQuery");
goToFilterMapping("ID");
// The two auto-wire suggestion toasts stack and briefly overlap while the
@@ -526,23 +460,27 @@ describe("dashboard filters auto-wiring", () => {
H.dashboardParametersContainer().findByText("ID").click();
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
"filteredCardQuery",
);
H.dashboardParametersPopover().within(() => {
H.fieldValuesCombobox().type("1,");
H.fieldValuesCombobox().type("3,");
cy.button("Add filter").click();
});
cy.wait("@cardQuery");
cy.wait("@filteredCardQuery");
H.getDashboardCard(0).within(() => {
getTableCell("ID", 0).should("contain", "1");
getTableCell("ID", 0).should("have.text", "3");
});
H.getDashboardCard(1).within(() => {
getTableCell("Product ID", 0).should("contain", "1");
getTableCell("Product ID", 0).should("have.text", "3");
});
H.getDashboardCard(2).within(() => {
getTableCell("Product ID", 0).should("contain", "1");
getTableCell("Product ID", 0).should("have.text", "3");
});
});
@@ -564,8 +502,6 @@ describe("dashboard filters auto-wiring", () => {
});
});
cy.wait("@cardQuery");
goToFilterMapping("ID");
H.getDashboardCard(0).findByText("Products.ID").should("exist");
@@ -576,23 +512,27 @@ describe("dashboard filters auto-wiring", () => {
H.dashboardParametersContainer().findByText("ID").click();
cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as(
"filteredCardQuery",
);
H.dashboardParametersPopover().within(() => {
H.fieldValuesCombobox().type("1,");
H.fieldValuesCombobox().type("3,");
cy.button("Add filter").click();
});
cy.wait("@cardQuery");
cy.wait("@filteredCardQuery");
H.getDashboardCard(0).within(() => {
getTableCell("ID", 0).should("contain", "1");
getTableCell("ID", 0).should("have.text", "3");
});
H.getDashboardCard(1).within(() => {
getTableCell("Product ID", 0).should("contain", "1");
getTableCell("Product ID", 0).should("have.text", "3");
});
H.getDashboardCard(2).within(() => {
getTableCell("Product ID", 0).should("contain", "1");
getTableCell("Product ID", 0).should("have.text", "3");
});
});
});
@@ -645,42 +585,6 @@ describe("dashboard filters auto-wiring", () => {
.should("have.length", 1)
.should("contain", "Removed card");
});
it("should dismiss toasts on timeout", () => {
createDashboardWithCards({ cards }).then((dashboardId) => {
H.visitDashboard(dashboardId);
});
H.editDashboard();
H.setFilter("Text or Category", "Is");
cy.clock();
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
cy.tick(1000);
H.undoToast().should("be.visible");
// AUTO_WIRE_TOAST_TIMEOUT
cy.tick(12000);
H.undoToast().should("not.exist");
removeFilterFromDashCard(0);
H.selectDashboardFilter(H.getDashboardCard(0), "Name");
cy.tick(1000);
// cy.clock();
H.undoToast().findByRole("button", { name: "Auto-connect" }).click();
cy.tick(1000);
H.undoToast().should("be.visible");
// AUTO_WIRE_UNDO_TOAST_TIMEOUT
cy.tick(8000);
H.undoToast().should("not.exist");
});
});
it("should auto-wire a new card to correct parameter targets (metabase#44720)", () => {

View File

@@ -1,75 +0,0 @@
const { H } = cy;
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
const { ORDERS_ID } = SAMPLE_DATABASE;
describe("dashboard filters values source config clearing and restoring", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
cy.intercept("POST", "/api/dataset").as("dataset");
});
it("should clear and restore parameter static-list values when the type changes", () => {
H.createQuestionAndDashboard({
questionDetails: {
display: "scalar",
query: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
},
},
}).then(({ body: { dashboard_id } }) => {
H.visitDashboard(dashboard_id);
H.editDashboard();
H.setFilter("Number", "Equal to", "Foo");
mapFilterToQuestion();
H.setFilterListSource({
values: [["10", "Ten"], ["20", "Twenty"], "30"],
});
H.saveDashboard();
H.editDashboard();
editFilter("Foo");
editFilterType("Text or Category", "Is");
H.checkFilterListSourceHasValue({ values: [] });
mapFilterToQuestion("Email");
setFilterSourceFromConnectedFields();
editFilterType("Number", "Equal to");
H.checkFilterListSourceHasValue({
values: [["10", "Ten"], ["20", "Twenty"], "30"],
});
});
});
});
function setFilterSourceFromConnectedFields() {
H.sidebar().findByText("Edit").click();
H.modal().within(() => {
cy.findByText("From connected fields").click();
cy.button("Done").click();
});
}
const mapFilterToQuestion = (column = "Quantity") => {
cy.findByText("Select…").click();
H.popover().within(() => cy.findByText(column).click());
};
function editFilter(name: string) {
cy.findByTestId("edit-dashboard-parameters-widget-container")
.findByText(name)
.click();
}
function editFilterType(type: string, subType: string) {
H.sidebar().findByText("Filter or parameter type").next().click();
H.selectDropdown().findByText(type).click();
H.sidebar().findByText("Filter operator").next().click();
H.selectDropdown().findByText(subType).click();
}

View File

@@ -110,13 +110,16 @@ describe("scenarios > dashboard > filters > management", () => {
});
describe("change parameter type", () => {
it("should reset existing filter mappings and default value", () => {
it("should reset existing filter mappings and default value, and preselect the default operator for every type", () => {
createDashboardWithFilterAndQuestionMapped();
selectFilter("Text");
H.getDashboardCard().should("contain", "People.Name");
cy.log("verify Text default value: Is");
H.sidebar().findByDisplayValue("Is").should("exist");
cy.log("change filter type");
H.sidebar().within(() => {
@@ -131,26 +134,12 @@ describe("scenarios > dashboard > filters > management", () => {
// verifies no default value
cy.findByText("No default").should("exist");
});
H.getDashboardCard().should("not.contain", "People.Name");
H.saveDashboard();
H.filterWidget().should("not.exist");
});
it("should preselect default value for every type of filter", () => {
createDashboardWithFilterAndQuestionMapped();
selectFilter("Text");
cy.log("verify Text default value: Is");
H.sidebar().findByDisplayValue("Is").should("exist");
changeFilterType("Number");
cy.log("verify Number default value: Between");
cy.log("verify Number default value: Equal to");
verifyOperatorValue("Equal to");
H.getDashboardCard().should("not.contain", "People.Name");
changeFilterType("ID");
cy.log("verify ID default value: Numeric ID");
@@ -163,72 +152,15 @@ describe("scenarios > dashboard > filters > management", () => {
changeFilterType("Location");
cy.log("verify Date default value: Is");
cy.log("verify Location default value: Is");
verifyOperatorValue("Is");
H.saveDashboard();
H.filterWidget().should("not.exist");
});
it("should use saved parameter value when user switches back to the saved filter type", () => {
const textFilter = createMockParameter({
name: "Text Text",
slug: "string",
id: "5aefc726",
type: "string/does-not-contain",
sectionId: "string",
});
const peopleQuestionDetails = {
query: { "source-table": PEOPLE_ID, limit: 5 },
};
H.createDashboardWithQuestions({
dashboardDetails: {
parameters: [textFilter],
},
questions: [peopleQuestionDetails],
}).then(({ dashboard, questions: cards }) => {
const [peopleCard] = cards;
H.updateDashboardCards({
dashboard_id: dashboard.id,
cards: [
{
card_id: peopleCard.id,
parameter_mappings: [
{
parameter_id: textFilter.id,
card_id: peopleCard.id,
target: ["dimension", ["field", PEOPLE.NAME, null]],
},
],
},
],
});
H.visitDashboard(dashboard.id);
});
H.editDashboard();
selectFilter("Text Text");
H.sidebar().findByDisplayValue("Does not contain").should("exist");
changeFilterType("Number");
// default value for a number type
H.sidebar().findByDisplayValue("Equal to").should("exist");
changeFilterType("Text or Category");
cy.log("verify the saved parameter value is restored");
H.sidebar().within(() => {
cy.findByDisplayValue("Does not contain").should("exist");
cy.findByDisplayValue("Text or Category").should("exist");
cy.findByDisplayValue("Text Text").should("exist");
});
});
it("should restore parameter mappings when user switches back to the saved parameter type", () => {
it("should restore the saved parameter value and mappings when user switches back to the saved filter type", () => {
const textFilter = createMockParameter({
name: "Text Text",
slug: "string",
@@ -278,11 +210,21 @@ describe("scenarios > dashboard > filters > management", () => {
changeFilterType("Number");
// default value for a number type
H.sidebar().findByDisplayValue("Equal to").should("exist");
cy.log("verify that mapping is cleared");
H.getDashboardCard().should("not.contain", "People.Name");
changeFilterType("Text or Category");
cy.log("verify the saved parameter value is restored");
H.sidebar().within(() => {
cy.findByDisplayValue("Does not contain").should("exist");
cy.findByDisplayValue("Text or Category").should("exist");
cy.findByDisplayValue("Text Text").should("exist");
});
cy.log("verify that mapping is restored");
H.getDashboardCard().should("contain", "People.Name");
});
@@ -301,11 +243,11 @@ describe("scenarios > dashboard > filters > management", () => {
changeOperator("Contains");
H.getDashboardCard().should("contain", "People.Name");
// verifies default value does not exist
H.sidebar().findByText("No default").should("exist");
H.getDashboardCard().should("contain", "People.Name");
H.saveDashboard();
H.filterWidget().should("contain", "Text");
@@ -354,6 +296,8 @@ function createDashboardWithFilterAndQuestionMapped() {
H.visitDashboard(dashboard.id);
});
H.filterWidget().should("contain", "Text");
H.editDashboard();
}

View File

@@ -14,10 +14,8 @@ describe("pivot tables", () => {
cy.signInAsAdmin();
QSHelpers.createBaseQuestions();
cy.intercept("POST", "/api/dataset").as("dataset");
cy.intercept("POST", "/api/dataset/pivot").as("datasetPivot");
cy.intercept("GET", "/api/dashboard/**").as("getDashboard");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
cy.then(function () {
H.createQuestion({
@@ -108,7 +106,10 @@ describe("pivot tables", () => {
cy.findByTestId("qb-header")
.button(/Filter/)
.click();
H.popover().findByText("Summaries").should("not.exist");
H.popover().within(() => {
cy.findByText("Base Orders Question").should("be.visible");
cy.findByText("Summaries").should("not.exist");
});
function verifyDateMappingOptions() {
QSHelpers.verifyDashcardMappingOptions(QUESTION_PIVOT_INDEX, [

View File

@@ -18,7 +18,7 @@ describe("scenarios > dashboard > filters", { tags: "@slow" }, () => {
cy.signInAsAdmin();
});
it("should be able to use a static list source with a dropdown or a search box", () => {
it("should be able to use a static list source with a dropdown or a search box, and restore it when the type changes back", () => {
H.createQuestionAndDashboard({
questionDetails: targetQuestion,
}).then(({ body: { dashboard_id } }) => {
@@ -52,6 +52,21 @@ describe("scenarios > dashboard > filters", { tags: "@slow" }, () => {
H.filterWidget().eq(1).findByText("Twenty").should("be.visible");
H.getDashboardCard().findByText("4").should("be.visible");
cy.log("static list values are cleared and restored when the type changes");
H.editDashboard();
editFilter("Number");
editFilterType("Text or Category", "Is");
H.checkFilterListSourceHasValue({ values: [] });
mapFilterToQuestion("Email");
setFilterSourceFromConnectedFields();
editFilterType("Number", "Equal to");
H.checkFilterListSourceHasValue({
values: [["10", "Ten"], ["20", "Twenty"], "30"],
});
});
it("should allow to use a card source with numeric columns and single or multiple values", () => {
@@ -126,6 +141,28 @@ const mapFilterToQuestion = (column = "Quantity") => {
H.popover().within(() => cy.findByText(column).click());
};
function setFilterSourceFromConnectedFields() {
H.sidebar().findByText("Edit").click();
H.modal().within(() => {
cy.findByText("From connected fields").click();
cy.button("Done").click();
});
}
function editFilter(name) {
cy.findByTestId("edit-dashboard-parameters-widget-container")
.findByText(name)
.click();
}
function editFilterType(type, subType) {
H.sidebar().findByText("Filter or parameter type").next().click();
H.selectDropdown().findByText(type).click();
H.sidebar().findByText("Filter operator").next().click();
H.selectDropdown().findByText(subType).click();
}
const filterDashboard = ({ index, isLabeled = false, isDropdown = false }) => {
// eslint-disable-next-line metabase/no-unsafe-element-filtering
H.filterWidget().eq(index).click();

Some files were not shown because too many files have changed in this diff Show More