Compare commits

...

48 Commits

Author SHA1 Message Date
Fraser
64fb24e355 Split api/analytics.ts into search telemetry and the export mutation
The file bundled two unrelated concerns. Search tracking now lives in
api/analytics/search.ts next to its only caller, and the export
mutation in api/analytics/export.ts. The analytics/ directory satisfies
the no-analytics-import-outside-analytics-files rule that pinned the
old filename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:23:08 +12:00
Fraser
74b08a51c8 Update module-boundaries config (#79733) 2026-08-12 14:33:34 +12:00
Fraser
244700d893 Create shared value-formatting module (#77771) 2026-08-12 13:19:22 +12:00
Romeo Van Snick
2a3f535c39 Prefetch a code-split page when a link to it scrolls into view (#79688) 2026-08-12 10:39:11 +10:00
Denis Berezin
eebccaad10 UXW-4973 Correctly show cache durations (#79499)
* UXW-4973 Correctly show cache durations

* Add validation

* Review fixes

* Fix CI

* Review fixes

* Fix e2e test
2026-08-12 02:09:14 +03:00
Andrei Chernykh
3b936768f0 Show pinned items in the collection contents list (#79679)
* Show pinned items in the collection contents list

* Update e2e specs for pinned items appearing in the contents list

* Simplify the collection empty state offset to a plain vh value

* Simplify bookmark state, skip the pinned query in trash, and drop the dead toolbar prop

* Use a real pointer click for the pinned item menu so hover cannot be raced away
2026-08-11 16:00:16 -07:00
Kevin Kelleher
1d56ea8657 Use consistent icon for explorations (#79728)
use consistent icon for explorations
2026-08-11 21:58:28 +00:00
Kevin Kelleher
4981a05a19 Include library in Entity and Mini Pickers after downgrade to OSS (#79678)
include library in Entity and Mini pickers after downgrade to OSS
2026-08-11 17:38:30 -04:00
Mihael Konjević
bd8bab1d36 fix: run source-card field-type inference before cross-stage inference (BOT-1604) (#78799)
* fix: run source-card field-type inference before cross-stage inference (BOT-1604)

The repair pipeline mini-resolves earlier stages to type a later stage's
cross-stage field refs, but ran that pass before stamping base-type onto a
source-card stage's own bare-name field refs. The mini-resolve of a
source-card-sourced prefix then always failed schema validation and was
silently skipped, leaving cross-stage refs untyped and the query permanently
unrunnable for any multi-stage construct_notebook_query built on a saved
question/model.

* docs: fix stale pass numbering in repair pipeline docstring (BOT-1604)

Number the new source-card field-type pass 4.5 and keep the pre-existing
cross-stage pass as 5, instead of bumping cross-stage to 5.5 - this leaves
the other in-file references to "Pass 5" correct and matches the file's
existing fractional-pass-number convention.
2026-08-11 23:28:22 +02:00
Mihael Konjević
56117f5bb8 Surface metric explicit-join dimensions via agent feedback (BOT-1612) (#74989)
- metric-details surfaces the metric's FK-less join dimensions (which the
  consumer-framed dimension list silently drops), each carrying the exact
  portable join clause to paste into `joins:` and an alias-qualified field
  reference. Fail closed: an entry is emitted only when its join clause exports
  and targets a real table; join-key columns are excluded.
- the :no-fk-path repair error is now actionable, pointing the agent at the
  metric's /dimensions resource for the join to add.

Also fix a pre-existing option-key typo in resources.clj
(:with-queryable-dimensions / :with-field-values -> the ?-suffixed forms the
readers expect) so the plain metric resource correctly skips dimensions and
field-value fetches.
2026-08-11 23:14:06 +02:00
Chris Vandoodewaard
9b9b40a5f9 centralize linting workflows (#79673)
* init file

* fix linting

* delete files moved into lint.yml

* try parallel steps

* more parallel

* rework so that dependencies are ordered properly

* match naming

* remove whitespace

* update file-paths, add concurrency block
2026-08-11 11:15:25 -04:00
Denis Berezin
24e36a9f0e UXW-4712 Fix non-applicable empty series tooltip values for funnel bar charts (#79602) 2026-08-11 17:33:55 +03:00
github-automation-metabase
c2d55422d1 Auto-update documentation for master (#79680)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-11 10:21:23 -04:00
Timur Mashara
c0fe1712f6 docs - host the model button image in the repo (#79707)
* docs - host the model button image in the repo

The troubleshooting guide linked the model button screenshot from
https://www.metabase.com/learn/images/models/model-icon.png, which now
404s with no redirect. Learn images were moved out of the marketing repo
into a CDN bucket, and that key space has been renamed several times with
old keys hard-deleted rather than redirected.

This broke the nightly Update Docs job in metabase/docs.metabase.github.io
on Aug 8-10. It only goes green today because the link gate tolerates one
broken link and the other one got fixed upstream.

Commit the image here instead of chasing the CDN. It matches how every
other image in docs/ is referenced, it gets versioned along with the
release branch so older doc snapshots keep the image they shipped with,
and it is the only form our link checkers actually validate: lychee runs
--offline (external URLs skipped) and the docs site's htmlproofer runs
--disable-external.

The source file was a WebP served with a .png extension, so it has been
transcoded to a real PNG - pngcrush in optimize-images.yml would have
failed on the original bytes.

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

* Optimized 1 images, saved 1.8 KB (1.7% smaller)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-11 16:16:18 +02:00
Egor Grushin
24b402cf95 Fix icon and text vertical alignment in incremental transform alert (#79681)
fix(ui): align Alert icon with text when there is no title

Icon box was locked to the 24px title row, so title-less alerts (e.g.
incremental transform warning) hung the icon above a single line of text.
Without a title, icon and close button now center on the first message line.
2026-08-11 15:04:31 +01:00
Timofey Kachalov
ca89dc06f1 Dev Data app fixes (#79604) 2026-08-11 17:50:44 +04:00
Chris Vandoodewaard
698956f32f milestones should only be updated/expected in metabase/metabase (#79539)
* onyl run set-milestone in metabase/metabase

* don't trigger cloud issues if this isn't metabase/metabase

* run ci?
2026-08-11 09:05:08 -04:00
Nemanja Glumac
4b10b24deb Run the block-master-source-branch-prs workflow conditionally (#79706) 2026-08-11 11:37:00 +00:00
Paolo Rascunà
0635ba585d Add prascuna to Metabot team and Slack map (#79704)
Co-authored-by: Paolo Rascuna <1936871+prascuna@users.noreply.github.com>
2026-08-11 12:30:14 +02:00
Andrei Simionescu
e9d98d62c3 BOT-1953: Suppress the Metabot sidebar on the conversation route (#79574)
* BOT-1953: Suppress the Metabot sidebar on the conversation route

The full-page Metabot surface disables the app-bar button and the Cmd+E
shortcut so the chat cannot open on top of itself. That check compared the
pathname against /question/ask exactly, but #77549 added a second route
rendering the same MetabotAsk, /metabot/conversation/:convoId, which
MetabotAsk itself redirects to after the first message. The sidebar came
back from there on.

useIsFullPageMetabot matches either route and drives both suppression
sites. useIsAskPage stays strict for MetabotAsk's redirect, which must not
re-fire once it is already on the conversation route. The route path now
has one spelling, Urls.CONVERSATION_BASE_PATH, so a rename cannot silently
turn the suppression off again.

The conversation matcher pins the route shape rather than a prefix, with an
optional trailing slash because react-router matches the route either way.

Cmd+E had no coverage because a note in the test harness claimed tinykeys
shortcuts do not fire under jsdom. They do, bound to Control rather than
Meta since jsdom reports an empty navigator.platform.

* BOT-1953: Match the Metabot route predicates with react-router

useIsAskPage compared the pathname to /question/ask by string equality,
so /question/ask/, which react-router serves as the same page, left the
app-bar button and Cmd/Ctrl+E active on a full-page Metabot surface.

Both predicates now go through matchPath, the matcher the router itself
uses: trailing slash and case tolerated, extra segments and an empty
conversation id rejected.

* BOT-1953: Keep the urls module to plain URL builders

The two route predicates move into the metabot hooks that use them, which
now call matchPath directly; urls/ goes back to exporting only functions
that build strings. Their url-level specs go with them, since the metabot
ui and app-bar button specs already pin the behavior.
2026-08-11 09:18:45 +01:00
Fraser
a6b5e09200 Count dynamic imports as module boundary edges (#79693) 2026-08-11 17:42:21 +12:00
Romeo Van Snick
fa3af4772e Give the reference section its own chunk (#79641) 2026-08-11 15:20:08 +10:00
Romeo Van Snick
8350e6cc4a Fetch the AI auditing pages when their route is matched (#79565) 2026-08-11 11:54:21 +10:00
Denis Berezin
2f3d32a2ba UXW-4983 disable download to pdf for empty dashboard tab (#79490)
* UXW-4983 disable download to pdf for empty dashboard tab

* Add tooltip

* Update Loki Snapshots

* Review fixes - hide button when dashboard is empty

* Update Loki Snapshots

* Fix e2e

* Review fix

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-08-11 00:45:44 +03:00
Kevin Kelleher
c9da277bd3 Fix dashboard click behavior for date columns without a unit (#79664)
add fallback unit for formatting unbinned date columns
2026-08-10 17:43:06 -04:00
github-automation-metabase
d225a5f8d0 Auto-update documentation for master (#79622)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-08-10 16:09:01 -04:00
Jeff Bruemmer
16d281da2a docs - custom viz embedding web components (#79403) 2026-08-10 19:57:07 +00:00
Chris Vandoodewaard
7592d39e42 combine all notification workflows into issue-notifactions.yml (#79659)
* combine all notification workflows into issue-notifactions.yml

* try proposed naming schema

* add newline

* remove unused on-new-feature-label job
2026-08-10 15:42:21 +00:00
Ryan Laurie
9e6e08bffb remove dead workflows and support code (#79624)
* remove dead workflows and support code

# Conflicts:
#	.github/workflows/build-for-release.yml
#	.github/workflows/main.yml
#	.github/workflows/presto-kerberos-integration-test.yml

* remove dead clojure files
2026-08-10 09:32:38 -06:00
Andrei Chernykh
9bdf46f3d7 Stop autopinning models and metrics on creation (#79548)
* Stop autopinning models and metrics on creation

* Fix e2e assertions that expected new models to be pinned

* Remove negative-case pin tests per review
2026-08-10 08:26:16 -07:00
Chris Vandoodewaard
814b57b74a only publish Github release for major and gold releases (#79478)
* only publish Github release for major and gold releases

* pr review fixes
2026-08-10 10:16:58 -04:00
Denis Berezin
570a0e18c8 UXW-4929 Resolve unknown string dashboard ids (#79586) 2026-08-10 16:43:48 +03:00
Romeo Van Snick
bfa63e2a92 Replace the Bun globals in the yarn.lock alias script to fix the type check (#79650)
* Use node:fs in the yarn.lock alias script to fix the type check

* Wrap the alias script in a main function instead of top-level await
2026-08-10 14:14:54 +02:00
Chris Vandoodewaard
ec9e8c40f0 post warning about deprecated labels (#79618)
* post warning about deprecated labels

* cleanup double/triple backport logic, fail if labels set

* fix message, whitespace

* silent api response
2026-08-10 08:00:34 -04:00
Romeo Van Snick
518ca39d56 CI: rebuild node_modules from the bun store on install (#79644)
Rebuild node_modules from bun store to avoid stale CI cache
2026-08-10 13:47:08 +02:00
Kevin Kelleher
0fc5582ae2 Prefetch queries from Exploration toolbar navigation (#79615)
* move prefetch up into ExplorationPage

* fix e2e
2026-08-10 07:37:22 -04:00
Romeo Van Snick
b750f8d8fa Split documents into its own chunk (#79349)
* Add lazyModalRoute for modals under a code-split page

* Split documents into its own chunk

* Prefetch the documents chunk from links into it

* Prefetch the comments sidesheet with the document page
2026-08-10 21:02:14 +10:00
Romeo Van Snick
fc7d75b01f Fix Snyk scan by renaming bun's aliased yarn.lock keys (#79646)
* Fix Snyk scan by renaming bun's aliased yarn.lock keys

* Trigger the Snyk workflow on changes to the alias fix script
2026-08-10 21:02:02 +10:00
Uladzimir Havenchyk
af266d1426 fix: pass isSensible to custom viz (#79325)
* fix: pass isSensible to custom viz

* fixup

* address review

* fixup

* address review

* address review

* fixup

* fixup

* address custom viz re-render

* fixup
2026-08-10 12:12:01 +02:00
Andrei Simionescu
660b7ac2db BOT-1944: Fix two Metabot tool failures (#79498)
* Fix two Metabot tool failures surfaced by the reliability data

get_transform_details passed the transform's query map straight into the XML
formatter. clojure.data.xml reads a map in second position as an attribute map
and then throws on MBQL 5's namespaced :lib/type key, so every query-source
transform failed and only Python ones (no :query key) got through. That is 72
errors in 75 calls over 90 days, across 15+ instances. The query now renders as
SQL text, matching how the line below already handles :target.

create_sql_query has two implementations sharing one tool name, and neither used
the shared agent-error contract. The :sql profile's code-edit variant had no
error handling at all, so the :agent-error? exceptions validate-database-access
raises escaped uncaught and were tracked as tool failures. That variant errored
on 26% of calls against 0.04% for the :internal one. Both now delegate to
handle-agent-error, as the other tools do: the intended validation message goes
back to the model, and anything unexpected is rethrown so real defects stay
visible in the telemetry rather than being reported as successes.

Neither tool had a test; both now do.

* Address review: transform rendering and 403 classification

Transform source queries now render verbatim (data.xml escaped <, >, &,
which breaks old_string matching against the real query), python bodies
render, and the source type attribute is no longer a keyword literal.
Notebook-built queries fall back to EDN with the attached metadata
provider stripped, shared with transform->xml, whose string guard always
dropped the normalized (map) query.

validate-database-access tags the plain 403 from read-check so both
create_sql_query variants classify permission failures as agent errors,
and the pre-classification ERROR logs are gone; run-tool already logs
both paths at the right levels.

* Tolerate string queries in transform-query->text

Legacy transforms stored raw SQL strings under :query, and orphaned sources
skip normalization, so the query reaching this helper is not always a map.
dissoc on a string threw ClassCastException; the search formatter's old
string guard had tolerated exactly this shape. Strings now pass through
verbatim, and the helper's namespace gets its first tests.

* Use a doc link for escape-xml in the escape-xml-content docstring

* Read orphaned sources' string keys in extract-sql-content

Orphaned transform sources skip normalization and keep their JSON string
keys, so the keyword probes missed them and the model got EDN of a
string-keyed map instead of the SQL. Multi-stage queries now fall through
to the EDN branch too: stage 0 alone is partial SQL and hid the rest.

Also tags the read-check 404 alongside the 403, closing the race where
the database vanishes between the exists? check and the read.
2026-08-10 10:26:23 +01:00
Fraser
cc136d9725 Move the query pickers into querying (#79561) 2026-08-10 20:50:57 +12:00
Romeo Van Snick
aee69e552a Drop prettier/standalone from the expression formatter and load the printer on demand (#79639)
* Print expressions with prettier's doc printer instead of prettier/standalone

* Load the expression printer on demand
2026-08-10 13:13:28 +10:00
metabase-bot[bot]
4dc84c1615 [chore] Update E2E test timings with recent data (#79628)
Update E2E test timings with averaged data

Collected timing data from 7 days of test runs.
All specs with timing data were included (simple averages).

🤖 Generated automatically by update-e2e-timings workflow

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Actions <actions@github.com>
2026-08-09 09:13:08 +02:00
Nemanja Glumac
fb00ed06ef Remove the test-results S3 upload (#79552)
We no longer upload JUnit XML to S3, so the action and all 47 call sites
go. Test results stay visible through the JUnit reporter and CI
Conductor.

`id-token: write` drops from minimal-tests and transforms-python-stress-test,
whose only OIDC consumer was the upload's AWS credential handshake. It
stays in run-tests, which must keep granting it to drivers.yml's ECR login.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:41:53 +02:00
Anderson
7675914513 Add in-collection type filters with dynamic options (#79340)
* Add dynamic collection type filters

* Complete collection filter coverage

* Harden dynamic collection filters

* Simplify collection type filters

* Address review feedback on collection type filters

* Additional clean up
2026-08-07 18:57:13 -03:00
Anderson
28160265ec Implement in-collection text search (#79333)
* Add in-collection item search

* Add collection search end-to-end coverage

* Fix collection search navigation and coverage

* Add loading state and fix spacing for collection search

* Keep collection tables mounted during refetches

* A couple UI refinements

* Show collection search loading inline
2026-08-07 18:57:12 -03:00
Anderson
3ec36759e7 Backend: search and type-filter metadata for collection items (#79332)
* Add collection item search and filter metadata

* Add collection authority filters

* Cover collection search edge cases

* Keep snippet pinned-state behavior unchanged

* Never report snippets in available_models

* Remove collection authority filters

* Code cleanup

* Cover explorations in available models

---------

Co-authored-by: Nathan Voxland <nathan@metabase.com>
2026-08-07 18:57:12 -03:00
Denis Berezin
d8a19a330a UXW-4939 Fix crash on dashboard tab duplication (#79554)
Co-authored-by: azharizz <azharizzannada@gmail.com>
2026-08-08 00:34:11 +03:00
427 changed files with 12248 additions and 9875 deletions

View File

@@ -523,7 +523,6 @@
metabase.test.data.interface/defdataset clojure.core/def
metabase.test.data.interface/defdataset-edn clojure.core/def
metabase.test.embedded-postgres.core/with-system clojure.core/let
metabase.test.gentest/defgentest clojure.test/deftest
metabase.test/defdataset clojure.core/def
metabase.test/let-url clojure.core/let
metabase.test/with-actions clojure.core/let
@@ -682,7 +681,6 @@
metabase.test.data/dataset hooks.metabase.test.data/dataset
metabase.test.data/mbql-query hooks.metabase.test.data/mbql-query
metabase.test.data/run-mbql-query hooks.metabase.test.data/mbql-query
metabase.test.gentest/iterate hooks.metabase.test.gentest/iterate*
metabase.test.util.async/with-open-channels hooks.common/let-with-optional-value-for-last-binding
metabase.test.util.generators.jvm/with-random-cards hooks.metabase.test.util.generators.jvm/with-random-cards
metabase.test.util.log/with-log-level hooks.common/with-ignored-first-arg

View File

@@ -5,7 +5,7 @@
;; to defend in your PR.
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
{:ignore-counts {:aliased-namespace-symbol 3
:all 50
:all 49
:case-symbol-test 1
:clojure-lsp/unused-public-var 9
:def-fn 1

View File

@@ -1,14 +0,0 @@
(ns hooks.metabase.test.gentest
(:require
[clj-kondo.hooks-api :as hooks]))
(defn iterate*
"(iterate {:as limit-spec} [s1 val1 & more] body-form body-form...)"
[{{:keys [children] :as node} :node :as orig}]
(let [[token limit-spec bindings & body-nodes] children
node* (-> (hooks/list-node
(list token limit-spec
(hooks/list-node (list* (hooks/token-node 'let) bindings
body-nodes))))
(with-meta (meta node)))]
(assoc orig :node node*)))

View File

@@ -3,3 +3,11 @@ self-hosted-runner:
# selected through `vars.DEFAULT_RUNNER_KEY` and is not checked.
labels:
- arm-ubuntu-24-2core
paths:
# GitHub added parallel steps (`parallel`, `background`, `wait`, `wait-all`,
# `cancel`) in June 2026, and actionlint 1.7.12 does not know the schema yet:
# https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
.github/workflows/lint.yml:
ignore:
- 'step must run script with "run" section or run action with "uses" section'

View File

@@ -53,7 +53,13 @@ runs:
# If a job wants to patch packages, it does so explicitly in the CI. This setting is opt-out.
# For example, we never want to cache node_modules with patches already applied so cache-generator.yml skips that step.
- name: Clean install
run: bun install --frozen-lockfile
# Remove the restored node_modules before installing. The os-week cache can
# carry a stale tree (for example a different React major), and
# `--frozen-lockfile` alone does not prune it. Rebuilding from the cached bun
# store gives an exact, lockfile-correct node_modules with no extra download.
run: |
rm -rf node_modules
bun install --frozen-lockfile
shell: bash
- name: Patch frontend dependencies
if: inputs.apply-patches == 'true'

View File

@@ -1,40 +0,0 @@
name: Upload Test Results to s3
description: Uploads test results files to s3
inputs:
input-path:
required: true
output-name:
required: true
default: test
role-to-assume:
required: true
aws-region:
required: true
default: us-east-1
bucket:
required: true
runs:
using: "composite"
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885
with:
role-to-assume: ${{ inputs.role-to-assume }}
aws-region: ${{ inputs.aws-region }}
- name: zip test results
env:
INPUT_DIR: ${{ inputs.input-path }}
OUTPUT_FILE: ${{ inputs.output-name }}
shell: bash
run: | # sh
zip ${OUTPUT_FILE}.zip $INPUT_DIR/*.xml
- name: Upload test results to S3
env:
BUCKET: ${{ inputs.bucket }}
OUTPUT_FILE: ${{ inputs.output-name }}
shell: bash
run: | # sh
DATE=$(date '+%Y-%m-%d')
aws s3 cp ${OUTPUT_FILE}.zip s3://$BUCKET/$DATE/$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT/

View File

@@ -1,22 +0,0 @@
name: 'Verify Organization Membership'
description: 'Check if a user is a member of a GitHub organization'
inputs:
github_token:
description: 'Token with access to the organization'
required: true
organization_name:
description: 'Organization name'
required: true
username:
description: 'Username'
required: true
outputs:
is_member:
value: ${{ steps.verify-membership.outputs.is_member }}
description: "Is the user a member of the organization"
runs:
using: "composite"
steps:
- run: bash ${{ github.action_path }}/verify_membership.sh ${{ inputs.github_token }} ${{ inputs.organization_name }} ${{ inputs.username }}
id: verify-membership
shell: bash

View File

@@ -1,25 +0,0 @@
#!/bin/bash
TOKEN=$1
ORG_NAME=$2
USERNAME=$3
response=$(curl -L -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$ORG_NAME/members/$USERNAME")
# Github returns an empty response with 204 status if user is a member and 404 status otherwise
# See: https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#check-organization-membership-for-a-user
if [ "$response" == "204" ]; then
echo "The user $USERNAME is a member of $ORG_NAME"
echo "is_member=true" >> $GITHUB_OUTPUT
elif [ "$response" == "404" ]; then
echo "The user $USERNAME is not a member of $ORG_NAME"
echo "is_member=false" >> $GITHUB_OUTPUT
else
echo "Failed to determine the membership status for $USERNAME in $ORG_NAME"
echo "is_member=false" >> $GITHUB_OUTPUT
exit 1
fi

View File

@@ -106,13 +106,6 @@ backend_ci: &backend_ci
- ".github/workflows/backend.yml"
- ".github/workflows/drivers.yml"
backend_presto_kerberos:
- "**/presto_jdbc/**"
- "**/presto-jdbc/**" # the driver module has a hyphen. underscores are classpaths
- "**/presto_jdbc.clj"
- "**/presto_jdbc_test.clj"
- ".github/workflows/presto-kerberos-integration-test.yml"
backend_sources: &backend_sources
- *shared_sources
- "**deps.edn"
@@ -183,7 +176,7 @@ embedding_sdk_host_sample_apps:
- "e2e/test-host-app/**/*.cy.*.{js,ts}"
snowplow: &snowplow
- ".github/workflows/snowplow.yml"
- ".github/workflows/lint.yml"
- "snowplow/**"
- "bin/lint-snowplow-schemas.bb"
- "bin/lint-snowplow-schemas-test.bb"
@@ -212,7 +205,7 @@ yaml:
- "**/*.yaml"
codeql:
- ".github/workflows/codeql.yml"
- ".github/workflows/lint.yml"
- "frontend/src/**"
- "enterprise/frontend/src/**"
@@ -220,14 +213,9 @@ i18n:
- *default
- ".github/actions/prepare-frontend/**"
- ".github/actions/prepare-backend/**"
- ".github/workflows/i18n.yml"
- ".github/workflows/lint.yml"
- *sources
visualizations:
- "frontend/src/metabase/visualizations/**"
- "frontend/src/metabase/static-viz/**"
- "src/metabase/pulse/**"
cljs:
- *shared_sources
- *shared_specs
@@ -239,7 +227,7 @@ serialization_baseline:
- "test_resources/serialization_baseline/**"
openapi:
- ".github/workflows/openapi.yml"
- ".github/workflows/lint.yml"
- "src/metabase/api/**"
- "src/metabase/api_routes/**"
- "src/metabase/*_rest/api.clj"

View File

@@ -0,0 +1,66 @@
/**
* Renames aliased dependency keys in a bun-generated yarn.lock.
*
* Yarn names an alias entry after the alias, for example
* `"typescript7@npm:typescript@7.0.2"`. Bun names it after the aliased package
* instead, for example `"typescript@npm:typescript@7.0.2"`. Snyk follows the
* yarn convention and fails to resolve the dependency.
*/
import { readFile, writeFile } from "node:fs/promises";
async function main() {
const packageJsonPath = `${process.cwd()}/package.json`;
const lockPath = `${process.cwd()}/yarn.lock`;
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
const dependencies = Object.entries<string>(
Object.assign(
{},
packageJson.dependencies,
packageJson.devDependencies,
packageJson.optionalDependencies,
),
);
let lock = await readFile(lockPath, "utf8");
const renamedKeys: string[] = [];
for (const [alias, specifier] of dependencies) {
if (!specifier.startsWith("npm:")) {
continue;
}
const aliasedPackage = specifier
.slice("npm:".length)
.replace(/@[^@]*$/, "");
if (aliasedPackage === alias) {
continue;
}
const bunKey = `"${aliasedPackage}@${specifier}"`;
const yarnKey = `"${alias}@${specifier}"`;
if (lock.includes(bunKey)) {
lock = lock.replaceAll(bunKey, yarnKey);
renamedKeys.push(`${bunKey} -> ${yarnKey}`);
}
}
if (renamedKeys.length > 0) {
await writeFile(lockPath, lock);
}
process.stdout.write(
renamedKeys.length > 0
? `Renamed alias keys in yarn.lock:\n${renamedKeys.join("\n")}\n`
: "No alias keys to rename in yarn.lock\n",
);
}
main().catch((error) => {
process.stderr.write(`Failed to rename alias keys in yarn.lock: ${error}\n`);
process.exit(1);
});

View File

@@ -1,84 +0,0 @@
const githubSlackMap = require("../../release/github-slack-map.json");
async function getIssueData({ context, github, manualIssueNumber }) {
let issueNumber;
let issueData;
let prReviews = [];
const triggerType = context.eventName;
if (context.eventName === "issues") {
issueNumber = context.payload.issue.number;
issueData = context.payload.issue;
} else if (context.eventName === "workflow_dispatch") {
issueNumber = parseInt(manualIssueNumber);
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issueData = data;
} else if (context.eventName === "pull_request_review") {
const prNumber = context.payload.pull_request.number;
const { data: prData } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
// Extract issue number from branch name (e.g., "claude-fix/issue-12345-some-title")
const branchMatch = prData.head.ref.match(/claude-fix\/issue-(\d+)/);
if (!branchMatch) {
throw new Error(
`Could not extract issue number from branch name: ${prData.head.ref}`,
);
}
issueNumber = parseInt(branchMatch[1]);
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issueData = data;
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
prReviews = reviews.filter(
(review) =>
review.state === "CHANGES_REQUESTED" &&
isMetabaseMember(review.user.login),
);
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const employeeComments = comments.filter((comment) =>
isMetabaseMember(comment.user.login),
);
return {
issueNumber,
issueData,
employeeComments,
prReviews,
triggerType,
isIssueAuthorEmployee: isMetabaseMember(issueData.user.login),
};
}
function isMetabaseMember(username) {
return username in githubSlackMap;
}
module.exports = {
getIssueData,
};

View File

@@ -1,65 +0,0 @@
#! /usr/bin/env bash
# runs one or more Metabase test(s) against a Kerberized Presto instance
set -eo pipefail
# Need Java commands on $PATH, which apparently is not yet the case
export JAVA_HOME="/usr/lib/jvm/msopenjdk-current"
export PATH="$PATH:$JAVA_HOME/bin"
# ensure java commmand is available
which java
# install clojure version needed for Metabase
curl -O https://download.clojure.org/install/linux-install-1.12.0.1488.sh
chmod +x linux-install-1.12.0.1488.sh
./linux-install-1.12.0.1488.sh
RESOURCES_DIR=/app/source/resources
# ensure the expected files are in place, in the resources dir
if [ ! -f "$RESOURCES_DIR/ssl_keystore.jks" ]; then
echo "$RESOURCES_DIR/ssl_keystore.jks does not exist; cannot run test" >&2
exit 11
fi
if [ ! -f "$RESOURCES_DIR/krb5.conf" ]; then
echo "$RESOURCES_DIR/krb5.conf does not exist; cannot run test" >&2
exit 12
fi
if [ ! -f "$RESOURCES_DIR/client.keytab" ]; then
echo "$RESOURCES_DIR/client.keytab does not exist; cannot run test" >&2
exit 13
fi
# Copy the JDK cacerts file to our resources
cp $JAVA_HOME/lib/security/cacerts $RESOURCES_DIR/cacerts-with-presto-ca.jks
# Capture the Presto server self signed CA in PEM format
openssl s_client -showcerts -connect presto-kerberos:7778 </dev/null \
| openssl x509 -outform PEM >$RESOURCES_DIR/presto-ssl-root-ca.pem
# Convert the Presto server self signed CA to DER format
openssl x509 -outform der -in $RESOURCES_DIR/presto-ssl-root-ca.pem -out $RESOURCES_DIR/presto-ssl-root-ca.der
# Add Presto's self signed CA to the truststore
keytool -noprompt -import -alias presto-kerberos -keystore $RESOURCES_DIR/cacerts-with-presto-ca.jks \
-storepass changeit -file $RESOURCES_DIR/presto-ssl-root-ca.der -trustcacerts
# Set up the environment variables pointing to all of this, and run some tests
DRIVERS=presto-jdbc \
MB_ENABLE_PRESTO_JDBC_DRIVER=true \
MB_PRESTO_JDBC_TEST_HOST=presto-kerberos \
MB_PRESTO_JDBC_TEST_PORT=7778 \
MB_PRESTO_JDBC_TEST_SSL=true \
MB_PRESTO_JDBC_TEST_SSL_KEYSTORE_PATH=$RESOURCES_DIR/ssl_keystore.jks \
MB_PRESTO_JDBC_TEST_SSL_KEYSTORE_PASSWORD=presto \
MB_PRESTO_JDBC_TEST_SSL_TRUSTSTORE_PATH=$RESOURCES_DIR/cacerts-with-presto-ca.jks \
MB_PRESTO_JDBC_TEST_SSL_TRUSTSTORE_PASSWORD=changeit \
MB_PRESTO_JDBC_TEST_KERBEROS=true \
MB_PRESTO_JDBC_TEST_USER=bob@EXAMPLE.COM \
MB_PRESTO_JDBC_TEST_KERBEROS_PRINCIPAL=bob@EXAMPLE.COM \
MB_PRESTO_JDBC_TEST_KERBEROS_REMOTE_SERVICE_NAME=HTTP \
MB_PRESTO_JDBC_TEST_KERBEROS_KEYTAB_PATH=$RESOURCES_DIR/client.keytab \
MB_PRESTO_JDBC_TEST_KERBEROS_CONFIG_PATH=$RESOURCES_DIR/krb5.conf \
clojure -X:dev:test:drivers:drivers-dev :only metabase.driver.presto-jdbc-test

3
.github/team.json vendored
View File

@@ -65,7 +65,8 @@
"piranha",
"crisptrutski",
"undrfined",
"andreis"
"andreis",
"prascuna"
]
},
{

View File

@@ -1,44 +0,0 @@
name: Actionlint
on:
push:
branches:
- 'master'
- 'release-**'
paths:
- '.github/workflows/**'
pull_request:
paths:
- '.github/workflows/**'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
actionlint:
name: Lint Github Actions Workflows
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- uses: actions/checkout@v6
- name: Get changed Github actions
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
list-files: shell
- name: Setup actionlint
if: steps.changes.outputs.actionlint == 'true'
id: download
shell: bash
run: |
if command -v actionlint; then
echo "executable=actionlint" >> "$GITHUB_OUTPUT"
else
# script sets executable output, for details see: https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash)
fi
- name: Run actionlint
if: steps.changes.outputs.actionlint == 'true'
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ steps.changes.outputs.actionlint_files }}

View File

@@ -125,15 +125,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-mysql:
if: ${{ !inputs.skip }}
@@ -260,15 +251,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-postgres:
if: ${{ !inputs.skip }}
@@ -370,15 +352,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
app-db-tests-result:
needs:

View File

@@ -99,6 +99,36 @@ jobs:
)
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: "!! double-backport and triple-backport label deprecation warning !!"
if:
|
github.event.pull_request.merged == true && (
github.event.action == 'closed' && (
contains(github.event.pull_request.labels.*.name, 'double-backport') ||
contains(github.event.pull_request.labels.*.name, 'triple-backport')
) ||
github.event.action == 'labeled' && (
github.event.label.name == 'double-backport' ||
github.event.label.name == 'triple-backport'
)
)
env:
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
run: |
msg="🚨🚨 Backport failed because \`double-backport\` and \`triple-backport\` labels are **deprecated**! Use the \`backport\` label to port changes to all supported releases instead. 🚨🚨"
gh api \
--silent \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2026-03-10" \
/repos/"$REPO"/issues/"$PR"/comments \
-f "body=$msg"
echo "::error::$msg"
exit 1
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -110,29 +140,6 @@ jobs:
pull-request-number: ${{ github.event.pull_request.number }}
base-ref: ${{ github.event.pull_request.base.ref }}
- name: how many backports?
id: how-many-backports
uses: actions/github-script@v9
with:
script: | # js
const labels = [
...${{ toJson(github.event.pull_request.labels) }},
{ name:"${{ github.event.label.name }}" }
];
const backportLabelNames = labels.filter(label => label.name.includes('backport')).map(label => label.name);
let result = 1;
if (backportLabelNames.includes("triple-backport")) {
result = 3;
} else if (backportLabelNames.includes("double-backport")) {
result = 2;
}
console.log("backports", result);
return result;
- name: get release versions
id: get-latest-release-version
uses: actions/github-script@v9
@@ -157,21 +164,3 @@ jobs:
backport-commit: ${{ steps.find_commit.outputs.commit }}
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
github-token-2: ${{ secrets.GITHUB_TOKEN }}
- name: Double Backport
if: ${{ always() && fromJson(steps.how-many-backports.outputs.result) >= 2 }}
uses: ./.github/actions/create-backport
with:
target-version: ${{ fromJson(steps.get-latest-release-version.outputs.result).secondLatest }}
backport-commit: ${{ steps.find_commit.outputs.commit }}
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
github-token-2: ${{ secrets.GITHUB_TOKEN }}
- name: Triple Backport
if: ${{ always() && fromJson(steps.how-many-backports.outputs.result) >= 3 }}
uses: ./.github/actions/create-backport
with:
target-version: ${{ fromJson(steps.get-latest-release-version.outputs.result).thirdLatest }}
backport-commit: ${{ steps.find_commit.outputs.commit }}
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
github-token-2: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -281,16 +281,6 @@ jobs:
test-suite: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.test-group-name }}
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.edition }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Publish Test Report (JUnit)
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
if: failure()

View File

@@ -5,10 +5,10 @@ on:
jobs:
check-source-branch:
if: github.head_ref == 'master'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Block master branch PRs
if: github.head_ref == 'master'
run: |
echo "PRs from \`master\` branches are not allowed, please use a new branch name for your PR."
exit 1

View File

@@ -1,273 +0,0 @@
# Builds a fresh release JAR from scratch at a given commit.
#
# You should almost never have to use this. The normal release flow relies on a
# commit that has already been built and tested on its branch — see
# `tag-for-release.yml`, which tags that existing JAR. Building from scratch
# here is only for exceptional cases.
name: Release 0 - Build Release Artifact
run-name: Build ${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: 'Metabase version (e.g. v0.46.3)'
type: string
required: true
commit:
description: 'A full-length commit SHA-1 hash'
required: true
jobs:
start-message:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: |
release
.github
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts
- name: Send build start message
uses: actions/github-script@v9
env:
SLACK_RELEASE_CHANNEL: ${{ vars.SLACK_RELEASE_CHANNEL }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
with:
script: | # js
const { sendPreReleaseMessage } = require('${{ github.workspace }}/release/dist/index.cjs');
await sendPreReleaseMessage({
github,
owner: context.repo.owner,
repo: context.repo.repo,
version: '${{ inputs.version }}',
runId: '${{ github.run_id }}',
releaseSha: '${{ inputs.commit }}',
userName: '${{ github.actor }}',
channelName: '${{ vars.SLACK_RELEASE_CHANNEL }}',
}).catch(console.error);
trigger-milestone-check:
# Posts a Slack FYI about issues that still need review for this release.
# Informational only — nothing depends on it, so it never blocks the release.
needs: [check-version, check-commit]
permissions:
contents: read
issues: read
uses: ./.github/workflows/release-milestone-check.yml
secrets: inherit
with:
version: ${{ inputs.version }}
commit: ${{ inputs.commit }}
check-version:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
ee: ${{ fromJson(steps.canonical_version.outputs.result).ee }}
oss: ${{ fromJson(steps.canonical_version.outputs.result).oss }}
steps:
- name: Fail early on the incorrect version format
if: ${{ !(startsWith(inputs.version,'v0.') || startsWith(inputs.version,'v1.')) }}
run: |
echo "The version format is invalid!"
echo "It must start with either 'v0.' or 'v1.'."
echo "Please, try again."
exit 1
- uses: actions/checkout@v6
with:
sparse-checkout: |
release
.github
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts
- name: Get Release Version
uses: actions/github-script@v9
id: canonical_version
with:
script: | # js
const { isValidVersionString, getCanonicalVersion, hasBeenReleased } = require('${{ github.workspace }}/release/dist/index.cjs');
const version = '${{ inputs.version }}';
if (!isValidVersionString(version)) {
throw new Error("The version format is invalid! It must start with either 'v0.' or 'v1.'.");
}
const versions = {
ee: getCanonicalVersion(version, 'ee'),
oss: getCanonicalVersion(version, 'oss'),
};
const released = await hasBeenReleased({
github,
owner: context.repo.owner,
repo: context.repo.repo,
version: versions.oss,
});
if (released) {
throw new Error("This version has already been released!", version);
}
return versions;
check-commit:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- name: Check out the code to verify the release branch
uses: actions/checkout@v6
with:
fetch-depth: 0 # IMPORTANT! to get all the branches
sparse-checkout: |
release
.github
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts
- name: Get Release Branch
uses: actions/github-script@v9
id: release_branch
with:
result-encoding: string
script: | # js
const { getReleaseBranch } = require('${{ github.workspace }}/release/dist/index.cjs');
const version = '${{ inputs.version }}';
const releaseBranch = getReleaseBranch(version);
return releaseBranch;
- name: Ensure that the specified commit exists in the ${{ steps.release_branch.outputs.result }} release branch
run: | # bash
RELEASE_BRANCH=${{ steps.release_branch.outputs.result }}
git checkout "$RELEASE_BRANCH"
if git branch --contains ${{ inputs.commit }} | grep -q "$RELEASE_BRANCH"; then
echo "Commit found in correct release branch"
else
echo "Commit not found in correct release branch"
exit 1
fi
git checkout -
get-build-requirements:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
java_version: ${{ fromJson(steps.dependencies.outputs.result).java_version }}
node_version: ${{ fromJson(steps.dependencies.outputs.result).node_version }}
build_process: ${{ fromJson(steps.dependencies.outputs.result).build_process }}
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: |
release
.github
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts
- name: Get build dependencies
uses: actions/github-script@v9
id: dependencies
with:
script: | # js
const { getBuildRequirements, getMajorVersion } = require('${{ github.workspace }}/release/dist/index.cjs');
const version = '${{ inputs.version }}';
const requirements = getBuildRequirements(version);
const build_process = getMajorVersion(version) < 46 ? 'legacy' : 'new';
console.log('Build process:', build_process);
return {
java_version: requirements.java,
node_version: requirements.node,
build_process,
};
manage-milestones:
# Close the milestone we're about to ship and open the next one BEFORE building,
# so any PRs that merge while the build is in flight are tagged with the next
# milestone (not the one about to be released).
#
# Sequenced after trigger-milestone-check so that the check sees the milestone
# in its open state. milestone-check is informational and must never block, so
# we run regardless of its outcome; only preflight failures suppress this.
needs: [check-version, check-commit, trigger-milestone-check]
if: ${{ !cancelled() && needs.check-version.result == 'success' && needs.check-commit.result == 'success' }}
permissions:
contents: read
issues: write
uses: ./.github/workflows/release-milestone-manage.yml
with:
version: ${{ inputs.version }}
build-uberjar-for-release:
needs: [check-version, check-commit, get-build-requirements]
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 50
strategy:
matrix:
edition: [oss, ee]
env:
INTERACTIVE: false
steps:
- name: Get correct version number
run: | # bash
if [ '${{matrix.edition}}' == 'ee' ]; then
version='${{ needs.check-version.outputs.ee }}'
else
version='${{ needs.check-version.outputs.oss }}'
fi
echo "VERSION=$version" >> "$GITHUB_ENV"
- name: Setup Java ${{ needs.get-build-requirements.outputs.java_version }}
uses: actions/setup-java@v5.2.0
with:
distribution: 'temurin'
java-version: ${{ needs.get-build-requirements.outputs.java_version }}
- name: Setup Node.js ${{ needs.get-build-requirements.outputs.node_version }}
uses: actions/setup-node@v6.4.0
with:
node-version: ${{ needs.get-build-requirements.outputs.node_version }}.x
- name: Check out the code
uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.commit }}
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Build Metabase
if: ${{ needs.get-build-requirements.outputs.build_process != 'legacy' }}
run: ./bin/build.sh :edition :${{ matrix.edition }} :version "$VERSION"
- name: Build Metabase (legacy)
if: ${{ needs.get-build-requirements.outputs.build_process == 'legacy' }}
run: cd ./bin/build-mb && clojure -X build/build! :edition :${{ matrix.edition }} :version "$VERSION"
- name: Store commit's SHA-1 hash
run: echo ${{ inputs.commit }} > COMMIT-ID
shell: bash
- run: mv ./target/uberjar/metabase.jar ./metabase.jar
- name: Calculate SHA256 checksum
run: sha256sum ./metabase.jar > SHA256.sum
shell: bash
- name: Upload JARs as artifact
uses: actions/upload-artifact@v7
with:
name: metabase-release-${{ matrix.edition }}-${{ inputs.commit }}-uberjar
path: |
./metabase.jar
./COMMIT-ID
./SHA256.sum
run-pre-release-tests:
name: Run pre-release tests for ${{ inputs.version }}
needs: build-uberjar-for-release
uses: ./.github/workflows/pre-release.yml
secrets: inherit
with:
commit: ${{ inputs.commit }}
version: ${{ inputs.version }}

View File

@@ -93,10 +93,10 @@ jobs:
":dev:ee:ee-dev:drivers:drivers-dev:test:eastwood" # backend.yml (eastwood + eastwood/test)
":dev:ci:ee:ee-dev:drivers:drivers-dev:test" # test-driver.yml (ee) -> covers data.json:2.4.0
":dev:ci:oss:oss-dev:drivers:drivers-dev:test" # test-driver.yml (oss)
":dev:ci:test:ee:ee-dev" # backend.yml matrix (ee) + generative-query-test.yml
":dev:ci:test:ee:ee-dev" # backend.yml matrix (ee)
":dev:ci:test:oss:oss-dev" # backend.yml matrix (oss)
":dev:ci:ee:ee-dev:test:cloverage" # backend-cloverage.yml
":dev:drivers:drivers-dev:test" # reset-cloud-test-dataset.yml + presto script
":dev:drivers:drivers-dev:test" # reset-cloud-test-dataset.yml
":dev:drivers:build:build-dev:build-test:ci" # build-scripts.yml
":ee:drivers:check" # backend.yml (check; git dep athos/clj-check)
":ee:doc" # docs-generate-base.yml

View File

@@ -1,39 +0,0 @@
name: "CodeQL"
on:
push:
branches:
- 'master'
- 'release-**'
pull_request:
jobs:
files-changed:
name: Check which files changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 3
outputs:
codeql: ${{ steps.changes.outputs.codeql }}
steps:
- uses: actions/checkout@v6
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
analyze:
needs: files-changed
if: needs.files-changed.outputs.codeql == 'true'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: javascript
config-file: ./.github/codeql/codeql-config.yml
trap-caching: false
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4

View File

@@ -138,15 +138,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-bigquery-cloud-sdk-ee:
needs: determine-driver-skips
@@ -211,15 +202,6 @@ jobs:
test-args: ${{ matrix.job.test-args }}
artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -293,15 +275,6 @@ jobs:
test-args: ${{ matrix.job.test-args }}
artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -346,15 +319,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-databricks-ee:
needs: determine-driver-skips
@@ -417,15 +381,6 @@ jobs:
artifact-suffix: ${{ matrix.job.name }}
test-args: ${{ matrix.job.test-args }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-databricks-multi-catalog-ee:
needs: determine-driver-skips
@@ -489,15 +444,6 @@ jobs:
artifact-suffix: ${{ matrix.job.name }}
test-args: ${{ matrix.job.test-args }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-h2:
needs: determine-driver-skips
@@ -518,15 +464,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-h2-oss:
needs: determine-driver-skips
@@ -548,15 +485,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-mysql-mariadb:
needs: determine-driver-skips
@@ -688,15 +616,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -767,15 +686,6 @@ jobs:
test-args: ${{ matrix.job.test-args }}
artifact-suffix: ${{ matrix.version.name }}-${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -848,15 +758,6 @@ jobs:
:exclude-tags '[:mongo-sharded-cluster-tests :mb/transforms-python-test]'
:only-tags [:mb/driver-tests]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-mongo-sharded-cluster-ee:
needs: determine-driver-skips
@@ -892,15 +793,6 @@ jobs:
test-args: >-
:only metabase.driver.mongo.sharded-cluster-test
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-oracle:
needs: determine-driver-skips
@@ -976,15 +868,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-postgres:
needs: determine-driver-skips
@@ -1079,15 +962,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -1165,15 +1039,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-redshift-ee:
needs: determine-driver-skips
@@ -1248,15 +1113,6 @@ jobs:
${{ matrix.job.test-args }}
artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: ${{ !matrix.job.skip && !cancelled() }}
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -1330,15 +1186,6 @@ jobs:
test-args: ${{ matrix.job.test-args }}
artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -1381,15 +1228,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-sqlite-ee:
needs: determine-driver-skips
@@ -1421,15 +1259,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
be-tests-sqlserver:
needs: determine-driver-skips
@@ -1496,15 +1325,6 @@ jobs:
test-args: ${{ matrix.job.test-args }}
artifact-suffix: ${{ matrix.version.name }}-${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -1539,15 +1359,6 @@ jobs:
# junit-name: 'be-tests-starburst-ee'
# test-args: >-
# :only-tags [:mb/driver-tests]
# - name: Upload Test Results
# uses: ./.github/actions/upload-test-results
# if: always()
# with:
# input-path: ./target/junit/
# output-name: ${{ github.job }}
# bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
# role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
# aws-region: ${{ vars.AWS_REGION }}
login-to-ecr:
needs: determine-driver-skips
@@ -1621,15 +1432,6 @@ jobs:
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
drivers-tests-result:
needs:

View File

@@ -216,17 +216,6 @@ jobs:
echo "No ./target/junit directory; nothing to process."
fi
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
# Related to our custom upload to S3
output-name: e2e-${{ inputs.name }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Quarantine gate
# Only one of the two run steps executes; the other reports `skipped`. Only gate on actual failure.
if: ${{ !cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure') }}

View File

@@ -111,16 +111,6 @@ jobs:
echo "No ./target/junit directory; nothing to process."
fi
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: e2e-embedding-sdk-react-${{ matrix.react-version }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Quarantine gate
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
@@ -290,16 +280,6 @@ jobs:
echo "No ./target/junit directory; nothing to process."
fi
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: e2e-embedding-sdk
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Quarantine gate
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}

View File

@@ -1,38 +0,0 @@
name: Escalation Slack Notification
on:
issues:
types:
- labeled
jobs:
on-escalation-label:
if: github.event.label.name == '.Escalation'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- name: Format issue title (escape quotes)
uses: actions/github-script@v9
id: vars
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
with:
script: |
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
- name: Send escalated issue ${{ github.event.issue.number }} to Slack
id: slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_ESCALATIONS_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "🔥🔥🔥 ${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
}
}
]
}

View File

@@ -129,15 +129,6 @@ jobs:
adapter: frontend
test-suite: fe-tests-unit
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always() && github.event_name != 'workflow_dispatch'
with:
input-path: ./target/junit
output-name: frontend-unit
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
fe-tests-timezones:
if: ${{ !inputs.skip }}

View File

@@ -1,58 +0,0 @@
name: Generative Query Test
on:
workflow_dispatch:
inputs:
context-seed:
description: 'Context seed (empty = random)'
type: number
iteration-seed:
description: 'Iteration seed (for iteration replay, empty = dependent on Context seed)'
type: number
iterations:
description: 'Iterations (number of test iterations, overrides seconds)'
type: number
seconds:
description: 'Seconds (Number of seconds after which next iteration will not start)'
test:
description: 'Test or namespace to run'
default: metabase.query-processor.generative-test/execution-with-cards-test
ref:
description: 'Ref to run generative tests on'
default: qgen-ms2
# TODO: Consider renaming the job in next milestones
jobs:
generative-query-test:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 90
name: Generative Query Test
env:
CI: 'true'
MB_GENTEST_CONTEXT_SEED: ${{ inputs.context-seed }}
MB_GENTEST_ITERATION_SEED: ${{ inputs.iteration-seed }}
MB_GENTEST_LIMIT_ITERATIONS: ${{ inputs.iterations }}
MB_GENTEST_LIMIT_SECONDS: ${{ inputs.seconds }}
MB_GENTEST_RUN: true
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Run test
run: clojure -X:dev:ci:test:ee:ee-dev :only '${{ inputs.test }}'
- name: Publish Test Report (JUnit)
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
if: failure()
with:
path: "target/junit/**/*_test.xml"
name: JUnit Test Report (Generative Query test)
reporter: java-junit
list-suites: failed
list-tests: failed
fail-on-error: false

View File

@@ -1,51 +0,0 @@
name: i18n
on:
push:
branches:
- 'master'
- 'release-**'
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
files-changed:
name: Check which files changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 3
outputs:
i18n: ${{ steps.changes.outputs.i18n }}
steps:
- uses: actions/checkout@v6
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
verify-i18n-files:
needs: files-changed
if: needs.files-changed.outputs.i18n == 'true'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Install xgettext if necessary
run: |
if ! command -v xgettext > /dev/null; then
sudo apt install gettext
fi
- name: Check xgettext version
run: xgettext --version
- name: Check i18n tags/make sure template can be built
run: ./bin/i18n/update-translation-template
- name: Verify i18n translations (.po files)
run: ./bin/i18n/build-translation-resources

184
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,184 @@
name: Lint
on:
push:
branches:
- 'master'
- 'release-**'
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
files-changed:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
outputs:
actionlint: ${{ steps.changes.outputs.actionlint }}
actionlint_files: ${{ steps.changes.outputs.actionlint_files }}
codeql: ${{ steps.changes.outputs.codeql }}
i18n: ${{ steps.changes.outputs.i18n }}
openapi: ${{ steps.changes.outputs.openapi }}
snowplow: ${{ steps.changes.outputs.snowplow }}
yaml: ${{ steps.changes.outputs.yaml }}
steps:
- uses: actions/checkout@v6
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
list-files: shell
lint:
needs: files-changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Setup frontend env
if: |
needs.files-changed.outputs.yaml == 'true' ||
needs.files-changed.outputs.openapi == 'true'
uses: ./.github/actions/prepare-frontend
# Always required by whitespace linter, also used by OpenAPI spec validation
- name: Setup backend env
uses: ./.github/actions/prepare-backend
- name: Setup actionlint
if: needs.files-changed.outputs.actionlint == 'true'
id: download
shell: bash
run: |
if command -v actionlint; then
echo "executable=actionlint" >> "$GITHUB_OUTPUT"
else
# script sets executable output, for details see: https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash)
fi
- parallel:
# Lint whitespace, always runs
- name: Run Whitespace Linter
run: clojure -T:whitespace-linter lint :whitespace-linter/progress-bar false
# Lint YAML
- name: Lint YAML files
if: needs.files-changed.outputs.yaml == 'true'
run: bun run lint-yaml
# Lint Github Actions
- name: Run actionlint
if: needs.files-changed.outputs.actionlint == 'true'
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ needs.files-changed.outputs.actionlint_files }}
# Generate and Lint OpenAPI spec
- name: Generate OpenAPI spec
if: needs.files-changed.outputs.openapi == 'true'
run: bun run generate-openapi
# Parallel steps run asynchronously, and explicitly waiting for a parallel step means "if" statements are ignored.
# Move OpenAPI validation outside the parallel block so that spec generation happens asynchronously without requiring
# an explicit "wait".
- name: Validate OpenAPI spec
if: needs.files-changed.outputs.openapi == 'true'
run: bun run lint-openapi
codeql:
if: needs.files-changed.outputs.codeql == 'true'
needs: files-changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: javascript
config-file: ./.github/codeql/codeql-config.yml
trap-caching: false
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
i18n:
needs: files-changed
if: needs.files-changed.outputs.i18n == 'true'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Install xgettext if necessary
run: |
if ! command -v xgettext > /dev/null; then
sudo apt install gettext
fi
- name: Check xgettext version
run: xgettext --version
- name: Check i18n tags/make sure template can be built
run: ./bin/i18n/update-translation-template
- name: Verify i18n translations (.po files)
run: ./bin/i18n/build-translation-resources
snowplow:
needs: files-changed
if: needs.files-changed.outputs.snowplow == 'true'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Java
uses: actions/setup-java@v5.2.0
with:
distribution: temurin
java-version: 25
- name: Set up Babashka
uses: turtlequeue/setup-babashka@d78ec6570aea3b614bc695cafcceb82eb45c2af9 # v1.8.0
with:
babashka-version: 1.12.197
- name: Install igluctl
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/igluctl.zip" https://github.com/snowplow/igluctl/releases/download/0.13.0/igluctl_0.13.0.zip
echo "0eac19121fca1431dec44738ada8eacc4242811a95d015ee71e00deec2b4d748 $RUNNER_TEMP/igluctl.zip" | sha256sum -c -
unzip -q "$RUNNER_TEMP/igluctl.zip" -d "$RUNNER_TEMP/igluctl"
chmod +x "$RUNNER_TEMP/igluctl/igluctl"
echo "$RUNNER_TEMP/igluctl" >> "$GITHUB_PATH"
- name: Lint (igluctl)
# igluctl recursively parses every file as JSON, so it chokes on colocated
# *.md docs and on two schemas whose internal `self.version` doesn't match
# the filename version (pending Snowcat audit). Stage a filtered copy and
# patch only the `self.version` mismatch so igluctl still lints the rest
# of those schemas.
run: |
set -euo pipefail
LINT_ROOT="$RUNNER_TEMP/iglu-lint"
rm -rf "$LINT_ROOT"
mkdir -p "$LINT_ROOT"
cp -r snowplow/iglu-client-embedded/schemas "$LINT_ROOT/"
find "$LINT_ROOT" -name '*.md' -delete
# TODO(BOT-1403): drop these patches once the Snowcat audit fixes the
# internal `self.version` mismatch on these two schemas. We rewrite
# only `self.version` in the staged copy so the file's path version
# matches what igluctl expects; every other lint rule still runs.
# Each `sed` invocation uses the schema's wrong/right pair, so it
# silently no-ops once the underlying schema is fixed.
sed -i 's/"version": "1-0-1"/"version": "1-0-2"/' \
"$LINT_ROOT/schemas/com.metabase/embed_share/jsonschema/1-0-2"
sed -i 's/"version": "1-0-0"/"version": "1-0-1"/' \
"$LINT_ROOT/schemas/com.metabase/serialization/jsonschema/1-0-1"
igluctl lint "$LINT_ROOT/schemas"
- name: Lint (Metabase schema rules)
run: ./bin/lint-snowplow-schemas.bb
- name: Test linter rules
run: ./bin/lint-snowplow-schemas-test.bb

View File

@@ -1,39 +0,0 @@
name: Slack Notification for New Feature Requests
on:
issues:
types:
- labeled
jobs:
on-issue-labeled:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
if: |
contains(github.event.issue.labels.*.name, 'Type:New Feature')
steps:
- name: Setting title
uses: actions/github-script@v9
id: vars
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
with:
script: |
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
- name: Send the issue ${{ github.event.issue.number }} to Slack
id: slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_FEATURE_REQUEST_TRIAGE_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "New incoming feature request: ${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
}
}
]
}

View File

@@ -9,7 +9,6 @@ concurrency:
cancel-in-progress: true
permissions:
id-token: write
contents: read
checks: write
actions: read
@@ -70,15 +69,6 @@ jobs:
:exclude-tags '[:mb/driver-tests :mb/transforms-python-test]'
:partition/total 2
:partition/index 0
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
# Postgres driver tests
postgres-driver-test:
@@ -116,15 +106,6 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
# Frontend unit tests
frontend-unit-tests:
@@ -168,15 +149,6 @@ jobs:
adapter: frontend
test-suite: fe-tests-unit
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit
output-name: frontend-unit
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
# Summary job
minimal-tests-result:

View File

@@ -0,0 +1,208 @@
# Send Slack and/or PagerDuty notifications when labels are added to a Github issue.
name: Notify on Issue Labeled
on:
issues:
types:
- labeled
jobs:
on-escalation-label:
if: github.event.label.name == '.Escalation'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:
- name: Format issue title (escape quotes)
uses: actions/github-script@v9
id: vars
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
with:
script: |
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
- name: Send escalated issue ${{ github.event.issue.number }} to Slack
id: slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_ESCALATIONS_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "🔥🔥🔥 ${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
}
}
]
}
on-security-label:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
if: github.event.label.name == 'SECURITY'
steps:
- name: Setting title
uses: actions/github-script@v9
id: vars
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
with:
script: |
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
- name: Send the issue ${{ github.event.issue.number }} to Slack
id: slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_SECURITY_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
}
}
]
}
# Note: this only triggers if a issue is already labeled with `Type:Bug` and `flaky-test-fix` and then a team label is added.
on-team-label:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
if: |
contains(github.event.label.name, '.Team/') &&
( contains(github.event.issue.labels.*.name, 'Type:Bug') ||
contains(github.event.issue.labels.*.name, 'flaky-test-fix') )
steps:
- uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
- name: Build Slack message (with Linear link if available)
uses: actions/github-script@v9
id: vars
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
with:
script: |
const { findLinearIssue } = require('./.github/scripts/find-linear-issue.js');
const title = context.payload.issue.title;
const issueUrl = context.payload.issue.html_url;
let text = title + '\n' + issueUrl;
// The Linear issue is usually already synced by the time the
// team label lands at triage, but retry briefly just in case.
try {
const delaysSeconds = [0, 15, 30];
let result = null;
for (const delay of delaysSeconds) {
if (delay) await new Promise((r) => setTimeout(r, delay * 1000));
result = await findLinearIssue({
githubIssueUrl: issueUrl,
linearApiKey: process.env.LINEAR_API_KEY,
});
if (result.success) break;
}
if (result && result.success) {
const id = result.issue; // e.g. "GIT-1234"
text += `\nLinear: <https://linear.app/metabase/issue/${id}|${id}>`;
} else {
core.info(`No Linear issue found for ${issueUrl}; posting without link.`);
}
} catch (e) {
// Never block the notification on a Linear lookup failure
core.warning(`Linear lookup failed: ${e.message}`);
}
core.setOutput('message', JSON.stringify(text));
- name: Extracting team name
id: team_name
env:
LABEL_NAME: ${{ github.event.label.name }}
run: |
team_label="${LABEL_NAME//&/$}"
echo "$team_label"
team_name=${team_label#*.Team/}
echo "$team_name"
echo "team_name=${team_name^^}_SLACK_ISSUES_WEBHOOK_URL" >> "$GITHUB_OUTPUT"
- name: Check webhook secret exists
id: check_secret
env:
WEBHOOK_URL: ${{ secrets[steps.team_name.outputs.team_name] }}
run: |
if [ -z "$WEBHOOK_URL" ]; then
echo "Secret ${{ steps.team_name.outputs.team_name }} is not set, skipping Slack notification"
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "exists=true" >> "$GITHUB_OUTPUT"
fi
- name: Send the issue ${{ github.event.issue.number }} to Slack
if: steps.check_secret.outputs.exists == 'true'
id: slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets[steps.team_name.outputs.team_name] }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ${{ steps.vars.outputs.message }}
}
}
]
}
on-escalation-and-team-querying-label:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
if: |
(github.event.label.name == 'Priority:P0' || github.event.label.name == 'Priority:P1' || github.event.label.name == '.Escalation') &&
contains(github.event.issue.labels.*.name, '.Team/Querying')
steps:
- name: Trigger PagerDuty Incident
env:
ROUTING_KEY: ${{ secrets.QUERYING_PAGERDUTY_INTEGRATION_KEY }}
ISSUE_TITLE: ${{ github.event.issue.title }}
LABEL_NAME: ${{ github.event.label.name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_URL: ${{ github.event.issue.html_url }}
REPO: ${{ github.repository }}
ALL_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
run: |
payload=$(jq -n \
--arg rk "$ROUTING_KEY" \
--arg dk "github-issue-$ISSUE_NUMBER" \
--arg sum "[$LABEL_NAME] $ISSUE_TITLE" \
--arg src "GitHub Issues - $REPO" \
--arg lbl "$ALL_LABELS" \
--arg url "$ISSUE_URL" \
'{
routing_key: $rk,
event_action: "trigger",
dedup_key: $dk,
payload: {
summary: $sum,
source: $src,
severity: "critical",
custom_details: { Labels: $lbl }
},
links: [{ href: $url, text: "View GitHub Issue" }]
}')
curl -X POST https://events.pagerduty.com/v2/enqueue \
-H 'Content-Type: application/json' \
-d "$payload"

View File

@@ -1,44 +0,0 @@
name: Validate OpenAPI
on:
push:
branches:
- 'master'
- 'release-**'
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
files-changed:
name: Check which files changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 3
outputs:
openapi: ${{ steps.changes.outputs.openapi }}
steps:
- uses: actions/checkout@v6
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
validate-openapi:
needs: files-changed
if: needs.files-changed.outputs.openapi == 'true'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- name: Generate OpenAPI spec
run: bun run generate-openapi
- name: Validate OpenAPI spec
run: bun run lint-openapi

View File

@@ -1,109 +0,0 @@
# this file uses MBA https://github.com/metabase/mba to spin up a Metabase
# instance then runs the test script in the MBA instance sharing the same
# network to interact with presto container
# TODO: get rid of MBA and use actions/prepare-backend instead
name: Kerberized Presto Integration Test
on:
pull_request:
push:
branches:
- master
- "release**"
- "feature**"
tags:
- "**"
jobs:
files-changed:
name: Check which files changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 3
outputs:
backend_presto_kerberos: ${{ steps.changes.outputs.backend_presto_kerberos }}
steps:
- uses: actions/checkout@v6
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
run-presto-kerberos-test:
needs: files-changed
if: needs.files-changed.outputs.backend_presto_kerberos == 'true'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
steps:
- name: Install babashka
run: >
mkdir -p /tmp/babashka-install \
&& cd /tmp/babashka-install \
&& curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install \
&& chmod +x install \
&& sudo ./install \
&& cd -
- name: Checkout Metabase repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check out Presto Kerberos Docker Compose
uses: actions/checkout@v6
with:
repository: metabase/presto-kerberos-docker
ref: master
token: ${{ secrets.GITHUB_TOKEN }}
path: presto-kerberos-docker
- name: Bring up Presto+Kerberos cluster
run: cd presto-kerberos-docker && docker compose up -d && cd ..
- name: Run Presto test query from command line (sanity check)
run: cd presto-kerberos-docker && ./test.sh && cd ..
# Since we are managing the Docker containers from the GitHub action container, we need to copy all the
# relevant resources now, into the resources dir for later consumption by the app
- name: Copy Presto SSL keystore to resources
run: docker cp presto-kerberos:/tmp/ssl_keystore.jks resources
- name: Copy krb5.conf file to resources
run: docker cp presto-kerberos:/etc/krb5.conf resources
- name: Copy client.keytab file to resources
run: docker cp presto-kerberos:/home/presto/client.keytab resources
- name: Checkout mba
uses: actions/checkout@v6
with:
repository: metabase/mba
ref: master
token: ${{ secrets.GITHUB_TOKEN }}
path: mba-src
- name: ls mba
run: ls -latr mba-src
- name: Symlink mba
run: cd mba-src && sudo ln -s "$(pwd)/src/main.clj" /usr/local/bin/mba && chmod +x /usr/local/bin/mba && cd ..
- name: Ensure mba
run: which mba
- name: Run Metabase via MBA
run: mba --mb . --data-db postgres-data -n example.com compose config > mba-compose.yml
- name: Adjust generated docker-compose config
uses: actions/github-script@v9
with:
script: | #js
const fs = require("fs");
const config = fs.readFileSync("mba-compose.yml", "utf8");
const newConfig = config.replace(
/name:\s+example.com/,
"name: example.com\n external: true",
);
fs.writeFileSync("mba-compose.yml", newConfig);
- name: Print compose config
run: cat mba-compose.yml
- name: Run mba
run: docker compose -f mba-compose.yml up -d
- name: Run test script in MBA instance
run: docker compose -f mba-compose.yml exec metabase .github/scripts/run-presto-kerberos-integration-test.sh

View File

@@ -1,47 +0,0 @@
name: Querying On-call PagerDuty Alert
on:
issues:
types:
- labeled
jobs:
notify-querying-on-call:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
if: |
(github.event.label.name == 'Priority:P0' || github.event.label.name == 'Priority:P1' || github.event.label.name == '.Escalation') &&
contains(github.event.issue.labels.*.name, '.Team/Querying')
steps:
- name: Trigger PagerDuty Incident
env:
ROUTING_KEY: ${{ secrets.QUERYING_PAGERDUTY_INTEGRATION_KEY }}
ISSUE_TITLE: ${{ github.event.issue.title }}
LABEL_NAME: ${{ github.event.label.name }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_URL: ${{ github.event.issue.html_url }}
REPO: ${{ github.repository }}
ALL_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
run: |
payload=$(jq -n \
--arg rk "$ROUTING_KEY" \
--arg dk "github-issue-$ISSUE_NUMBER" \
--arg sum "[$LABEL_NAME] $ISSUE_TITLE" \
--arg src "GitHub Issues - $REPO" \
--arg lbl "$ALL_LABELS" \
--arg url "$ISSUE_URL" \
'{
routing_key: $rk,
event_action: "trigger",
dedup_key: $dk,
payload: {
summary: $sum,
source: $src,
severity: "critical",
custom_details: { Labels: $lbl }
},
links: [{ href: $url, text: "View GitHub Issue" }]
}')
curl -X POST https://events.pagerduty.com/v2/enqueue \
-H 'Content-Type: application/json' \
-d "$payload"

View File

@@ -381,7 +381,8 @@ jobs:
dot-x-tag: ${{ startsWith(matrix.tag, 'v') }}
trigger-cloud-issues:
if: ${{ needs.check-version.outputs.kind == 'minor' || needs.check-version.outputs.kind == 'major' }}
# Only create issues in the cloud ops repository if this release happens in https://github.com/metabase/metabase.
if: ${{ github.repository == 'metabase/metabase' && (needs.check-version.outputs.kind == 'minor' || needs.check-version.outputs.kind == 'major') }}
needs: [push-tags, check-version]
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10

View File

@@ -1,39 +0,0 @@
name: Security Issue Slack Notification
on:
issues:
types:
- labeled
jobs:
on-issue-opened:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
if: github.event.label.name == 'SECURITY'
steps:
- name: Setting title
uses: actions/github-script@v9
id: vars
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
with:
script: |
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
- name: Send the issue ${{ github.event.issue.number }} to Slack
id: slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_SECURITY_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
}
}
]
}

View File

@@ -63,15 +63,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
semantic-search-tests-mariadb:
if: ${{ !inputs.skip }}
@@ -138,15 +129,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
semantic-search-tests-mysql:
if: ${{ !inputs.skip }}
@@ -220,15 +202,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
semantic-search-tests-postgres:
if: ${{ !inputs.skip }}
@@ -294,15 +267,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
# pgvector-on-the-app-db mode: the app db itself is a pgvector-capable Postgres and
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
@@ -358,15 +322,6 @@ jobs:
${{ matrix.job.test-args }}
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
semantic-search-tests-result:
needs:

View File

@@ -15,6 +15,8 @@ on:
jobs:
set-milestone:
name: Automatically set milestone
# Only run set-milestone in https://github.com/metabase/metabase since this is the only place milestones are used.
if: github.repository == 'metabase/metabase'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 30
permissions:

View File

@@ -1,75 +0,0 @@
name: Snowplow
on:
pull_request:
branches:
- '**'
jobs:
files-changed:
name: Check which files changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 3
outputs:
snowplow: ${{ steps.changes.outputs.snowplow }}
steps:
- uses: actions/checkout@v6
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
lint:
needs: files-changed
if: needs.files-changed.outputs.snowplow == 'true'
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Java
uses: actions/setup-java@v5.2.0
with:
distribution: temurin
java-version: 25
- name: Set up Babashka
uses: turtlequeue/setup-babashka@d78ec6570aea3b614bc695cafcceb82eb45c2af9 # v1.8.0
with:
babashka-version: 1.12.197
- name: Install igluctl
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/igluctl.zip" https://github.com/snowplow/igluctl/releases/download/0.13.0/igluctl_0.13.0.zip
echo "0eac19121fca1431dec44738ada8eacc4242811a95d015ee71e00deec2b4d748 $RUNNER_TEMP/igluctl.zip" | sha256sum -c -
unzip -q "$RUNNER_TEMP/igluctl.zip" -d "$RUNNER_TEMP/igluctl"
chmod +x "$RUNNER_TEMP/igluctl/igluctl"
echo "$RUNNER_TEMP/igluctl" >> "$GITHUB_PATH"
- name: Lint (igluctl)
# igluctl recursively parses every file as JSON, so it chokes on colocated
# *.md docs and on two schemas whose internal `self.version` doesn't match
# the filename version (pending Snowcat audit). Stage a filtered copy and
# patch only the `self.version` mismatch so igluctl still lints the rest
# of those schemas.
run: |
set -euo pipefail
LINT_ROOT="$RUNNER_TEMP/iglu-lint"
rm -rf "$LINT_ROOT"
mkdir -p "$LINT_ROOT"
cp -r snowplow/iglu-client-embedded/schemas "$LINT_ROOT/"
find "$LINT_ROOT" -name '*.md' -delete
# TODO(BOT-1403): drop these patches once the Snowcat audit fixes the
# internal `self.version` mismatch on these two schemas. We rewrite
# only `self.version` in the staged copy so the file's path version
# matches what igluctl expects; every other lint rule still runs.
# Each `sed` invocation uses the schema's wrong/right pair, so it
# silently no-ops once the underlying schema is fixed.
sed -i 's/"version": "1-0-1"/"version": "1-0-2"/' \
"$LINT_ROOT/schemas/com.metabase/embed_share/jsonschema/1-0-2"
sed -i 's/"version": "1-0-0"/"version": "1-0-1"/' \
"$LINT_ROOT/schemas/com.metabase/serialization/jsonschema/1-0-1"
igluctl lint "$LINT_ROOT/schemas"
- name: Lint (Metabase schema rules)
run: ./bin/lint-snowplow-schemas.bb
- name: Test linter rules
run: ./bin/lint-snowplow-schemas-test.bb

View File

@@ -10,6 +10,7 @@ on:
- "**/package.json"
- ".github/workflows/snyk.yml"
- ".github/scripts/write-poms.sh"
- ".github/scripts/fix-yarn-lock-aliases.ts"
schedule:
- cron: "0 5 * * *"
@@ -58,18 +59,22 @@ jobs:
# Snyk doesn't support bun.lock yet, so we generate yarn.lock and scan that.
# Bun has a bug where it normalizes "< 3.0.0" to "< 3" in alias entries
# but not in dependency specs, causing Snyk to fail matching dependencies.
# The sed commands fix this inconsistency. They continue on error to let
# Snyk report parsing issues directly rather than failing on the workaround.
# The sed commands fix this inconsistency. Bun also names aliased entries
# after the aliased package instead of the alias, which the script fixes.
# Both continue on error to let Snyk report parsing issues directly rather
# than failing on the workaround.
- name: Generate yarn.lock
if: matrix.project.file == 'yarn.lock'
run: |
bun install --yarn
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
- name: Generate release/yarn.lock
if: matrix.project.file == 'release/yarn.lock'
run: |
bun install --yarn
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
working-directory: release
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
- name: Run snyk test

View File

@@ -1,114 +0,0 @@
# .github/workflows/team-issues-slack-notification.yml
#
# Posts newly team-labeled bugs to the team's Slack channel (e.g.
# #team-graphy-bugs), now including a link to the synced Linear issue.
#
# Changes from the current version:
# - Checks out .github/scripts so we can reuse find-linear-issue.js
# - The message-building step now queries Linear (via attachmentsForURL)
# with a short retry, and appends "Linear: <link|ID>" when found
# - Degrades gracefully: if no Linear issue is attached (or the API is
# down), the message posts without the Linear issue link
name: Team Issues Slack Notification
on:
issues:
types:
- labeled
jobs:
on-team-label-added:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
if: |
contains(github.event.label.name, '.Team/') &&
( contains(github.event.issue.labels.*.name, 'Type:Bug') ||
contains(github.event.issue.labels.*.name, 'flaky-test-fix') )
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
- name: Build Slack message (with Linear link if available)
uses: actions/github-script@v9
id: vars
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
with:
script: |
const { findLinearIssue } = require('./.github/scripts/find-linear-issue.js');
const title = context.payload.issue.title;
const issueUrl = context.payload.issue.html_url;
let text = title + '\n' + issueUrl;
// The Linear issue is usually already synced by the time the
// team label lands at triage, but retry briefly just in case.
try {
const delaysSeconds = [0, 15, 30];
let result = null;
for (const delay of delaysSeconds) {
if (delay) await new Promise((r) => setTimeout(r, delay * 1000));
result = await findLinearIssue({
githubIssueUrl: issueUrl,
linearApiKey: process.env.LINEAR_API_KEY,
});
if (result.success) break;
}
if (result && result.success) {
const id = result.issue; // e.g. "GIT-1234"
text += `\nLinear: <https://linear.app/metabase/issue/${id}|${id}>`;
} else {
core.info(`No Linear issue found for ${issueUrl}; posting without link.`);
}
} catch (e) {
// Never block the notification on a Linear lookup failure
core.warning(`Linear lookup failed: ${e.message}`);
}
core.setOutput('message', JSON.stringify(text));
- name: Extracting team name
id: team_name
env:
LABEL_NAME: ${{ github.event.label.name }}
run: |
team_label="${LABEL_NAME//&/$}"
echo "$team_label"
team_name=${team_label#*.Team/}
echo "$team_name"
echo "team_name=${team_name^^}_SLACK_ISSUES_WEBHOOK_URL" >> "$GITHUB_OUTPUT"
- name: Check webhook secret exists
id: check_secret
env:
WEBHOOK_URL: ${{ secrets[steps.team_name.outputs.team_name] }}
run: |
if [ -z "$WEBHOOK_URL" ]; then
echo "Secret ${{ steps.team_name.outputs.team_name }} is not set, skipping Slack notification"
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "exists=true" >> "$GITHUB_OUTPUT"
fi
- name: Send the issue ${{ github.event.issue.number }} to Slack
if: steps.check_secret.outputs.exists == 'true'
id: slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets[steps.team_name.outputs.team_name] }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ${{ steps.vars.outputs.message }}
}
}
]
}

View File

@@ -25,7 +25,6 @@ on:
- sqlserver
permissions:
id-token: write
contents: read
checks: write
actions: read
@@ -64,15 +63,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -124,15 +114,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -174,15 +155,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -228,15 +200,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -283,15 +246,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -328,15 +282,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -382,15 +327,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -434,15 +370,6 @@ jobs:
granular-rerun: 'false'
test-args: >-
:only-tags [:mb/transforms-python-test]
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
with:
input-path: ./target/junit/
output-name: ${{ github.job }}
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner

View File

@@ -1,20 +0,0 @@
name: Whitespace
on:
push:
branches:
- 'master'
- 'release-**'
pull_request:
merge_group:
jobs:
whitespace-linter:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
- run: clojure -T:whitespace-linter lint :whitespace-linter/progress-bar false
name: Run Whitespace Linter

View File

@@ -1,35 +0,0 @@
name: YAML
on:
push:
branches:
- 'master'
- 'release-**'
pull_request:
jobs:
files-changed:
name: Check which files changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 3
outputs:
yaml: ${{ steps.changes.outputs.yaml }}
steps:
- uses: actions/checkout@v6
- name: Test which files changed
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:
token: ${{ github.token }}
filters: .github/file-paths.yaml
yaml-linter:
needs: files-changed
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
if: needs.files-changed.outputs.yaml == 'true'
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Prepare front-end environment
uses: ./.github/actions/prepare-frontend
- run: bun run lint-yaml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -223,6 +223,7 @@ Metabase's reference documentation.
- [SSO](./embedding/modular-embedding.md)
- [Guest](./embedding/guest-embedding.md)
- [SDK](./embedding/sdk/introduction.md)
- [Custom visualizations](./embedding/custom-visualizations.md)
- [Full app embedding](./embedding/full-app-embedding.md)
- [Securing embeds](./embedding/securing-embeds.md)
- [AI agent resources](./embedding/ai-agent-resources.md)

6364
docs/api.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ These policies determine how long cached results will remain valid.
{% include plans-blockquote.html feature="Duration caching policy" %}
Invalidate and clear the cache after a certain number of hours. When someone runs a query, Metabase will first check whether it has cached the results, and whether those results are still valid. If not, Metabase runs the query against the database and caches the results. These cached results will remain valid for the duration you've set.
Invalidate and clear the cache after a certain amount of time (seconds, minutes, hours, or days). When someone runs a query, Metabase will first check whether it has cached the results, and whether those results are still valid. If not, Metabase runs the query against the database and caches the results. These cached results will remain valid for the duration you've set.
### Schedule caching policy

View File

@@ -222,6 +222,8 @@ config:
llm-metabot-provider: anthropic/claude-sonnet-4-6
llm-mistral-api-base-url: https://api.mistral.ai/v1
llm-mistral-api-key: null
llm-moonshot-api-base-url: https://api.moonshot.ai/v1
llm-moonshot-api-key: null
llm-openai-api-base-url: https://api.openai.com
llm-openai-api-key: null
llm-openai-model: gpt-5.4
@@ -353,4 +355,5 @@ config:
usage-metadata-schedule: 0 0 2 * * ? *
use-native-pivot-tables: false
user-visibility: all
warehouse-allowed-networks: null
```

View File

@@ -1390,7 +1390,7 @@ Maximum tokens for LLM responses.
- Default: `anthropic/claude-sonnet-4-6`
- [Configuration file name](./config-file.md): `llm-metabot-provider`
The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-5.4`, `openrouter/anthropic/claude-haiku-4.5`.
The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-5.4`, `moonshot/kimi-k3`, `openrouter/anthropic/claude-haiku-4.5`.
### `MB_LLM_MISTRAL_API_BASE_URL`
@@ -1408,6 +1408,22 @@ The Mistral API base URL used for Chat Completions.
The Mistral API Key.
### `MB_LLM_MOONSHOT_API_BASE_URL`
- Type: string
- Default: `https://api.moonshot.ai/v1`
- [Configuration file name](./config-file.md): `llm-moonshot-api-base-url`
The Moonshot AI API base URL used for Chat Completions. Repoint this to use the `.cn` platform; keys are not interchangeable between the two.
### `MB_LLM_MOONSHOT_API_KEY`
- Type: string
- Default: `null`
- [Configuration file name](./config-file.md): `llm-moonshot-api-key`
The Moonshot AI API Key.
### `MB_LLM_OPENAI_API_BASE_URL`
- Type: string
@@ -2667,6 +2683,19 @@ When enabled, run pivot queries as a single native GROUPING SETS query on driver
Note: Sandboxed users will never see suggestions.
### `MB_WAREHOUSE_ALLOWED_NETWORKS`
- Type: keyword
- Default: `null`
Controls which networks Metabase may connect to for warehouse connections.
Options:
- external-only (only globally routable public addresses)
- allow-private (external + private networks but NOT loopback or link-local)
- allow-all (no restrictions).
Defaults to external-only on Metabase Cloud and allow-all when self-hosted.
Also covers the SSH tunnel host and the database auth-provider URLs.
## Other environment variables
The following environment variables can only be set via the environment. They cannot be set by the configuration file.

View File

@@ -43,7 +43,6 @@ For Slack subscriptions, you can set up a subscription for a channel (like #gene
You can also send subscriptions to private channels, but that requires additional configuration, see [Sending alerts and subscriptions to private Slack channels](../configuring-metabase/slack.md#sending-alerts-and-subscriptions-to-private-slack-channels).
If you rename the subscribed channel in Slack, you'll need to update the subscription to point to the new Slack channel name.
### Frequency and timing
@@ -134,7 +133,7 @@ You can view a list of all alerts and dashboard subscriptions that people have s
{% include plans-blockquote.html feature="Dashboard subscription filter customization" %}
You can customize which filter values to apply to each dashboard subscription. That way you can send an email (or Slack message) with the dashboard results to different groups of people, with different dashboard filters applied for each group. You only need to maintain one dashboard, which you can use to send results relevant to each subscriber. For example, send a dashboard filtered for "Marketing" department and "This week" date weekly to a marketing lead, and send the dashboard filtered for all departments and "This quarter" to the CEO.
You can customize which filter values to apply to each dashboard subscription. That way you can send an email (or Slack message) with the dashboard results to different groups of people, with different dashboard filters applied for each group. You only need to maintain one dashboard, which you can use to send results relevant to each subscriber. For example, send a dashboard filtered for "Marketing" department and "This week" date weekly to a marketing lead, and send the dashboard filtered for all departments and "This quarter" to the CEO.
### Setting filter values
@@ -158,9 +157,14 @@ See [Remove Metabase branding from exports](../questions/exporting-results.md#re
Since people viewing embedded dashboards likely don't have direct access to your Metabase, Metabase will omit the links to Metabase items in subscriptions sent from embedded dashboards. That way people don't get broken links in their email. See [embedded dashboards](../embedding/components.md).
## Custom visualizations fall back to the default chart
Nobody's signed in when Metabase renders a subscription, so a card that uses a [custom visualization](../questions/visualizations/custom.md) falls back to the default visualization for its results.
## Further reading
- [Alerts](../questions/alerts.md)
- [Setting up email](../configuring-metabase/email.md)
- [Setting up Slack](../configuring-metabase/slack.md)
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
- [Custom visualizations](../questions/visualizations/custom.md)

View File

@@ -420,9 +420,9 @@ For uploading and managing plugins, see [Custom visualizations](../questions/vis
## Versioning and compatibility
The Custom Visualizations SDK works with Metabase 1.62 and newer. Declare the versions your plugin supports with `metabase.version` in `metabase-plugin.json`, using [npm semver range](https://github.com/npm/node-semver#ranges) syntax — for example, `">=1.62 <1.64"`. Keep the range closed on both ends: an open-ended range like `">=1.62.0"` claims compatibility with future Metabase versions your plugin has never run against. Write the range against the full version number (`">=1.62 <1.64"`), not a bare major version (`">=62 <64"`), which won't match.
Metabase runs two compatibility checks on a plugin, and neither one blocks anything (the plugin uploads and runs either way). When a check fails, Metabase shows a [compatibility warning](../questions/visualizations/custom.md#compatibility-warnings-are-heads-ups-not-errors) on the **Manage visualizations** page.
If a plugin's declared range doesn't include the Metabase version it's installed on — at upload time, or later after a Metabase upgrade — the plugin still uploads and runs, and Metabase shows a warning on the **Manage visualizations** page.
The second check is the Metabase version, which you declare yourself. Set `metabase.version` in `metabase-plugin.json` to an [npm semver range](https://github.com/npm/node-semver#ranges) like `">=1.62 <1.64"`, closed on both ends. Metabase versions are [license.major.minor](https://www.metabase.com/version-support), so include the license prefix: a bare `">=62 <64"` won't match anything.
## Custom visualization limitations
@@ -437,15 +437,15 @@ Metabase runs plugin code in an isolated sandbox, so a visualization works only
- **Navigation and the rest of the app**: history changes, the host page's URL and referrer, and any DOM outside the plugin's own container.
- **Unsafe DOM and timing APIs**: `document.write`, `execCommand`, constructable stylesheets, raw HTML parsers (`DOMParser`, `setHTMLUnsafe`, `XSLTProcessor`), and resource-timing APIs that expose other requests the page has made.
### When embedding, custom visualizations are only available in the SDK
### Custom visualizations only work in authenticated embeds
The [Modular embedding SDK](../embedding/sdk/introduction.md) renders custom visualizations when you allowlist them with the [`allowedCustomVisualizations` prop](../embedding/sdk/config.md#custom-visualizations) on `MetabaseProvider`.
[Modular embeds](../embedding/modular-embedding.md) can render custom visualizations when you allowlist them with the `allowedCustomVisualizations` setting, both with web components and with the [React SDK](../embedding/sdk/introduction.md). See [Custom visualizations in embeds](../embedding/custom-visualizations.md).
Other embedding types don't render custom visualizations. In [modular embedding](../embedding/modular-embedding.md) with web components and [public links](../embedding/public-links.md), any card that uses a custom visualization falls back to the default visualization for the query's results.
In [guest embeds](../embedding/introduction.md) and [public links](../embedding/public-links.md), any question that uses a custom visualization falls back to the default visualization for the query's results.
### Custom visualizations don't render in exports and subscriptions
Static renders—like charts in alerts or dashboard subscriptions—fall back to a default visualization for the card's data shape.
Static renders (like charts in alerts or dashboard subscriptions) fall back to a default visualization for the query's data shape.
## Example plugins

View File

@@ -106,4 +106,5 @@ If you're using the [modular embedding SDK](./sdk/introduction.md), you can prov
## Further reading
- [Appearance](./appearance.md)
- [Custom visualizations in embeds](./custom-visualizations.md)
- [Modular embedding SDK](./sdk/introduction.md).

View File

@@ -0,0 +1,95 @@
---
title: Custom visualizations in embeds
summary: Enable custom visualizations in your embeds by adding them to your allowlist.
---
# Custom visualizations in embeds
{% include plans-blockquote.html feature="Custom visualizations" %}
Authenticated [modular embeds](./modular-embedding.md) can render [custom visualizations](../questions/visualizations/custom.md), whether you're embedding with web components or with the [React SDK](./sdk/introduction.md).
- [An admin has to turn on custom visualizations first](#an-admin-has-to-turn-on-custom-visualizations-first)
- [Add custom visualizations to your allowlist](#add-custom-visualizations-to-your-allowlist)
- [Set a Content Security Policy in your app](#set-a-content-security-policy-in-your-app)
- [Custom visualizations don't work in guest embeds](#custom-visualizations-dont-work-in-guest-embeds)
## An admin has to turn on custom visualizations first
Before an embed can render a custom visualization, an admin needs to [turn on custom visualizations](../questions/visualizations/custom.md#enabling-custom-visualizations) in your Metabase and upload the visualization. If the visualization doesn't exist in your Metabase, allowlisting its name in your app won't do anything.
To build a custom visualization, check out [Building custom visualizations](../developers-guide/custom-visualizations.md).
## Add custom visualizations to your allowlist
Embeds don't load custom visualizations by default. You have to list each visualization you want in the `allowedCustomVisualizations` allowlist. Any question that uses a custom visualization that isn't on the allowlist will fall back to the default visualization for that query's results.
Each entry in the allowlist is the custom visualization's name, prefixed with `custom:`. A custom visualization named `Calendar Heatmap` becomes `"custom:Calendar Heatmap"`. The name comes from the `name` in the visualization's plugin manifest. You can look it up in your Metabase under **Admin** > **Settings** > **Custom visualizations** > **Manage visualizations**.
Names are case-sensitive, so `"custom:calendar heatmap"` won't match a visualization named `Calendar Heatmap`. If an entry on your allowlist doesn't match an uploaded visualization, Metabase logs a warning to the browser console.
Where the allowlist goes depends on how you're embedding:
- [Web components](#web-components-allowlist-for-custom-visualizations)
- [React SDK](#react-sdk-allowlist-for-custom-visualizations)
### Web components allowlist for custom visualizations
`allowedCustomVisualizations` is a [page-level config](./modular-embedding.md#page-level-config), not an attribute on `<metabase-dashboard>` or `<metabase-question>`. The allowlist applies to every component on the page.
Add `allowedCustomVisualizations` to `defineMetabaseConfig()`:
```html
<!-- Load embedding library -->
<!-- REPLACE WITH YOUR METABASE URL HERE -->
<script defer src="https://your-metabase-url/app/embed.js"></script>
<!-- Embedding configuration -->
<script>
function defineMetabaseConfig(config) {
window.metabaseConfig = config;
}
</script>
<script>
defineMetabaseConfig({
instanceUrl: "https://your-metabase-url",
allowedCustomVisualizations: ["custom:Calendar Heatmap", "custom:Thumbs"],
});
</script>
<metabase-dashboard dashboard-id="1"></metabase-dashboard>
```
If you create your embed through the [embed wizard](./modular-embedding.md#create-a-new-embed), Metabase fills in the allowlist with the custom visualizations that the dashboard or question you picked already uses, so the generated snippet works as-is.
### React SDK allowlist for custom visualizations
Pass the `allowedCustomVisualizations` prop to `MetabaseProvider`. Like the page-level config for web components, the allowlist is global: it applies to every embedded component under the provider, not to one question or dashboard.
```typescript
{% include_file "{{ dirname }}/sdk/snippets/config/config-with-custom-visualizations.tsx" snippet="example" %}
```
## Set a Content Security Policy in your app
A custom visualization runs third-party JavaScript in your app. Metabase runs that code in an isolated sandbox, so a visualization can't reach the rest of your app or make network requests. The sandbox doesn't block passive image loads, though: a visualization can still trigger outbound requests through `<img>` tags or CSS `url()`.
To limit where custom visualizations can load images from, set a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) with an `img-src` allowlist in your app. The core Metabase app sets this CSP with [Restrict image domains](../configuring-metabase/settings.md#restrict-image-domains), but you should also set a CSP in your app.
## Custom visualizations don't work in guest embeds
Custom visualizations only work when Metabase knows who's viewing the embed. [Guest embeds](./guest-embedding.md) ignore `allowedCustomVisualizations` (and log a warning to the console), and fall back to the default visualization.
Any embed with a signed-in person can load custom visualizations, including embeds you're previewing locally with an API key or your existing Metabase session. See [Authentication](./authentication.md).
### Public links, subscriptions, and alerts fall back to the default visualization
Nobody signs in to view a [public link](./public-links.md), and nobody's signed in when Metabase renders a [dashboard subscription or alert](../questions/alerts.md), so a question that uses a custom visualization falls back to the default visualization for its results in both cases.
## Further reading
- [Custom visualizations](../questions/visualizations/custom.md)
- [Building custom visualizations](../developers-guide/custom-visualizations.md)
- [Modular embedding](./modular-embedding.md)
- [Modular embedding components](./components.md)

View File

@@ -533,6 +533,7 @@ Guest embeds can't take advantage of:
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
- [Query builder](../questions/query-builder/editor.md)
- [AI chat](./sdk/ai-chat.md)
- [Custom visualizations](./custom-visualizations.md)
For those features, check out [Modular embedding with SSO](./modular-embedding.md).

View File

@@ -71,6 +71,7 @@ All SSO options require a Pro or Enterprise plan.
| [Collection browser](./sdk/collections.md) | ✅ | ❌ |
| Advanced [Tenant](./tenants.md) and [permissions](../permissions/embedding.md) management | ✅ | ❌ |
| [Advanced theming](./appearance.md) | ✅ | ❌ |
| [Custom visualizations](./custom-visualizations.md) | ✅ | ❌ |
| [Usage analytics](../usage-and-performance-tools/usage-analytics.md) | ✅ | ❌ |
| Customize layouts and behavior with [plugins](./sdk/plugins.md) | ✅ | ❌ |
| [Locked filters](./guest-embedding.md#locked-parameters)\*\*\* | ❌ | ✅ |
@@ -106,7 +107,7 @@ For information about the anonymous usage data Metabase collects from embedded c
## Embedding limitations
- Currently, you can't embed [documents](../documents/introduction.md) (though you can create [public documents](./public-links.md)).
- Only the [Modular embedding SDK](./sdk/introduction.md) renders [custom visualizations](../questions/visualizations/custom.md), and only ones you allowlist with the [`allowedCustomVisualizations` prop](./sdk/config.md#custom-visualizations). In other embedding types, any card that uses a custom visualization falls back to the default visualization for the query's results.
- [Modular embeds](./modular-embedding.md) that use SSO can render [custom visualizations](../questions/visualizations/custom.md), but only the custom visualizations you add to your [allowlist](./custom-visualizations.md). Guest embeds fall back to the default visualization.
## Further reading

View File

@@ -196,6 +196,8 @@ To define the configuration that applies to every embed on the page, use the `de
- `pluginsConfig` : plugins to customize the behavior of embedded components. Use the `handleLink` function to customize what happens when people click a link in your embedded questions and dashboards. For details on the `handleLink` API, including code examples, see [`handleLink` plugin](./sdk/plugins.md#handlelink).
- `allowedCustomVisualizations: ["custom:Calendar Heatmap"]` (optional): the [custom visualizations](./custom-visualizations.md) that the components on the page are allowed to load. Not available in guest embeds.
## Authentication
For authentication setup including API keys for local testing, SSO with JWT/SAML, and cross-domain configuration, see [Modular embedding - authentication](./authentication.md).

View File

@@ -94,6 +94,8 @@ If you want to embed your question or dashboard as an iframe in a simple web pag
To customize the appearance of your question or dashboard, you can update the link in the `src` attribute with [public embed parameters](#public-embed-parameters).
Nobody signs in to view a public link or public embed, so a question that uses a [custom visualization](../questions/visualizations/custom.md) falls back to the default visualization for its results. To render custom visualizations, you'll need an authenticated [modular embed](./modular-embedding.md) with the visualization on your [allowlist](./custom-visualizations.md).
## Public embed parameters
To apply appearance or filter settings to your public embed, you can add parameters to the end of the link in your iframe's `src` attribute.
@@ -195,3 +197,4 @@ Admins can see all publicly shared questions, dashboards, documents, and actions
- [Publishing data visualizations to the web](https://www.metabase.com/learn/metabase-basics/embedding/charts-and-dashboards).
- [Customizing Metabase's appearance](../configuring-metabase/appearance.md).
- [Embedding introduction](../embedding/start.md).
- [Custom visualizations in embeds](./custom-visualizations.md).

View File

@@ -34,18 +34,7 @@ To pass a theme, use `defineMetabaseTheme`. See [Reuse a saved theme in the SDK]
## Custom visualizations
The SDK can render [custom visualizations](../../questions/visualizations/custom.md). To allow custom visualizations in the embed, pass an allowlist of the custom visualizations to the `allowedCustomVisualizations` prop on `MetabaseProvider`:
```typescript
{% include_file "{{ dirname }}/snippets/config/config-with-custom-visualizations.tsx" snippet="example" %}
```
Only the custom visualizations you list will load. Each entry is the visualization's name (the manifest `name`, which you can find under **Admin** > **Settings** > **Custom visualizations** > **Manage visualizations**), prefixed with `custom:`. For example, a custom visualization named `Calendar Heatmap` becomes `"custom:Calendar Heatmap"`. Names are case-sensitive, so `"custom:calendar heatmap"` won't match a visualization named `Calendar Heatmap`.
Omitting the prop, or passing an empty array, turns off custom visualizations. Cards that use a custom visualization will fall back to the default visualization for the query's results. If you allowlist a name that doesn't match an installed custom visualization, the SDK logs a warning to the console and falls back to the default visualization.
For security, the SDK runs each custom visualization's code in an isolated sandbox, so a visualization can't reach your app or make network requests. The sandbox doesn't block passive image loads, though. A visualization can still trigger outbound requests through `<img>` tags or CSS `url()`. To limit where custom visualizations can load images from, set a Content Security Policy with an `img-src` allowlist in your app (the core Metabase app does this with [Restrict image domains](../../configuring-metabase/settings.md#restrict-image-domains)). [Only add visualizations you trust](../../questions/visualizations/custom.md#only-add-visualizations-you-trust).
The SDK can render [custom visualizations](../../questions/visualizations/custom.md) that you allowlist with the `allowedCustomVisualizations` prop on `MetabaseProvider`. See [Custom visualizations in embeds](../custom-visualizations.md).
## Global event handlers

View File

@@ -110,6 +110,7 @@ Start with one of the quickstarts, then see these pages for more info on compone
- [Actions](./actions.md)
- [Appearance](../appearance.md)
- [Collections](./collections.md)
- [Custom visualizations](../custom-visualizations.md)
- [Plugins](./plugins.md)
- [Config](./config.md)
- [Versioning](./version.md)

View File

@@ -42,6 +42,10 @@ Every `<metabase-question>` attribute, the `StaticQuestion` and `InteractiveQues
Guest embedding is a secure way to embed charts and dashboards. Guest embeds are view-only; people won't be able to drill-through charts and tables.
### [Custom visualizations in embeds](./custom-visualizations.md)
Allowlist the custom visualizations that your embedded components can load. Available in modular embeds that use SSO, with web components or the React SDK.
### [Translating embeds](./translations.md)
Upload a translation dictionary to translate questions and dashboards in modular embeds.

View File

@@ -162,9 +162,14 @@ See [Remove Metabase branding from exports](./exporting-results.md#remove-metaba
Since people viewing embedded questions likely don't have direct access to your Metabase, Metabase omits links to Metabase items in alerts sent from embedded questions. That way people don't get broken links in their email.
## Custom visualizations fall back to the default chart
Nobody's signed in when Metabase renders an alert, so a question that uses a [custom visualization](./visualizations/custom.md) falls back to the default visualization for its results.
## Further reading
- [Dashboard subscriptions](../dashboards/subscriptions.md)
- [Setting up email](../configuring-metabase/email.md)
- [Setting up Slack](../configuring-metabase/slack.md)
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
- [Custom visualizations](./visualizations/custom.md)

View File

@@ -38,7 +38,7 @@ Once you've [built the custom visualization](../../developers-guide/custom-visua
3. Click **Add visualization**.
- Bundles must be smaller than 5 MiB.
- Each custom visualization lists the Metabase versions it supports (for example, "Requires Metabase >=1.62"). If your Metabase version isn't in that range, Metabase rejects the upload and tells you which version the visualization needs.
- Each custom visualization lists the Metabase versions it supports (for example, "Requires Metabase >=1.62 <1.64"). If your Metabase isn't in that range, the visualization still uploads and works, but Metabase adds a [compatibility warning](#compatibility-warnings-are-heads-ups-not-errors).
- The **Manage visualizations** page shows each custom visualization's icon, name, the first eight characters of the bundle's hash, and its required Metabase version range, so you can tell which version is installed.
## Using a custom visualization
@@ -55,11 +55,11 @@ Custom visualizations behave like built-in charts in most places:
- **Dark mode.** Custom visualizations that use Metabase's colors adapt to [dark mode](../../people-and-groups/account-settings.md#theme) automatically.
- **Icons.** A custom visualization shows its own icon in the visualization picker, and questions that use it show that icon in collections and bookmarks.
### Custom visualizations in embeds only work when using the SDK
### Custom visualizations in embeds only work when people are signed in
The [Modular embedding SDK](../../embedding/sdk/introduction.md) can render custom visualizations. You allowlist the custom visualizations you want to load with the [`allowedCustomVisualizations` prop](../../embedding/sdk/config.md#custom-visualizations) on `MetabaseProvider`.
[Modular embeds](../../embedding/modular-embedding.md) that use SSO can render custom visualizations, whether you embed with web components or with the [React SDK](../../embedding/sdk/introduction.md). You allowlist the custom visualizations you want to load with the `allowedCustomVisualizations` setting. See [Custom visualizations in embeds](../../embedding/custom-visualizations.md).
Other embedding types don't render custom visualizations. In [modular embedding](../../embedding/modular-embedding.md) with web components, [guest and static embeds](../../embedding/introduction.md), and [public links](../../embedding/public-links.md), any card that uses a custom visualization falls back to the default visualization for the query's results.
Embeds without an authenticated person don't render custom visualizations. In [guest and static embeds](../../embedding/introduction.md) and [public links](../../embedding/public-links.md), any card that uses a custom visualization falls back to the default visualization for the query's results.
## Managing custom visualizations
@@ -69,6 +69,13 @@ _Admin > Settings > Custom visualizations > Manage visualizations_
- **Replace a bundle.** Upload a new `.tgz` to ship an updated version of a custom visualization. The new bundle's manifest `name` _must_ match the existing visualization's identifier, so questions that already use the visualization keep working.
- **Remove a visualization.** Cards that used the custom viz fall back to the default visualization.
### Compatibility warnings are heads-ups, not errors
Metabase flags a custom visualization on the **Manage visualizations** page when:
- **The SDK version doesn't match.** The visualization was built with a version of the Custom Visualizations SDK that your Metabase wasn't tested against.
- **The Metabase version doesn't match.** A visualization that was in range when you uploaded it can fall out of range when you upgrade Metabase, so this warning can show up on a visualization that's been working for months.
## Exports
- **Dashboard subscriptions and alerts don't use custom visualizations**. Cards that use custom visualizations will fall back to a default visualization for the card's data shape.

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -67,7 +67,7 @@ If you can’t solve your problem using the troubleshooting guides:
[known-issues-models]: https://github.com/metabase/metabase/labels/Querying%2FModels
[limit-data-learn]: https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/making-dashboards-faster#ask-for-less-data
[materialize-views-learn]: https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/making-dashboards-faster#materialize-views-create-new-tables-to-store-query-results
[model-button-image]: https://www.metabase.com/learn/images/models/model-icon.png
[model-button-image]: ./images/model-icon.png
[model-docs]: ../data-modeling/models.md
[replicate-database-learn]: https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/making-dashboards-faster#replicate-your-database
[sql-explain-learn]: https://www.metabase.com/learn/sql/working-with-sql/sql-best-practices#explain

View File

@@ -63,16 +63,17 @@ export const getUnpinnedSection = () => {
export const openPinnedItemMenu = (name: string) => {
cy.log(`open pinned item menu: ${name}`);
// Hovering a pinned item can trigger an async re-render (e.g. a pinned
// metric runs its query), which detaches a chained `.within()` subject. Hover
// first, then re-query the actions button so the click acts on a settled
// element and Cypress can retry on detach.
// Hovering a pinned item can trigger an async re-render, which replaces the
// hovered node and drops its :hover state, hiding the actions button again.
// Hover first so the button settles, then realClick: the real pointer
// movement re-applies :hover to the current node, so the click cannot race
// the visibility toggle.
getPinnedSection().findByText(name).closest("a").realHover();
getPinnedSection()
.findByText(name)
.closest("a")
.findByLabelText("Actions")
.click();
.realClick();
};
export const openUnpinnedItemMenu = (name: string) => {

File diff suppressed because it is too large Load Diff

View File

@@ -209,7 +209,7 @@ describe("scenarios > admin > performance > caching", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
openSidebarCacheStrategyForm("question");
H.selectCacheStrategy(/Duration/);
cy.findByLabelText(/Cache duration/).type("99");
cy.findByRole("spinbutton", { name: /Cache duration/ }).type("99");
saveCacheStrategyForm();
cy.log("Configure Orders, Count with Adaptive");
@@ -227,7 +227,10 @@ describe("scenarios > admin > performance > caching", () => {
cy.log("Clicking Duration: 99h opens its form with duration selected");
cy.findByTestId("cache-config-table").contains("Duration: 99h").click();
H.cacheStrategySelect().should("have.value", "Duration");
cy.findByLabelText(/Cache duration/).should("have.value", "99");
cy.findByRole("spinbutton", { name: /Cache duration/ }).should(
"have.value",
"99",
);
cy.log("Close the sidesheet via ESC");
cy.get("body").type("{esc}");

View File

@@ -55,13 +55,18 @@ describe("scenarios > collection pinned items overview", () => {
cy.intercept("POST", "/api/card/**/query").as("getCardQuery");
cy.intercept("GET", "/api/**/items?pinned_state*").as("getPinnedItems");
cy.intercept("GET", "/api/**/items?models*").as("getCollectionItems");
});
it("should be able to pin a dashboard", () => {
it("should be able to pin a dashboard and keep it in the contents list", () => {
openRootCollection();
H.openUnpinnedItemMenu(DASHBOARD_NAME);
H.popover().findByText("Pin this").click();
cy.wait("@getPinnedItems");
cy.wait(["@getPinnedItems", "@getCollectionItems"]);
cy.findByTestId("collection-table")
.findByText(DASHBOARD_NAME)
.should("be.visible");
H.getPinnedSection().within(() => {
cy.icon("dashboard").should("be.visible");
@@ -227,17 +232,19 @@ describe("scenarios > collection pinned items overview", () => {
// on specific elements rather than truly simulating mouse movements across the screen
H.dragAndDrop("draggingViz", "pinnedItems");
cy.findByTestId("collection-table")
.findByText("Orders, Count, Grouped by Created At (year)")
.should("not.exist");
cy.findByTestId("pinned-items")
.findByText("Orders, Count, Grouped by Created At (year)")
.should("exist");
// Pinned items stay in the contents list below the pinned section
cy.wait("@getCollectionItems");
cy.findByTestId("collection-table")
.findByText("Orders, Count, Grouped by Created At (year)")
.should("be.visible");
});
});
const openRootCollection = () => {
cy.visit("/collection/root");
cy.wait("@getPinnedItems");
cy.wait(["@getPinnedItems", "@getCollectionItems"]);
};

View File

@@ -1133,6 +1133,173 @@ describe("scenarios > collection items listing", () => {
const PAGE_SIZE = 25;
describe("search", () => {
function interceptSearch(query, alias) {
cy.intercept({
method: "GET",
pathname: "/api/collection/root/items",
query: {
q: query,
},
}).as(alias);
}
beforeEach(() => {
archiveAll();
H.createCollection({
name: "Quarterly revenue",
});
cy.signIn("normal");
H.createQuestion({
name: "Customer health",
collection_position: null,
query: TEST_QUESTION_QUERY,
});
cy.signInAsAdmin();
});
it("should search by item name and last editor name, clear results, and show an empty state", () => {
visitRootCollection();
interceptSearch("revenue", "searchByName");
cy.findByLabelText("Search items in this collection")
.should("have.value", "")
.type("revenue")
.should("have.value", "revenue");
cy.wait("@searchByName").then(({ request }) => {
const searchParams = new URL(request.url).searchParams;
expect(searchParams.get("q")).to.equal("revenue");
expect(searchParams.get("offset")).to.equal("0");
});
cy.findByTestId("collection-table")
.should("contain", "Quarterly revenue")
.and("not.contain", "Customer health");
cy.findByLabelText("Clear search").click();
cy.findByLabelText("Search items in this collection").should(
"have.value",
"",
);
cy.findByTestId("collection-table")
.should("contain", "Quarterly revenue")
.and("contain", "Customer health");
interceptSearch("Robert", "searchByEditor");
cy.findByLabelText("Search items in this collection")
.type("Robert")
.should("have.value", "Robert");
cy.wait("@searchByEditor").then(({ request }) => {
const searchParams = new URL(request.url).searchParams;
expect(searchParams.get("q")).to.equal("Robert");
expect(searchParams.get("offset")).to.equal("0");
});
cy.findByTestId("collection-table")
.should("contain", "Customer health")
.and("not.contain", "Quarterly revenue");
cy.findByLabelText("Clear search").click();
cy.findByLabelText("Search items in this collection").should(
"have.value",
"",
);
interceptSearch("nothing matches this", "searchWithoutResults");
cy.findByLabelText("Search items in this collection")
.type("nothing matches this")
.should("have.value", "nothing matches this");
cy.wait("@searchWithoutResults").then(({ request }) => {
expect(new URL(request.url).searchParams.get("q")).to.equal(
"nothing matches this",
);
});
cy.findByTestId("collection-filter-empty-state").within(() => {
cy.findByText("Didn't find anything").should("be.visible");
cy.findByText("There weren't any results for your search.").should(
"be.visible",
);
});
});
});
describe("type filters", () => {
beforeEach(() => {
archiveAll();
H.createDashboard({
name: "Revenue dashboard",
collection_position: null,
});
H.createQuestion({
name: "Customer question",
collection_position: null,
query: TEST_QUESTION_QUERY,
});
});
it("should filter collection items by the available types", () => {
cy.intercept(
{
method: "GET",
pathname: "/api/collection/root/items",
},
(request) => {
const models = new URL(request.url).searchParams
.getAll("models")
.sort();
if (models.join(",") === "card,collection") {
request.alias = "getTypeFilteredCollectionItems";
}
},
);
visitRootCollection();
cy.findByRole("button", { name: "Filter" }).click();
H.popover().within(() => {
cy.findAllByRole("checkbox").should("have.length", 3);
cy.findByLabelText("Collection").should("be.checked");
cy.findByLabelText("Dashboard").should("be.checked");
cy.findByLabelText("Question").should("be.checked");
cy.findByLabelText("Model").should("not.exist");
cy.findByRole("button", { name: "Apply" }).should("not.exist");
cy.findByLabelText("Dashboard").click();
});
cy.wait("@getTypeFilteredCollectionItems").then(({ request }) => {
const searchParams = new URL(request.url).searchParams;
expect(searchParams.getAll("models").sort()).to.deep.equal([
"card",
"collection",
]);
expect(searchParams.get("offset")).to.equal("0");
});
cy.findByTestId("collection-table")
.should("contain", "First collection")
.and("contain", "Customer question")
.and("not.contain", "Revenue dashboard");
cy.findByTestId("type-filter-indicator")
.find('[class*="Indicator-indicator"]')
.should("exist");
H.popover().findByLabelText("Dashboard").click();
cy.findByTestId("collection-table")
.should("contain", "Revenue dashboard")
.and("contain", "Customer question");
cy.findByTestId("type-filter-indicator")
.find('[class*="Indicator-indicator"]')
.should("not.exist");
});
});
describe("pagination", () => {
const SUBCOLLECTIONS = 1;
const ADDED_QUESTIONS = 15;

View File

@@ -31,7 +31,9 @@ describe("scenarios > Metabase Analytics Collection (AuditV2) ", () => {
H.openNavigationSidebar();
cy.findByRole("link", { name: /Usage analytics/i }).click();
cy.findByRole("link", { name: /Metabase metrics/i }).click();
H.getPinnedSection()
.findByRole("link", { name: /Metabase metrics/i })
.click();
cy.findByRole("link", { name: /Question views last week/i }).click();
cy.findByRole("button", { name: /Editor/ }).click();

View File

@@ -68,17 +68,17 @@ describe("collection permissions", () => {
cy.findByTestId("pinned-items").should("not.exist");
pinItem("Orders in a dashboard");
cy.findByTestId("collection-table")
cy.findByTestId("pinned-items")
.findByText("Orders in a dashboard")
.should("not.exist");
.should("be.visible");
pinItem("Orders, Count");
cy.findByTestId("collection-table")
cy.findByTestId("pinned-items")
.findByText("Orders, Count")
.should("not.exist");
.should("be.visible");
// Should see "pinned items" and items should be in that section
cy.findByTestId("pinned-items").within(() => {
// Pinned items also stay in the contents list
cy.findByTestId("collection-table").within(() => {
cy.findByText("Orders in a dashboard");
cy.findByText("Orders, Count");
});

View File

@@ -404,12 +404,7 @@ describe("scenarios > collections > trash", () => {
archiveBanner().should("not.exist");
cy.visit("/trash");
collectionTable().within(() => {
cy.findByText("Collection A").should("not.exist");
cy.findByText("Dashboard A").should("not.exist");
cy.findByText("Question A").should("not.exist");
});
cy.findByTestId("collection-empty-state").should("be.visible");
cy.visit(`/collection/${FIRST_COLLECTION_ID}`);
@@ -494,9 +489,7 @@ describe("scenarios > collections > trash", () => {
archiveBanner().findByText("Delete permanently").click();
H.modal().findByText("Delete Question B permanently?").should("exist");
H.modal().findByText("Delete permanently").click();
collectionTable().within(() => {
cy.findByText("Question B").should("not.exist");
});
cy.findByTestId("collection-empty-state").should("be.visible");
});
describe("bulk actions", () => {

View File

@@ -727,7 +727,7 @@ describe("scenarios > explorations > sidebar triage", () => {
);
});
it("hides pages from the toolbar and group menus, reveals them via Show hidden items, and keeps Initial investigation when everything is hidden", () => {
it("hides pages from the toolbar and group menus and reveals them via Show hidden items", () => {
createTwoPageExploration("Sidebar hide fixture").then(
({ explorationId, metricName, pageNames }) => {
cy.intercept("PUT", "/api/exploration/pages/hidden").as(
@@ -751,7 +751,6 @@ describe("scenarios > explorations > sidebar triage", () => {
)!;
cy.log("Triage arrows step between pages");
cy.findByRole("button", { name: "Previous" }).should("be.disabled");
cy.findByRole("button", { name: "Next" }).click();
selectedRows().should("contain.text", secondPageName);
cy.findByRole("button", { name: "Previous" }).click();

View File

@@ -55,9 +55,10 @@ describe("scenarios > metrics > collection", () => {
H.getPinnedSection()
.findByText(ORDERS_SCALAR_METRIC.name)
.should("be.visible");
// Pinned items also stay in the contents list below the pinned section
H.getUnpinnedSection()
.findByText(ORDERS_SCALAR_METRIC.name)
.should("not.exist");
.should("be.visible");
H.openPinnedItemMenu(ORDERS_SCALAR_METRIC.name);
H.popover().findByText("Unpin").click();
H.getPinnedSection().should("not.exist");
@@ -71,7 +72,7 @@ describe("scenarios > metrics > collection", () => {
.should("be.visible");
H.getUnpinnedSection()
.findByText(ORDERS_SCALAR_METRIC.name)
.should("not.exist");
.should("be.visible");
});
it("should be possible to add and remove a metric from bookmarks", () => {

View File

@@ -195,40 +195,6 @@ describe("scenarios > metrics > editing", () => {
verifyMetricAboutTimeseries({ yAxis: "Sum of Total" });
});
it("should pin new metrics automatically", () => {
cy.visit("/browse/metrics");
cy.findByTestId("browse-metrics-header")
.findByLabelText("Create a new metric")
.should("be.visible")
.click();
H.expectUnstructuredSnowplowEvent({
event: "metric_create_started",
triggered_from: "browse_metrics",
});
H.MetricPage.queryEditor().should("be.visible");
H.miniPicker().within(() => {
cy.findByText("Sample Database").click();
cy.findByText("Orders").click();
});
saveNewMetric({ name: "Pinned metric" });
H.expectUnstructuredSnowplowEvent({
event: "metric_created",
triggered_from: "main_app",
result: "success",
});
H.MetricPage.aboutPage().should("be.visible");
H.MetricPage.header().findByText("Our analytics").click();
cy.findByTestId("pinned-items").within(() => {
cy.findByText("Pinned metric").should("be.visible");
cy.findByText("A metric").should("be.visible");
});
});
it("should not crash when cancelling creation or editing of a metric (metabase#48024)", () => {
cy.log("cancel new metric creation");
startNewMetricWithTable("Sample Database", "Orders");

View File

@@ -36,8 +36,6 @@ describe("scenarios > models > create", () => {
// After saving, we land on view mode for the model
cy.location("pathname").should("match", /^\/model\/\d+-.*$/);
cy.findByTestId("question-row-count").should("have.text", "Showing 1 row");
checkIfPinned(modelName);
});
// This covers creating a GUI model from the browse page + nocollection permissions (2 in 1)
@@ -105,15 +103,3 @@ function navigateToNewModelPage(queryType = "native") {
cy.findByText("Use a native query").click();
}
}
function checkIfPinned(modelName) {
cy.findByTestId("app-bar").findByText("Our analytics").click();
cy.location("pathname").should("eq", "/collection/root");
cy.findByText(modelName)
.closest("a")
.find(".Icon-ellipsis")
.click({ force: true });
H.popover().findByText("Unpin").should("be.visible");
}

View File

@@ -87,7 +87,7 @@ describe("scenarios > models", () => {
.findAllByText("Our analytics")
.first()
.click();
getCollectionItemCard("Products Model").icon("model");
getCollectionItemRow("Products Model").icon("model");
getCollectionItemRow("Q1").icon("table2");
cy.url().should("not.include", "/question/" + id);
@@ -167,9 +167,7 @@ describe("scenarios > models", () => {
});
cy.findByTestId("qb-header").findAllByText("Our analytics").first().click();
getCollectionItemCard("Product Model").within(() => {
cy.icon("model");
});
getCollectionItemRow("Product Model").icon("model");
getCollectionItemRow("Q1").icon("table2");
cy.location("pathname").should("eq", "/collection/root");
@@ -603,30 +601,6 @@ describe("scenarios > models", () => {
});
});
it("should automatically pin newly created models", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
turnIntoModel();
H.visitCollection("root");
cy.findByTestId("pinned-items").within(() => {
cy.findByText("Orders");
cy.findByText("A model");
});
});
it("should undo pinning a question if turning into a model was undone", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
turnIntoModel();
H.undo();
cy.wait("@cardUpdate");
H.visitCollection("root");
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("Useful data").should("not.exist");
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
cy.findByText("A model").should("not.exist");
});
describe("listing", () => {
const modelDetails = {
name: "Orders Model 2",
@@ -665,10 +639,6 @@ function getCollectionItemRow(itemName) {
return cy.findByText(itemName).closest("tr");
}
function getCollectionItemCard(itemName) {
return cy.findByText(itemName).closest("a");
}
function getResults() {
return cy.findAllByTestId("result-item");
}

View File

@@ -296,7 +296,8 @@ function archive(name) {
}
function bookmarkPinnedItem(name) {
cy.findByText(name)
H.getPinnedSection()
.findByText(name)
.closest("a")
.find(".Icon-ellipsis")
.click({ force: true });

View File

@@ -1035,11 +1035,10 @@ function assertNonAdminCannotCreatePublicLink(resource) {
}
if (resource === "dashboard") {
// No public link: dashboards keep the app link copy and the PDF export.
// No public link: dashboards keep the app link copy
H.openSharingMenu();
H.sharingMenu().within(() => {
cy.findByText("Copy link").should("be.visible");
cy.findByText("Export as PDF").should("be.visible");
cy.findByText("Embed").should("not.exist");
cy.findByText(/public link/i).should("not.exist");
});

View File

@@ -782,6 +782,49 @@ describe("admin > custom visualizations", () => {
H.tableInteractive().findByText("37.65").should("be.visible");
});
it("switches away from a custom viz that cannot render the drilled data, and restores it when navigating back and forth (metabase#GDGT-2218)", () => {
H.createQuestion(
{
name: "Custom Viz Drill Question",
query: {
"source-table": SAMPLE_DB_TABLES.STATIC_ORDERS_ID,
aggregation: [["count"]],
},
display: H.CUSTOM_VIZ_DISPLAY,
},
{ visitQuestion: true },
);
H.main()
.findByText("Custom viz rendered successfully")
.should("be.visible");
cy.findByTestId("demo-viz-click-target").click();
cy.findByTestId("click-actions-view")
.findByText(/^Break out by/)
.click();
H.popover().findByText("Time").click();
H.popover().findByText("Created At").click();
H.echartsContainer().should("be.visible");
H.main()
.findByText("Custom viz rendered successfully")
.should("not.exist");
cy.go("back");
H.main()
.findByText("Custom viz rendered successfully")
.should("be.visible");
cy.go("forward");
H.echartsContainer().should("be.visible");
H.main()
.findByText("Custom viz rendered successfully")
.should("not.exist");
});
it("calls onHover and renders a tooltip", () => {
H.visitQuestion("@questionId");
switchToDemoViz();

View File

@@ -15,16 +15,36 @@ export type CustomVisualizationOpts<
VisualizationComponent: ComponentType<CustomVisualizationProps<TSettings>>;
};
type PluginErrorBoundaryProps = {
children: ReactNode;
label: string;
updateId: number;
};
class PluginErrorBoundary extends Component<
{ children: ReactNode; label: string },
{ error: Error | null }
PluginErrorBoundaryProps,
{ error: Error | null; updateId: number }
> {
state: { error: Error | null } = { error: null };
state: { error: Error | null; updateId: number } = {
error: null,
updateId: this.props.updateId,
};
static getDerivedStateFromError(error: Error) {
return { error };
}
// Reset on the next update so one bad render doesn't blank the plugin forever.
static getDerivedStateFromProps(
props: PluginErrorBoundaryProps,
state: { error: Error | null; updateId: number },
) {
if (props.updateId === state.updateId) {
return null;
}
return { error: null, updateId: props.updateId };
}
componentDidCatch(error: Error) {
const { message, stack } = error;
console.error(
@@ -51,11 +71,13 @@ export function defineConfig<TSettings extends BaseVisualizationSettings>(
initialProps: P,
): CustomVisualizationMountHandle<P> {
const root = createRoot(container);
let updateId = 0;
const render = (props: P) => {
root.render(
<PluginErrorBoundary
label={Component.displayName ?? Component.name ?? "plugin"}
updateId={updateId++}
>
<Component {...props} />
</PluginErrorBoundary>,

View File

@@ -30,7 +30,6 @@ export async function createModelFromTable(options: Options) {
display: "table",
result_metadata: null,
collection_id: collectionId,
collection_position: 1,
visualization_settings: {},
dataset_query: datasetQuery,
description: `A model created via the embedding sdk's CLI`,

View File

@@ -33,7 +33,7 @@ export class DevDiagnosticsCollector {
console.error = (...args: unknown[]) => {
this.record({
kind: "error",
message: args.map((arg) => this.formatArg(arg)).join(" "),
message: this.formatArgs(args),
});
originalError(...args);
@@ -134,6 +134,29 @@ export class DevDiagnosticsCollector {
this.uncapturedConsoleError?.(message);
}
/**
* Join `console.error` arguments, substituting `%s` — the only specifier React warnings use.
**/
private formatArgs(args: unknown[]): string {
const [format, ...rest] = args;
if (typeof format !== "string" || !format.includes("%s")) {
return args.map((arg) => this.formatArg(arg)).join(" ");
}
let consumed = 0;
// Leave a `%s` alone once the arguments run out, as the console does.
const substituted = format.replace(/%s/g, (match) =>
consumed < rest.length ? this.formatArg(rest[consumed++]) : match,
);
// Arguments past the last `%s` are appended, as the console does.
return [
substituted,
...rest.slice(consumed).map((arg) => this.formatArg(arg)),
].join(" ");
}
private formatArg(arg: unknown): string {
if (typeof arg === "string") {
return truncateDiagnosticText(arg);

View File

@@ -53,6 +53,50 @@ describe("dev diagnostics collector", () => {
expect(forwarded).toContainEqual(["passed through"]);
});
describe("%s substitution", () => {
const lastMessage = () =>
formatDevDiagnostic(getLastEntry(devDiagnostics.getEntries()));
it("substitutes them the way the browser console renders them", () => {
// The verbatim shape of a React warning: a format string plus its substitutions.
console.error(
'Warning: Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.%s',
"",
"\n\nCheck the render method of `App`.",
"\n in div",
);
expect(lastMessage()).toBe(
'Warning: Each child in a list should have a unique "key" prop.' +
"\n\nCheck the render method of `App`." +
" See https://reactjs.org/link/warning-keys for more information." +
"\n in div",
);
});
it("leaves other specifiers to the console's raw text", () => {
// Only `%s` is substituted — React never emits these, and getting them verbatim in
// the toolbar is a cosmetic loss rather than a misleading one.
console.error("%d items", 3);
expect(lastMessage()).toBe("%d items 3");
});
it("leaves a specifier alone when no argument is left, and appends the extras", () => {
console.error("only %s and %s", "one");
expect(lastMessage()).toBe("only one and %s");
console.error("%s then", "first", "extra");
expect(lastMessage()).toBe("first then extra");
});
it("still space-joins when the first argument carries no specifier", () => {
console.error("boom", { code: 1 });
expect(lastMessage()).toBe('boom {"code":1}');
});
});
it("survives arguments JSON cannot represent", () => {
const noop = () => undefined;

View File

@@ -6,9 +6,8 @@ import { EmptyState } from "metabase/common/components/EmptyState";
import AdminS from "metabase/css/admin.module.css";
import CS from "metabase/css/core/index.css";
import { Box, Checkbox, Icon } from "metabase/ui";
import { displayNameForColumn } from "metabase/utils/formatting";
import { displayNameForColumn, formatValue } from "metabase/value-formatting";
import { registerVisualization } from "metabase/visualizations/index";
import { formatValue } from "metabase/visualizations/lib/formatting";
import { isColumnRightAligned } from "metabase/visualizations/lib/table";
import type {
AuditTableSorting,

View File

@@ -1,4 +1,5 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { act, screen, within } from "__support__/ui";
import type { SetupOpts } from "metabase/admin/performance/components/test-utils";
@@ -16,7 +17,10 @@ import {
type DurationStrategy,
type ScheduleStrategy,
} from "metabase-types/api";
import { createMockTokenFeatures } from "metabase-types/api/mocks";
import {
createMockCacheConfig,
createMockTokenFeatures,
} from "metabase-types/api/mocks";
function setup(opts: SetupOpts = {}) {
baseSetup({
@@ -98,7 +102,7 @@ describe("StrategyEditorForDatabases", () => {
expect((await screen.findAllByRole("spinbutton")).length).toBe(1);
await changeInput(/Cache duration/, 24, 48);
await changeInput(/Cache duration/, 0, 48);
await userEvent.click(
await screen.findByTestId("strategy-form-submit-button"),
@@ -169,7 +173,11 @@ describe("StrategyEditorForDatabases", () => {
expect((await screen.findAllByRole("spinbutton")).length).toBe(1);
await changeInput(/Cache duration/, 24, 48);
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
"hours",
);
await changeInput(/Cache duration/, 0, 48);
await userEvent.click(
await screen.findByTestId("strategy-form-submit-button"),
@@ -250,15 +258,118 @@ describe("StrategyEditorForDatabases", () => {
expect(result).toBe("Scheduled: hourly");
});
it("can abbreviate a 'Duration' strategy", () => {
it.each([
[CacheDurationUnit.Hours, "Duration: 5h"],
[CacheDurationUnit.Minutes, "Duration: 5m"],
[CacheDurationUnit.Seconds, "Duration: 5s"],
[CacheDurationUnit.Days, "Duration: 5d"],
])("can abbreviate a 'Duration' strategy with unit %s", (unit, expected) => {
const strategy: DurationStrategy = {
type: "duration",
duration: 5,
unit: CacheDurationUnit.Hours,
unit,
refresh_automatically: false,
};
const result = getShortStrategyLabel(strategy);
expect(result).toBe("Duration: 5h");
expect(getShortStrategyLabel(strategy)).toBe(expected);
});
it("does not allow saving an empty cache duration", async () => {
await userEvent.click(
await screen.findByLabelText(
"Edit default policy (currently: Duration: 1h)",
),
);
const input = await screen.findByRole("spinbutton", {
name: /Cache duration/,
});
await userEvent.clear(input);
await userEvent.click(
await screen.findByTestId("strategy-form-submit-button"),
);
expect(
await screen.findByText("Enter a positive number."),
).toBeInTheDocument();
expect(
fetchMock.callHistory.calls("path:/api/cache", { method: "PUT" }),
).toHaveLength(0);
});
it("pluralizes the unit options with the entered duration", async () => {
await userEvent.click(
await screen.findByLabelText(
"Edit default policy (currently: Duration: 1h)",
),
);
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
"hour",
);
await changeInput(/Cache duration/, 0, 2);
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
"hours",
);
});
it("lets user change the duration unit", async () => {
await userEvent.click(
await screen.findByLabelText(
"Edit default policy (currently: Duration: 1h)",
),
);
await userEvent.click(await screen.findByTestId("duration-unit-select"));
await userEvent.click(
await screen.findByRole("option", { name: "minute" }),
);
await userEvent.click(
await screen.findByTestId("strategy-form-submit-button"),
);
expect(
await screen.findByLabelText(
"Edit default policy (currently: Duration: 1m)",
),
).toBeInTheDocument();
});
});
describe("StrategyEditorForDatabases (sub-hour duration policy)", () => {
beforeEach(() => {
setup({
cacheConfigs: [
createMockCacheConfig({
model: "root",
model_id: 0,
strategy: {
type: "duration",
duration: 30,
unit: CacheDurationUnit.Minutes,
refresh_automatically: false,
},
}),
],
});
});
it("shows a policy stored with a sub-hour unit faithfully", async () => {
await userEvent.click(
await screen.findByLabelText(
"Edit default policy (currently: Duration: 30m)",
),
);
expect(await getCacheStrategySelect()).toHaveValue("Duration");
expect(
await screen.findByRole("spinbutton", { name: /Cache duration/ }),
).toHaveValue(30);
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
"minutes",
);
});
});

View File

@@ -14,7 +14,6 @@ import type { ColumnItem } from "metabase/common/components/Table/types";
import { usePagination } from "metabase/common/hooks/use-pagination";
import { Center, Flex, Repeat, Skeleton, Stack } from "metabase/ui";
import type { CacheSortColumn, CacheableModel } from "metabase-types/api";
import { CacheDurationUnit } from "metabase-types/api";
import type { SortDirection } from "metabase-types/api/sorting";
import type { CacheableItem, UpdateTarget } from "../types";
@@ -100,16 +99,7 @@ export const StrategyEditorForQuestionsAndDashboards = () => {
})
: undefined;
const savedStrategy = useMemo(() => {
const strategy = targetConfig?.strategy;
if (!strategy) {
return undefined;
}
if (strategy.type === "duration") {
return { ...strategy, unit: CacheDurationUnit.Hours };
}
return { ...strategy };
}, [targetConfig?.strategy]);
const savedStrategy = targetConfig?.strategy;
const targetName = useMemo(() => {
if (targetId === null || targetModel === null) {

View File

@@ -3,24 +3,29 @@ import _ from "underscore";
import { getShortStrategyLabel } from "metabase/admin/performance/utils";
import { getCollectionPathAsString } from "metabase/common/collections/utils";
import { CacheDurationUnit } from "metabase-types/api";
import type { CacheableItem } from "../types";
const DURATION_UNIT_SECONDS: Record<CacheDurationUnit, number> = {
[CacheDurationUnit.Seconds]: 1,
[CacheDurationUnit.Minutes]: 60,
[CacheDurationUnit.Hours]: 3600,
[CacheDurationUnit.Days]: 86400,
};
export const formatValueForSorting = (
row: CacheableItem,
columnName: string,
) => {
if (columnName === "policy") {
const label = getShortStrategyLabel(row.strategy, row.model);
if (row.strategy.type === "duration") {
// Sort durations in ascending order of length
return label?.replace(/(\d+)h/, (_, num) => {
const paddedNumber = num.padStart(5, "0");
return `${t`Duration`} ${paddedNumber}`;
});
} else {
return label;
const seconds =
row.strategy.duration * DURATION_UNIT_SECONDS[row.strategy.unit];
return `${t`Duration`} ${String(seconds).padStart(12, "0")}`;
}
return getShortStrategyLabel(row.strategy, row.model);
}
if (columnName === "collection") {
return row.collection ? getCollectionPathAsString(row.collection) : "";

View File

@@ -170,6 +170,42 @@ describe("StrategyEditorForQuestionsAndDashboards utilities", () => {
]);
});
it("sorts durations with mixed units by real length", () => {
const durationRow = (
id: number,
duration: number,
unit: CacheDurationUnit,
): CacheableItem => ({
model: "question",
id,
strategy: {
type: "duration",
duration,
unit,
refresh_automatically: false,
},
});
const rows = [
durationRow(0, 1, CacheDurationUnit.Days),
durationRow(1, 100, CacheDurationUnit.Minutes),
durationRow(2, 90, CacheDurationUnit.Seconds),
durationRow(3, 10, CacheDurationUnit.Hours),
durationRow(4, 30, CacheDurationUnit.Seconds),
];
const sorted = rows.sort((rowA, rowB) => {
const a = formatValueForSorting(rowA, "policy");
const b = formatValueForSorting(rowB, "policy");
return String(a).localeCompare(String(b));
});
expect(sorted.map((row) => getShortStrategyLabel(row.strategy))).toEqual([
"Duration: 30s",
"Duration: 90s",
"Duration: 100m",
"Duration: 10h",
"Duration: 1d",
]);
});
it("sorts by collection correctly", () => {
const sorted = unsortedRows.sort((rowA, rowB) => {
// Unjustified type cast. FIXME

View File

@@ -28,11 +28,13 @@ const getDurationStrategyValidationSchema = () =>
Yup.object({
type: Yup.string().equals(["duration"]),
duration: getPositiveIntegerSchema().default(24),
unit: Yup.string().test(
"is-duration-unit",
"${path} is not a valid duration",
(value) => !!value && durationUnits.has(value),
),
unit: Yup.string()
.test(
"is-duration-unit",
"${path} is not a valid duration",
(value) => !!value && durationUnits.has(value),
)
.default(CacheDurationUnit.Hours),
refresh_automatically: Yup.boolean().nullable().default(false),
});
@@ -47,7 +49,7 @@ export const enterpriseOnlyCachingStrategies: Record<string, StrategyData> = {
},
duration: {
label: () => t`Duration`,
description: () => t`Keep the cache for a number of hours`,
description: () => t`Keep the cache for a set duration`,
validationSchema: getDurationStrategyValidationSchema,
shortLabel: () => t`Duration`,
},

View File

@@ -0,0 +1,215 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { act, screen, waitFor, within } from "__support__/ui";
import { setup } from "metabase/query_builder/containers/test-utils";
import { getCard } from "metabase/query_builder/selectors";
import { checkNotNull } from "metabase/utils/types";
import { registerVisualization } from "metabase/visualizations";
import { registerVisualizations } from "metabase/visualizations/register";
import type { VisualizationProps } from "metabase/visualizations/types/visualization";
import { isDate } from "metabase-lib/v1/types/utils/isa";
import type { CustomVizDisplayType } from "metabase-types/api";
import {
createMockCard,
createMockCategoryColumn,
createMockCustomVizPluginRuntime,
createMockDataset,
createMockDatasetData,
createMockNumericColumn,
} from "metabase-types/api/mocks";
import {
ORDERS,
ORDERS_ID,
SAMPLE_DB_ID,
createOrdersCreatedAtDatasetColumn,
createOrdersIdDatasetColumn,
createOrdersTotalDatasetColumn,
} from "metabase-types/api/mocks/presets";
import { applyDefaultVisualizationProps } from "./custom-viz-common";
const DISPLAY: CustomVizDisplayType = "custom:drill-demo-viz";
const CREATED_AT_COLUMN = createOrdersCreatedAtDatasetColumn({
source: "breakout",
unit: "month",
field_ref: ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
});
const COUNT_COLUMN = createMockNumericColumn({
name: "count",
display_name: "Count",
semantic_type: "type/Quantity",
source: "aggregation",
field_ref: ["aggregation", 0],
});
const DATASET = createMockDataset({
data: createMockDatasetData({
cols: [CREATED_AT_COLUMN, COUNT_COLUMN],
rows: [
["2026-01-01T00:00:00Z", 10],
["2026-02-01T00:00:00Z", 20],
],
}),
});
const UNDERLYING_RECORDS_DATASET = createMockDataset({
data: createMockDatasetData({
cols: [createOrdersIdDatasetColumn(), createOrdersTotalDatasetColumn()],
rows: [[1, 100]],
}),
});
const CATEGORY_DATASET = createMockDataset({
data: createMockDatasetData({
cols: [
createMockCategoryColumn({
name: "CATEGORY",
display_name: "Category",
source: "breakout",
}),
COUNT_COLUMN,
],
rows: [
["Doohickey", 10],
["Gadget", 20],
],
}),
});
const CARD = createMockCard({
id: 1,
name: "Orders by month",
description: "Count of orders bucketed by month",
type: "question",
display: DISPLAY,
visualization_settings: {
"card.title": "Orders by month",
"custom.setting": "kept",
},
dataset_query: {
database: SAMPLE_DB_ID,
type: "query",
query: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }]],
},
},
});
function DemoVisualization({ onVisualizationClick }: VisualizationProps) {
const [createdAt, count] = DATASET.data.rows[0];
return (
<div>
<span>Custom viz rendered</span>
<button
type="button"
onClick={(event) =>
onVisualizationClick?.({
value: count,
column: COUNT_COLUMN,
event: event.nativeEvent,
element: event.currentTarget,
data: [
{ col: CREATED_AT_COLUMN, value: createdAt },
{ col: COUNT_COLUMN, value: count },
],
dimensions: [{ column: CREATED_AT_COLUMN, value: createdAt }],
})
}
>
Click me
</button>
</div>
);
}
registerVisualizations();
registerVisualization(
applyDefaultVisualizationProps(
DemoVisualization,
{
id: DISPLAY,
getName: () => "Drill demo viz",
checkRenderable: ([{ data }]) => {
if (!data?.cols.some(isDate)) {
throw new Error("Needs a date column");
}
},
mount: () => ({ update: () => undefined, unmount: () => undefined }),
VisualizationComponent: () => null,
},
{
identifier: DISPLAY,
plugin: createMockCustomVizPluginRuntime(),
getUiName: () => "Drill demo viz",
},
),
);
async function drillFromCustomViz(actionName: RegExp) {
expect(await screen.findByText("Custom viz rendered")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Click me" }));
const clickActions = await screen.findByTestId("click-actions-view");
await userEvent.click(within(clickActions).getByText(actionName));
}
async function expectFallbackChart() {
expect(await screen.findByTestId("chart-container")).toBeInTheDocument();
expect(screen.queryByText("Custom viz rendered")).not.toBeInTheDocument();
}
describe("query builder > custom visualization drill-through", () => {
it("should keep the custom visualization selected after drilling (metabase#GDGT-2286)", async () => {
const { store } = await setup({ card: CARD, dataset: DATASET });
await drillFromCustomViz(/See this month by week/);
expect(await screen.findByText("Custom viz rendered")).toBeInTheDocument();
const card = checkNotNull(getCard(store.getState()));
expect(card.display).toBe(DISPLAY);
});
it("should switch away from a custom visualization that cannot render the drilled data, and back again when navigating back and forth (metabase#GDGT-2218)", async () => {
const { router } = await setup({ card: CARD, dataset: DATASET });
// the drilled query comes back without a date column, so the custom
// visualization can no longer render it
fetchMock.modifyRoute("dataset-post", { response: CATEGORY_DATASET });
await drillFromCustomViz(/See this month by week/);
await expectFallbackChart();
act(() => router.back());
expect(await screen.findByText("Custom viz rendered")).toBeInTheDocument();
act(() => router.forward());
await expectFallbackChart();
});
it("should still switch to a table for the underlying records drill", async () => {
const { store } = await setup({ card: CARD, dataset: DATASET });
fetchMock.modifyRoute("dataset-post", {
response: UNDERLYING_RECORDS_DATASET,
});
await drillFromCustomViz(/See these Orders/);
await waitFor(() => {
expect(checkNotNull(getCard(store.getState())).display).toBe("table");
});
expect(screen.queryByText("Custom viz rendered")).not.toBeInTheDocument();
});
});

View File

@@ -7,7 +7,7 @@ import type {
OmniPickerCollectionItem,
OmniPickerItem,
} from "metabase/common/components/Pickers/EntityPicker/types";
import { allCollectionModels } from "metabase/common/components/Pickers/EntityPicker/utils";
import { allCollectionModels } from "metabase/common/components/Pickers/utils";
import type {
GetEntityPickerSyntheticLibraryItemFunction,
LibrarySubCollectionType,

View File

@@ -1,5 +1,5 @@
import { formatNumber } from "metabase/utils/formatting";
import { formatDateTimeWithUnit } from "metabase/visualizations/lib/formatting";
import { formatDateTimeWithUnit } from "metabase/value-formatting";
import type { BillingInfoLineItem } from "metabase-types/api";
import {
supportedDisplayTypes,

View File

@@ -1,32 +1,88 @@
import { Route, redirect } from "metabase/router";
import { Route, redirect, registerPagePrefetch } from "metabase/router";
import * as Urls from "metabase/urls";
import { CliAnalyticsPage } from "./cli-analytics/components/CliAnalyticsPage";
import {
McpAnalyticsAvailabilityLayout,
MetabotAnalyticsAvailabilityLayout,
} from "./components/AvailabilityLayouts";
import { McpAnalyticsPage } from "./mcp-analytics/components/McpAnalyticsPage";
import { ConversationDetailPage } from "./metabot-analytics/components/ConversationDetailPage";
import { ConversationStatsPage } from "./metabot-analytics/components/ConversationStatsPage";
import { ConversationsPage } from "./metabot-analytics/components/ConversationsPage";
import { MetabotAnalyticsUpsellPage } from "./metabot-analytics/components/MetabotAnalyticsUpsellPage/MetabotAnalyticsUpsellPage";
/**
* The AI auditing pages, each in its own chunk.
*
* The plugin registry assigns these routes on every page load, so whatever they
* name is in the initial bundle. The pages carry the charting and data grid
* stack, which no other page needs on first paint.
*
* The route shape stays eager, so matching is unchanged. The availability gates
* above them still decide what renders, but they now decide a tick later: the
* router resolves a matched route's `lazy` before it commits, which means a page
* a gate is about to block is fetched anyway. That is a few tens of kilobytes on
* an admin page whose feature is switched off, in exchange for one splitting
* mechanism across the app.
*/
const conversationStatsPage = () =>
import("./metabot-analytics/components/ConversationStatsPage").then(
({ ConversationStatsPage }) => ({ Component: ConversationStatsPage }),
);
const conversationsPage = () =>
import("./metabot-analytics/components/ConversationsPage").then(
({ ConversationsPage }) => ({ Component: ConversationsPage }),
);
const conversationDetailPage = () =>
import("./metabot-analytics/components/ConversationDetailPage").then(
({ ConversationDetailPage }) => ({ Component: ConversationDetailPage }),
);
const metabotAnalyticsUpsellPage = () =>
import("./metabot-analytics/components/MetabotAnalyticsUpsellPage/MetabotAnalyticsUpsellPage").then(
({ MetabotAnalyticsUpsellPage }) => ({
Component: MetabotAnalyticsUpsellPage,
}),
);
const mcpAnalyticsPage = () =>
import("./mcp-analytics/components/McpAnalyticsPage").then(
({ McpAnalyticsPage }) => ({ Component: McpAnalyticsPage }),
);
const cliAnalyticsPage = () =>
import("./cli-analytics/components/CliAnalyticsPage").then(
({ CliAnalyticsPage }) => ({ Component: CliAnalyticsPage }),
);
/**
* Hovering a Monitor sidebar link starts the fetch, so the chunk is usually in
* hand by the time the click lands.
*
* `/usage` renders the stats page or the upsell page depending on the license,
* so hovering it asks for both. The conversation detail page takes the trailing
* slash, which keeps the link to the list from dragging it in as well.
*/
registerPagePrefetch(Urls.monitorAiAuditingUsage(), conversationStatsPage);
registerPagePrefetch(Urls.monitorAiAuditingUsage(), metabotAnalyticsUpsellPage);
registerPagePrefetch(Urls.monitorAiAuditingConversations(), conversationsPage);
registerPagePrefetch(
`${Urls.monitorAiAuditingConversations()}/`,
conversationDetailPage,
);
registerPagePrefetch(Urls.monitorAiAuditingMcp(), mcpAnalyticsPage);
registerPagePrefetch(Urls.monitorAiAuditingCli(), cliAnalyticsPage);
export function getAiAuditingRoutes() {
return (
<>
<Route index element={redirect("usage")} />
<Route element={<MetabotAnalyticsAvailabilityLayout />}>
<Route path="usage" element={<ConversationStatsPage />} />
<Route path="conversations" element={<ConversationsPage />} />
<Route
path="conversations/:convoId"
element={<ConversationDetailPage />}
/>
<Route path="usage" lazy={conversationStatsPage} />
<Route path="conversations" lazy={conversationsPage} />
<Route path="conversations/:convoId" lazy={conversationDetailPage} />
</Route>
<Route element={<McpAnalyticsAvailabilityLayout />}>
<Route path="mcp" element={<McpAnalyticsPage />} />
<Route path="mcp" lazy={mcpAnalyticsPage} />
</Route>
<Route path="cli" element={<CliAnalyticsPage />} />
<Route path="cli" lazy={cliAnalyticsPage} />
</>
);
}
@@ -35,11 +91,11 @@ export function getAiAuditingUpsellRoutes() {
return (
<>
<Route index element={redirect("usage")} />
<Route path="usage" element={<MetabotAnalyticsUpsellPage />} />
<Route path="usage" lazy={metabotAnalyticsUpsellPage} />
<Route element={<McpAnalyticsAvailabilityLayout />}>
<Route path="mcp" element={<McpAnalyticsPage />} />
<Route path="mcp" lazy={mcpAnalyticsPage} />
</Route>
<Route path="cli" element={<CliAnalyticsPage />} />
<Route path="cli" lazy={cliAnalyticsPage} />
</>
);
}

View File

@@ -83,16 +83,18 @@ describe("AI Auditing routes", () => {
[Urls.monitorAiAuditingUsage(), "Usage stats page"],
["/monitor/ai-auditing/conversations", "Conversations page"],
["/monitor/ai-auditing/conversations/42", "Conversation detail page"],
])("blocks %s when AI features are disabled", (route, pageText) => {
])("blocks %s when AI features are disabled", async (route, pageText) => {
setup({ route, aiFeaturesEnabled: false });
expect(screen.getByText("AI features are disabled")).toBeInTheDocument();
expect(
await screen.findByText("AI features are disabled"),
).toBeInTheDocument();
expect(screen.queryByText(pageText)).not.toBeInTheDocument();
});
it.each([false, true])(
"prioritizes globally disabled AI on the MCP route when upsell is %s",
(upsell) => {
async (upsell) => {
setup({
route: "/monitor/ai-auditing/mcp",
upsell,
@@ -100,7 +102,9 @@ describe("AI Auditing routes", () => {
mcpEnabled: false,
});
expect(screen.getByText("AI features are disabled")).toBeInTheDocument();
expect(
await screen.findByText("AI features are disabled"),
).toBeInTheDocument();
expect(screen.queryByText("MCP analytics page")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Go to AI Settings" }),
@@ -108,26 +112,26 @@ describe("AI Auditing routes", () => {
},
);
it("renders Usage stats at the canonical route", () => {
it("renders Usage stats at the canonical route", async () => {
setup({ route: Urls.monitorAiAuditingUsage() });
expect(screen.getByText("Usage stats page")).toBeInTheDocument();
expect(await screen.findByText("Usage stats page")).toBeInTheDocument();
});
it("renders full Metabot analytics when enabled and configured", () => {
it("renders full Metabot analytics when enabled and configured", async () => {
setup({ route: "/monitor/ai-auditing/conversations" });
expect(screen.getByText("Conversations page")).toBeInTheDocument();
expect(await screen.findByText("Conversations page")).toBeInTheDocument();
});
it("blocks MCP analytics when MCP is disabled", () => {
it("blocks MCP analytics when MCP is disabled", async () => {
setup({ route: "/monitor/ai-auditing/mcp", mcpEnabled: false });
expect(screen.getByText("MCP is disabled")).toBeInTheDocument();
expect(await screen.findByText("MCP is disabled")).toBeInTheDocument();
expect(screen.queryByText("MCP analytics page")).not.toBeInTheDocument();
});
it("keeps the license upsell ahead of AI configuration", () => {
it("keeps the license upsell ahead of AI configuration", async () => {
setup({
route: Urls.monitorAiAuditingUsage(),
upsell: true,
@@ -135,7 +139,7 @@ describe("AI Auditing routes", () => {
isConfigured: false,
});
expect(screen.getByText("Usage stats upsell")).toBeInTheDocument();
expect(await screen.findByText("Usage stats upsell")).toBeInTheDocument();
expect(
screen.queryByText("AI features are disabled"),
).not.toBeInTheDocument();
@@ -143,7 +147,7 @@ describe("AI Auditing routes", () => {
it.each([false, true])(
"renders CLI analytics unconditionally when upsell is %s",
(upsell) => {
async (upsell) => {
setup({
route: Urls.monitorAiAuditingCli(),
upsell,
@@ -152,18 +156,18 @@ describe("AI Auditing routes", () => {
mcpEnabled: false,
});
expect(screen.getByText("CLI analytics page")).toBeInTheDocument();
expect(await screen.findByText("CLI analytics page")).toBeInTheDocument();
},
);
it("applies MCP availability to the upsell route set", () => {
it("applies MCP availability to the upsell route set", async () => {
setup({
route: "/monitor/ai-auditing/mcp",
upsell: true,
mcpEnabled: false,
});
expect(screen.getByText("MCP is disabled")).toBeInTheDocument();
expect(await screen.findByText("MCP is disabled")).toBeInTheDocument();
expect(screen.queryByText("MCP analytics page")).not.toBeInTheDocument();
});
});

View File

@@ -30,7 +30,7 @@ import {
Text,
} from "metabase/ui";
import * as Errors from "metabase/utils/errors";
import { slugify } from "metabase/visualizations/lib/formatting/url";
import { slugify } from "metabase/utils/formatting";
import { useReplaceModelWithTransformMutation } from "metabase-enterprise/api";
import type { Card, Database } from "metabase-types/api";

View File

@@ -1,6 +1,6 @@
import { msgid, ngettext, t } from "ttag";
import { formatNumber } from "metabase/utils/formatting/numbers";
import { formatNumber } from "metabase/utils/formatting";
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils/index";
import type { Dataset } from "metabase-types/api";

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