Compare commits

...

113 Commits

Author SHA1 Message Date
Nicola Mometto
3ae1585c7e bump 2026-07-17 09:40:23 +01:00
Nicola Mometto
b0b11dca91 test seccomp 2026-07-17 09:39:53 +01:00
Oleh K
8984233763 Align transform job header to fix tab layout shift (#77803)
* Fix layout shift when switching tabs on the transform job page

The job Overview and Run history pages each rendered their own header:
Overview used an editable name input plus a `...` menu, while Run history
used a static title with no menu. The differing heights shifted the tab
row, so the clicked tab jumped when switching between the two.

Extract a shared JobHeader component (and a useJobHeaderState hook for the
readOnly/name-change logic) rendered identically by both pages, mirroring
the MetricHeader pattern. Run history now also shows the `...` menu and the
editable name, and Metabot is consistent across both tabs.

* Unify JobPage header logic with useJobHeaderState

JobPage duplicated the readOnly computation and name-change handler that
now live in useJobHeaderState. Source them from the hook instead; keep the
local useTransformPermissions call only for the page-level loading gate.

* Use strict undefined comparisons in job header code

Address review: job/jobId are never null, so compare with undefined
strictly and derive jobId from job.id in the JobHeader render.
2026-07-17 08:51:59 +02:00
Egor Grushin
8ab2269f04 GDGT-2767 Replace deprecated MaybeLink (#77908)
* Replace deprecated MaybeLink

* refactor
2026-07-17 03:12:48 +01:00
Kevin Kelleher
4385d33640 Fix failing test introduced by #77981 (#78000)
fix test
2026-07-17 01:03:20 +00:00
bryan
64588d02fb test(mfa): auth-mechanism matrix + lockout-corner coverage (#77624) 2026-07-16 22:11:37 +00:00
github-automation-metabase
20b1d51e6b Auto-update documentation for master (#77997)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-16 17:41:49 -04:00
Michael Eby
838d5a7a50 docs: add data isolation content (#77876)
* docs: add data isolation content

* Optimized 4 images, saved 70.0 KB (15.8% smaller)

* Fix links

* Edits from Alex

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-07-16 16:47:42 -04:00
dpsutton
5fb83b02e7 Fix MCP OAuth consent flow when Metabase is hosted under a subpath (#77765)
Fixes GIT-10551: connecting an MCP client to a Metabase instance served
under a subpath (site-url like https://example.com/metabase behind a
prefix-stripping reverse proxy) failed at the consent step.

The OAuth flow already derives every advertised URL from site-url —
discovery metadata, authorization/token/registration endpoints, and the
WWW-Authenticate resource_metadata header are all subpath-correct. But
three browser-facing pieces assumed Metabase lives at the domain root:

1. The consent form posted to root-relative /oauth/authorize/decision,
   so the browser sent the POST outside the subpath and the proxy
   returned 404 (the reported bug). The form action is now the absolute
   site-url-based URL, via the same absolute-url helper the page already
   uses for the logo. The bundled @font-face URLs on the page get the
   same treatment.

2. The CSRF cookie was scoped to Path=/oauth/authorize. Under a subpath
   the consent page lives at /metabase/oauth/authorize, so the browser
   would never send the cookie back and the decision POST would fail
   CSRF validation even with the form action fixed. The cookie path now
   carries the path component of site-url.

3. The logged-out flow: the backend bounces to the login page with a
   basename-relative redirect target (the server sits behind the
   prefix-stripping proxy and never sees the subpath). The frontend
   guard resolved that target against the bare origin, losing the
   subpath on the full-page redirect back to /oauth/authorize. The
   guard now normalizes targets through the router basename in both
   directions: paths without an explicit origin get the basename joined
   (browser-real href), and pathnames get it stripped (router path, so
   the backend-only-path prefix check still matches).

Why this is safe:

- Root deployments are bit-for-bit unchanged. site-url has no path
  component there, so the cookie path is still /oauth/authorize, the
  form action still targets /oauth/authorize/decision, and with an
  empty basename the guard resolves every input shape (relative path,
  bare path, absolute URL) to exactly the URLs it produced before.
- Under a subpath, the only outputs that change are ones that were
  previously unroutable (404 through the proxy or a path the SPA
  router would double-prefix).
- The server never sees the subpath, so no routes, parameters, or
  validation logic change; every fix is to a browser-visible artifact
  derived from site-url.

Tests pin both halves: existing root-deployment assertions are
untouched and still pass, and new tests cover the subpath form action,
cookie path, login redirect target, and the full consent -> decision
round-trip with a subpath site-url. Also verified live against a local
prefix-stripping Caddy proxy with a real MCP client flow (DCR, consent,
PKCE token exchange, tool calls).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:06:24 -05:00
Rodrigo López Dato
31b35fb452 Automatically focus new Key field when adding user attributes (#77715)
* Automatically focus new Key field when adding user attributes

* Only focus new entry if no error
2026-07-16 16:55:13 -03:00
Anderson
f82bdbdd52 Restore table color range saturation for single conditional formatting rules (#77981)
Restore color range saturation for single rules
2026-07-16 16:41:02 -03:00
Alexander Polyankin
2819934fdc Unify image-buffer pool (#77836)
* Rewrite image-buffer pool on dirigiste (same pool as the GraalVM contexts)

Replace the hand-rolled ConcurrentLinkedDeque pool, stats counters, sweeper
daemon, and logging with the dirigiste Pool already used for static-viz
contexts, with the same semantics: 0-3 pooled int[] arrays of the fixed
oversized size, held exclusively per render, idle arrays dropped after ~10
minutes. acquire blocks until an array is free when the requested size fits;
larger requests get a fresh unpooled BufferedImage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract the dirigiste pool factory into metabase.util.pool

create-pool was duplicated verbatim between the static-viz graal renderer
(channel.render.js.common) and the sqlglot graal runner (sql-parsing.common),
and image-buffer had just picked up a third use. Move it to metabase.util.pool
and use it from all three; the pool behavior tests move from
sql-parsing.common-test to util.pool-test with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add prometheus counters for image-buffer pool usage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rename image-buffer metrics to metabase-notification namespace

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:40:38 -04:00
Timofey Kachalov
ce264b2c85 Update SDK version to 0.64.0-alpha (#77978) 2026-07-16 18:33:44 +00:00
lbrdnk
07f62f33c3 Log suggested prompts generation exception on debug only (#77962)
Log exception on debug only
2026-07-16 18:15:54 +00:00
Andrei Chernykh
adf574b57b Convert Scalar visualization from a class to a function component (#77774) 2026-07-16 11:00:04 -07:00
da
43d61f4514 Update Jetty dependencies to version 12.1.11 (#77860)
https://github.com/jetty/jetty.project/releases

check 12.1.7...12.1.11
2026-07-16 14:08:59 -03:00
Andrei Chernykh
12ce1e67e7 Migrate nav search and search result components from Emotion to Mantine (#77332)
* Migrate nav search and search result components from Emotion to Mantine

* Address review feedback: close button label, valid footer markup, footer role and hover transition

* Restore search dropdown footer ref by returning a ref callback from useListKeyboardNavigation
2026-07-16 17:03:22 +00:00
Rodrigo López Dato
89abda4d66 Browse directly from RCLS settings to saved questions (#77614)
* Browse directly from settings to RCLS saved questions

* Move styles to separate file
2026-07-16 14:02:39 -03:00
Anderson
911fe173f2 Fix margins when printing documents with charts (#74234)
* Improve page/container margins when printing document

* do not call flushSync while printing

* Revert "do not call flushSync while printing"

This reverts commit 37c6fe2b50.

* Resize ECharts synchronously for print layout changes

* Refactor ECharts print resize tests

* Clean up
2026-07-16 13:18:49 -03:00
Ethan McCue
8705016252 Fix gauge breaking in static-viz (#77724)
Fix gauge color crashing

Co-authored-by: Kevin Kelleher <16599994+kevinkelleher12@users.noreply.github.com>
2026-07-16 13:46:56 +00:00
Chris Truter
fc7d1ec13b Make default-source-table-entry deterministic and field-safe (#77725) 2026-07-16 13:09:07 +00:00
da
6108e0182e Accept JWT in the body or in parameters according to the method (#75656)
* Follow up to https://github.com/metabase/metabase/pull/72798#discussion_r3234347346

* Apply suggestions from code review

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>

* Update docs/people-and-groups/authenticating-with-jwt.md

Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>

---------

Co-authored-by: Jeff Bruemmer <jeffbruemmer@gmail.com>
Co-authored-by: Jeff Bruemmer <jeff.bruemmer@gmail.com>
2026-07-16 13:01:48 +00:00
Kamil Mielnik
479d7ff67b GDGT-1570 Transform has changes after bluring source table input and still has changes after cancelling them (#77712) 2026-07-16 18:20:13 +07:00
Nicola Mometto
3e6612f075 fix incremental transforms on clickhouse (#77921)
* remove compile-with-inline for clickhouse, no longer needed since the driver got fixed

* re-enable full tests

* fix: for max behavior on clickhouse

* fix: insert for clickhouse

* add temporal reg test
2026-07-16 12:04:46 +01:00
Kamil Mielnik
ce8a99bf08 GDGT-2648 Action menu is too wide when editing a dashboard (#77795) 2026-07-16 17:43:14 +07:00
Nicola Mometto
ed3aa72e65 humanize the alert management error when recipient input is wrong (#77852)
* fix: better human readable validation

* tests
2026-07-16 10:34:25 +00:00
Romeo Van Snick
4081105624 Migrate expression parser and NotebookStepPreview to TypeScript (#77914)
* Generate expression parser as TypeScript

Add --typeScript to lezer-generator so the parser emits typed output; convert lezer.js to lezer.ts.

* Migrate NotebookStepPreview to TypeScript

* Reuse QuestionResultLoaderChildrenProps for VisualizationPreview props
2026-07-16 19:04:36 +10:00
Nicola Mometto
37d96787a1 Better human validation of notification schemas (#77922)
* fix: tighten schema

* 400 rather than 500
2026-07-16 08:57:22 +00:00
Fraser
e823ccc4d7 Move source-location utils to their sole consumer, metabot (#77676) 2026-07-16 20:33:45 +12:00
Romeo Van Snick
4674a3610f Compose route guards as element wrappers (#77686)
* Add element prop support to facade Route

* Compose route guards as element wrappers

* Memoize the element bridge by component type and render off the injected route

* Clarify that index routes accept component or element

* Test that the element bridge reuses shared components across sibling routes
2026-07-16 17:32:12 +10:00
Tyler Plude
1f0d8d6b34 [BOT-1840] Drop visualizer bespoke search index cols (#77684)
* drop temporal dim related search index cols

* revert manual changes to generated docs

* pr feedback; bump up overfetch in visualizer

* rm stale test
2026-07-15 22:02:32 -07:00
Chris Truter
5616e61f7a Allow obsolete semantic search kill-switch (#77903) 2026-07-15 21:58:06 -07:00
Romeo Van Snick
3aa548b966 Migrate DataSelector to TypeScript (#76861)
* Migrate DataSelector SavedEntityPicker to TypeScript

* Align DataSelector picker prop types with actual usage

* Migrate DataSelector to TypeScript and adapt consumers

* Migrate DataSelector unit tests to TypeScript

* Remove unused DataSelector props

* Add TODO for DataSelector api/metabase-lib structural-type cleanup

* Remove unused hasBackButton/onBack from DataSelectorSchemaPicker

* Use setState updater form in setStateWithComputedState

* Guard currentUser instead of faking a zero user id

* Avoid passing a null database to SchemaAndTableDataSelector

* Convert SavedQuestionHeaderButton unit spec to TypeScript

* Justify or remove DataSelector type casts for no-unjustified-type-casts
2026-07-16 14:03:33 +10:00
Timothy S. Dean
cb49c63e0c Better destination-db errors (#77890)
Different endpoints dealing with destination databases handled checks differently, causing confusing error messages depending on code path. This consolidates and standardizes both the messages and the HTTP code.
2026-07-15 20:27:53 -07:00
Romeo Van Snick
99661b47e3 Bundle-size stats: lower record threshold to 0.5% (#77780) 2026-07-16 09:06:44 +10:00
Fraser
98daa6fbf8 Move OnboardingStepper into embedding-hub 2026-07-16 10:19:05 +12:00
Alexander Polyankin
30e4219082 Remove redundant table id from segment and measure API (#77716)
* Remove table_id from measures and segments

* Clear stale Card table_id when the query loses its source table

populate-query-fields only wrote table_id when the derived value was
truthy, so converting an MBQL question to native SQL (or pointing it at
a native model) left table_id referencing the old table. Make it
unconditional, the same fix #68080 applied to source_card_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review: validate derived table on segment/measure updates

- PUT /api/segment/:id and PUT /api/measure/:id now derive the table from an
  updated definition: table-less definitions are rejected (consistent with
  POST), and if the definition implicitly moves the entity to another table,
  the user must also pass the create permission check for the new table
  (write-check alone only covered the old table).
- Drop the dead :table_id insert arg in POST /api/measure — before-insert
  already derives it from the definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Derive segment/measure perms from the definition, not table_id

Per review: instead of API-handler checks against a separately derived table
id, the mi/can-read?, mi/can-write?, and mi/can-create? implementations for
:model/Segment and :model/Measure now derive the source table from the
definition via lib/primary-source-table-id (same admin-or-analyst check as
before, rejecting definitions with no source table), and the API handlers no
longer deal with table ids at all — table_id only remains in what we return
to the client.

Segment definitions are now normalized to MBQL5 at the API boundary (like
measures already were), so the perms and model hooks always see full queries.
Updating a definition re-runs the create check with the new definition, since
the write-check only covers the existing one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix remote-sync segment permission tests for definition-derived perms

The three remote-sync segment tests used empty {} definitions and relied on
table_id/hydrated :table for the permission checks. Perms now derive the
source table from the definition, so give the segments a real MBQL5
definition on the venues table. Also coerce the can-manage helpers to
booleans so callers asserting false? keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Strip stray :aggregation from MBQL4 segment fragments at the API boundary

The model migration always dropped :aggregation from legacy segment
fragments (segments cannot have one). When definition normalization moved to
the API layer, that behavior was lost, so fragments with a stray aggregation
(which several Cypress specs post) started failing schema validation on
insert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reject invalid segment definitions instead of stripping :aggregation

Per review: don't silently fix up segment definitions with a stray
:aggregation — validate the normalized definition against the segment schema
at the API boundary and return a 400. Remove the aggregation key from the
Cypress specs that were posting it (it was always dropped by the old model
migration, so those specs never exercised it anyway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Simplify: keep table_id derivation at the API, revert the perms refactor

Restore the segment/measure permission methods, search filtering, and the
associated tests to their previous table_id-based behavior. The PR keeps to
its core change: the segment/measure APIs no longer accept table_id — it is
derived from the definition and used for the permission checks (including
re-checking the newly derived table when an update moves the entity).

Also update compare-to-the-rest-15655-test for the card change in this PR:
a wrong table_id can no longer be persisted (it is always derived from the
query), so the comparison dashboard for a nil-table card is built from the
field-distribution path now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Only clear a stale card table_id when the query itself changes

populate-query-fields' unconditional :table_id meant any card update
(rename, archive, collection move) would wipe table_id whenever the
derivation came back nil — e.g. when the query's source card had been
hard-deleted. Gate the nil-overwrite on :dataset_query actually being in
t2/changes; inserts and query edits still clear stale values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 18:01:40 -04:00
github-automation-metabase
eed6ef7980 Auto-update documentation for master (#77899)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-15 18:01:11 -04:00
Timofey Kachalov
b21e9dcba1 Fix lost plural translations for strings that also have a msgctxt (#77700) 2026-07-15 21:11:18 +00:00
Nathan Voxland
8771f8eb73 Persistent queue improvements (#77779)
* Persistent queue: concurrent batches, per-queue error handler, config

Generic improvements to the mq persistent-queue subsystem, extracted from the
explorations work so they can land independently:

- max-concurrent-batches: a queue can process multiple batches in parallel,
  bounded by a (optionally dynamic) limit (metabase.mq.queue.concurrency).
- on-error: per-queue handler invoked when a message exhausts its retries,
  so callers can record terminal failures instead of silently dropping them.
- Reworked polling, quartz scheduling, and registry for the above.
- Expanded mq/README.md and tests (concurrency, on-error, quartz affinity).

* Fix Eastwood reflection warnings on Scheduler.checkExists in quartz-test

`Scheduler.checkExists` is overloaded (JobKey vs TriggerKey), so the un-hinted
`nudge-key` made the three call sites reflective. Import `JobKey` and hint the
local; CI's Eastwood step fails the build on reflection warnings.

* Code fixes
2026-07-15 15:20:33 -05:00
Jeff Bruemmer
e5a9978cac docs - 63 temp workflow (#77722)
* 63 temp workflow

* format
2026-07-15 16:15:48 -04:00
Ben Grabow
039504f1e9 Add linear issue link (#77853) 2026-07-15 15:12:57 -05:00
Andrei Chernykh
09e0cf1718 Migrate remaining ModalContent Emotion styles to Mantine and fix dead EmbedModal header overrides (#77324)
* Migrate remaining ModalContent Emotion styles to Mantine and fix dead EmbedModal header overrides

* Make the modal header back button keyboard-accessible with a descriptive label
2026-07-15 11:14:15 -07:00
Ben Grabow
ba4c6a87c3 Update table-level permissions targeting sample DB to use the correct schema on upgrade (#77843)
Update table-level permissions targeting sample DB to use the correct schema
2026-07-15 12:54:25 -05:00
Alexander Polyankin
1921483a0d Revert data_permissions migration (#77864)
* data_permissions migration fix

* fix
2026-07-15 13:28:16 -04:00
Tyler Plude
0f97c3e87b [BOT-1833] Fix semantic search orphan table cleanup (#77499)
* fix orphan semantic search index table cleanup

* Harden orphan index cleanup: schema-scope the scan, skip views, isolate drops

The orphan scan now only considers base tables (a view in the DROP TABLE
batch would abort every nightly cleanup) and, in dedicated-pgvector mode,
only current_schema() — where the unqualified DROP actually resolves;
previously it scanned all schemas and logged drops that never happened.
Stale/orphan tables are dropped one statement at a time with per-table
error handling so a single undroppable table (e.g. one with a dependent
view) cannot permanently block stale, tombstone, and repair cleanup.

Also anchors index-table-name-pattern (\A/\z) so it stays substring-proof
independent of re-matches, documents that legacy pre-BOT-337
index_table_* names must keep matching, and derives the force-reset test
case from model-table-name/model-table-suffix instead of a literal.

* Count actual index-table drops and reap legacy index_table_ stragglers

drop-index-table! now reports :dropped/:missing/:failed — dropping without
IF EXISTS so a vacuous drop (metadata row whose table is already gone)
does not count — and cleanup logs dropped-of-found. The name pattern
gains an index_table_<anything> alternative: the prefix belongs
exclusively to the pre-BOT-337 era, whose digit-less stragglers were
otherwise unreachable.

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2026-07-15 09:40:46 -07:00
appleby
62cdfb57c2 Do not duplicate user context between system prompt and last user message (#77496)
* Do not duplicate user context between system prompt and last user message

In #71045 we moved most of the user context out of internal.selmer system prompt and injected it into the last user
message. However, other system prompts still injected user context into the system prompt, so we wound up formatting
and including it twice. If the `:user_is_viewing` context happens to be large (many related tables / fields), then the
memory and token cost can be significant (#76493).

This commit removes everything from system prompts that was already being injected into the last user message, namely:

- `current_time`
- `first_day_of_week`
- `current_user_info`
- `viewing_context`

It also moves `recent_views` into the last user message (not previously included there).

In addition to saving input tokens and memory, this should also help with caching on providers like openai that do
automatic prefix caching, since we now always inject the current date/time near the end of the prompt.

This also means all system prompts can drop the `<<<METABOT_CACHE_BREAKPOINT>>>` since they no longer have any dynamic
content after the breakpoint.

* Add :slackbot and :document-generate-content to profiles-excluding-recent-views

Their system prompt never included recent_views, so preserve the behavior now that we inject recent_views in the last
user message.
2026-07-15 16:27:20 +00:00
Maksym Yakubych
ba9e327dd0 fix: metabot ai settings (#77637) 2026-07-15 11:20:54 -05:00
Eric Normand
8706236c94 Add test that workspace isolation init never leaks generated passwords (#77721)
A failed CREATE USER used to surface the generated workspace password
verbatim in the JDBC exception message, which provisioning then logged
(GHY-3660). Scrubbing landed in #74657; this adds a regression test that
reproduces the failure for real: init-workspace-isolation! runs over a
connection authenticated as a low-privilege workspace user (granted
database-level CREATE on postgres/redshift so execution reaches the
password-bearing CREATE USER), and every piece of text a logger could
print from the thrown exception - stack trace, Throwable->map, causes,
suppressed, ex-data, and SQLException nextException links - must not
contain the password. A postgres-only assertion requires the redaction
marker in the echoed statement so the test cannot pass vacuously.
2026-07-15 10:18:13 -06:00
Ben Grabow
5598559520 Disable unsupported actions on driver change (#77838) 2026-07-15 11:04:16 -05:00
Ben Grabow
8d47578032 Compile transforms with nested union all layer to prevent inheriting IDENTITY property (#77539)
* Compile transforms with nested union all layer to prevent inheriting IDENTITY property
2026-07-15 10:35:01 -05:00
Timofey Kachalov
7cc5a515f1 Update log4j-* versions (#77808) 2026-07-15 19:10:30 +04:00
Alexander Polyankin
bf028c04dd Treat a blank MB_CONFIG_FILE_PATH as unset (#77821)
JDK 25 changed the empty file path to resolve to the current directory
(File("").exists() is now true), so a long-standing MB_CONFIG_FILE_PATH=''
made the config-from-file loader try to parse the current directory as YAML
and fail at boot. Ignore blank values like metabase.config.core/config-str
does everywhere else, and don't count them as config-text-file being enabled
in anonymous stats.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:19:28 -04:00
Benoit Vinay
b2d652c6ab Search MB documentation link incorrectly has underline on hover (#77701)
Fix for underline
2026-07-15 16:12:23 +02:00
Sébastien
e29d97eb9e Fill available space in the dashboard new-question query builder (#77270) 2026-07-15 15:56:53 +02:00
Kamil Mielnik
83215dcacd GDGT-2400 Custom viz plugin can overflow its own container (#77695) 2026-07-15 13:44:06 +00:00
Kamil Mielnik
f0a65273fb GDGT-2649 New action modal has redundant vertical scrollbar (#77809) 2026-07-15 13:35:42 +00:00
Egor Grushin
0d59548a1e Gdgt 2586 replace deprecated loadingspinner component (#77638)
* initial migration

* initial migration

* remove deadcode loaders

* one more site is migrated. remove dead code

* make data-testid="loading-indicator" to be by default for Loader component

* fix unit test
2026-07-15 14:11:08 +01:00
github-automation-metabase
16a8399a5c Auto-update documentation for master (#77760)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-15 09:10:37 -04:00
Nicola Mometto
45940c8ff1 fix: index manager driver added metabdata (#77807) 2026-07-15 12:35:34 +00:00
Kevin Kelleher
88474b9d2a Fix type error introduced by #77131 (#77817)
fix type error
2026-07-15 12:19:52 +00:00
Oleh K
bb8b550a21 Omit negative values from treemap instead of producing wrong percentages (#77601)
* Omit negative values from treemap instead of producing wrong percentages

Treemaps summed metric values as signed totals and divided by that signed
total, so rows of 30 and -50 rendered a surviving leaf as -150%. Negative
tiles also have no positive area, so ECharts dropped them with no warning.

Mirror the pie chart: omit negative values (with a warning) during
aggregation, or fall back to absolute values when every value is negative.
With all node values non-negative the downstream percentage math is correct.

Fixes GDGT-2760

* Tighten null check and document per-row negative filtering
2026-07-15 13:57:05 +02:00
Alexander Polyankin
373b755b50 Bump mise JDK and docs to temurin 25 to match CI (#77769)
* Bump mise JDK to temurin 25 to match CI

CI defaults to Java 25 (.github/actions/prepare-backend and the backend
workflow matrix), but mise.toml still pinned temurin 21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update remaining Java 21 leftovers to 25

The e2e matrix builder and docs still referenced JDK 21, while CI,
Docker images, and release requirements for v60+ are on Java 25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 07:50:30 -04:00
Kevin Kelleher
03a8afdba8 Truncate categorical axis labels rather than not showing them (#77131)
* truncate categorical axis labels

* Update Loki Snapshots

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-07-15 07:30:44 -04:00
Ngoc Khuat
aaa84da26f Move index manager driver changelog entry to 0.64.0 (#77710)
move index manager driver changelog entry from 0.63.0 to 0.64.0
2026-07-15 09:27:36 +00:00
Arik Aronov
611ef3690b Set enablePutGet as a Snowflake connection property (#77731)
* assoc in connection-details->spec

* Drop unused empty? refer

* fix in check-manager-of-group function name
2026-07-15 12:05:23 +03:00
github-automation-metabase
559301b57b Fix flaky MBQL to SQL transform conversion e2e test (#77699)
* Pin MBQL transform source schema in transforms e2e spec

The many_schemas Postgres fixture creates an "Animals" table in every
schema (Schema A through Schema Z). createMbqlTransform resolved its
source table via getTableId by name only, so Array.find returned an
arbitrary schema's "Animals" depending on /api/table ordering. The
"convert an MBQL transform to a SQL transform" test asserts the compiled
SQL contains "Schema A", so a non-Schema-A match made it flake.

Add an optional sourceSchema to createMbqlTransform and pin the spec's
source to Schema A so the resolved table and its SQL are deterministic.

* Wait for MBQL->SQL transform save to settle before clicking Run tab

The Run tab only exists in read-only view mode; after clicking Save the
test must wait for the update PUT and the navigation off /edit, otherwise
it races the still-in-flight save and times out looking for the Run tab.

* only pin source schema for the default source table

---------

Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
2026-07-15 15:21:14 +07:00
Romeo Van Snick
803f470a7c Replace IndexRoute/IndexRedirect/Redirect with index routes and Navigate (#77591)
* Add index-route support and redirect() to the metabase/router facade

* Adapt ModalRoute and DataPermissionsPage to the function-based Route

* Replace IndexRoute/IndexRedirect/Redirect with index routes and redirect()

* Convert createEntityIdRedirect to a facade-hook wrapper component

* Update route test scaffolding for index routes and redirect()

* Build the Route shim via createRoutes instead of augmenting RouteUtils

* Point the guards spec at the relocated Route export

* Stabilize transforms permission navigation in e2e specs

* Freeze redirect() target at mount to fix chained index-redirect race

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 14:43:41 +10:00
Alexander Polyankin
cb41874c74 Revert "Bound concurrent query-cache writes" (#77781)
Revert "Bound concurrent query-cache writes (#77738)"

This reverts commit a77339d049.
2026-07-14 23:36:46 -04:00
Alexander Polyankin
1a8a17229a Don't GZIP sync-flush per object when serializing QP cache entries (#77786)
* Don't GZIP sync-flush per object when serializing QP cache entries

Sync-flushing after every cached row meant one native Deflater call in a
JNI critical region per row; on JDK <= 21 (no G1 region pinning, JEP 423)
that stalls GC via the GCLocker under concurrent cache writes. Let the
deflater buffer normally instead, and have result-fn finish the stream
(draining the deflater and writing the GZIP trailer) before returning the
bytes. Old cache entries (unterminated gzip) still deserialize since the
reader already treats EOFException as end-of-stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Don't flush the JSON download writer per row

Each per-row flush cascaded into a sync-flush of the gzipped HTTP response:
one native Deflater call in a JNI critical region per exported row (GCLocker
stalls on JDK <= 21) plus one TCP packet per row (#34795, fixed for the :api
writer but not :json downloads) and a worse compression ratio. Let the
BufferedWriter push rows through as it fills, like the CSV writer does;
finish! still flushes the tail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:32:58 -04:00
Romeo Van Snick
972c96b58d Convert modal routes to a modalRoute factory and delete getParentPath (#77372)
* Resolve relative navigation in the router facade's useNavigate

* Add modalRoute factory for modals declared as child routes

* Convert modal routes to the modalRoute factory and delete getParentPath

* Keep navigate's identity stable when v3 rebuilds the matched routes

* Measure matched route pathnames with v3's matchPattern

* Address review: splat note and test, parsePath docstring grammar
2026-07-15 11:36:35 +10:00
Alexander Polyankin
4c9497c75c Add missing FKs (#77713) 2026-07-14 21:16:40 -04:00
technomancy
4e5bb2daa8 Isolate snowflake DB names in CI aggressively (#77669)
Make Snowflake CI runs use a randomly-generated, isolated DB names.

Something is still randomly calling `destroy-db!` on the test-data
dataset and wrecking things for CI runs:

```
(pprint (jdbc/query (no-db-connection-spec)
                    ["SELECT query_text, end_time
                        FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
                       WHERE query_text LIKE '%_c1bae%'
                         AND (query_text LIKE '%DROP%' OR query_text LIKE '%drop%')
                       ORDER BY end_time DESC limit 128"]))
[...]
"DROP DATABASE \"sha_rel_c1baee7db240aa419104c2d925a07a4d4faeeb24_test-data\";"
```

This particular query (without `IF EXISTS`) only exists on master in
the `destroy-db!` method. However, this method already has protection
against being run on the `test-data` dataset and will throw an
exception if this is tried! And the main call to `destroy-db!` is
gated behind a feature check for `:test/dynamic-dataset-loading`. So
there's nowhere this could be coming from on master.

Rather than trying to track it down, I think it's better to just treat
all snowflake DBs as isolated and disposable. Update `qualified-db-name`
to just prefix the DB name with a random prefix, and use the
`after-run` hook to drop it when done. This also allows us to get rid
of the janky and weird locking system, because no two jobs should ever
try to hit the same DB.
2026-07-14 21:05:52 -04:00
Alexander Polyankin
9cd10f877b Always include inactive tables in dependencies API responses (#77714) 2026-07-14 20:00:12 -04:00
Egor Grushin
9a5a4e2587 GDGT-2833 Add per-version download link to Security Center advisories (#77677)
* Add per-version download link to Security Center advisories

Thread optional download_jar_url through backend schema/fetch and render
a Download link next to each fixed version in AdvisoryCard.

* fix aligning

* Read instance version via useSetting; simplify affected-version lookup

* rollback a redundant change

* addressing PRs comments

* use sequence to nil-coerce download_jar_urls

* addressing the PR's comments

* add download-click analytics

* fix test: add download_jar_urls to Advisory factory

* add e2e test
2026-07-15 00:13:03 +01:00
Egor Grushin
7b3ed42a61 GDGT-2585 Replace deprecated Input component (#77559)
* initial migration

* fix link input size

* remove dead files

* Use Input.ClearButton for textinput clear icon

Replace hand-rolled Tooltip+ActionIcon+Icon clear pattern w/ Mantine
Input.ClearButton across 5 sites; c=text-secondary + rightSectionPointerEvents=all.
Comment why LinkViz needs pointer-events on both root+input.

* fix unit test

* Update loki refs for parameter search Input->TextInput

* Use Input.ClearButton in PermissionsEditorContent; rm blank line in SearchBar styled

* addressing the PR's comments
2026-07-14 22:49:55 +01:00
Sloan Sparger
bf26412ac5 [Metabot] Backend driven conversation state (#76939)
* validate client sent parent_message_id

* implement more restrictive retry logic

* move history to the backend

* migate state from conversation to message records

* client nows generates message ids to shrink case of client not knowing id of written message record on aborted requests

* pr feedback

* [Metabot] Implement conversation branches in usage analytics (#77542)

* implement branches in usage analtyics

* sol pass at making code terser / cleaner

* handle error messages correctly + allow branching after assistant message

* pr feedback

* fix test failures

* remove migration logicalFilePath
2026-07-14 16:22:06 -05:00
Egor Grushin
12835e1889 GDGT-2580 Replace deprecated FormErrorMessage component (#77497)
* initial migration

* initial migration

* Fix WritebackParameter missing name/slug in scaffold
2026-07-14 22:09:33 +01:00
Chris Truter
d65b0f5661 Remove the semantic search kill switch (#77570) 2026-07-14 20:51:41 +00:00
Alexander Polyankin
a77339d049 Bound concurrent query-cache writes (#77738) 2026-07-14 16:16:11 -04:00
Alexander Polyankin
fcd561b654 Replace timeout-prone permissions-collapse migration with aggregate-first query (#77727) 2026-07-14 15:39:15 -04:00
Alexander Polyankin
d3a8d67430 Skip static-viz bundle size budget in dev builds (#77741)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:06:17 -04:00
Chris Vandoodewaard
bd6c7a3ef6 Lint Github Actions Workflows with actionlint (#77698)
* add actionlint workflow

* install actionlint with mage

* pass flags to actionlint

* fix options

* don't use mage

* install actionlint locally with mise, update workflow to use install script
2026-07-14 14:43:38 -04:00
Alexander Polyankin
973bddf906 Bump Docker runtime images to Temurin 25 JRE (#77730) 2026-07-14 13:40:54 -04:00
Nick Fitzpatrick
ace59d6c1c Show all connected parameters when setting up a subscription (#77626) 2026-07-14 21:40:27 +05:30
Eric Normand
5246357b5c Gate write share actions (embed publish, public link) on write access (#72752, GHY-3814) (#77528)
* Hide write-only share actions when entity is not writable (#72752)

The Question and Dashboard sharing menus, and the public-link popover,
offered write actions to any admin regardless of write access. On a
read-only remote-synced instance a card/dashboard in a synced collection
is not writable (can_write=false), so these actions 403'd or authored
state that doesn't stick — the "updating embed fails on read-only
instance" symptom in #72752.

Gate the write actions on can_write, matching how the normal edit
affordances already disappear when the entity can't be written:
- Embed (publish): hidden when not writable.
- Create a public link: hidden when not writable; an existing public link
  stays visible so it can still be viewed/copied (those are reads).
- Remove public link: hidden when not writable.

* Disable embed Publish instead of hiding the option on read-only entities (#72752)

Keep the Embed sharing option visible for a read-only remote-synced
card/dashboard, but disable the Publish/Unpublish buttons in the embed
modal with a tooltip explaining the entity is synced and read-only.
This reverts the earlier hiding of the Embed menu item; the public-link
gating (hide create / remove, keep an existing link viewable) is
unchanged.

* Translate the read-only embed tooltip per resource type (#72752)

Interpolating resourceType left the noun untranslated and prevented the
rest of the sentence from agreeing with its gender.
2026-07-14 10:57:58 -05:00
Mihael Konjević
2ca895407f Bot 1729 eval tracing (#77169)
* Add ai-tracing: eval-time trace capture for the Metabot agent

Introduce a new `ai-tracing` module that captures complete, per-run agent
traces during benchmark/eval runs and exports them to an external eval
backend (Confident AI) over OTLP — separate from production observability.

- Capture is gated by a per-run dynamic binding (`*capture*`), orthogonal to
  MB_TRACING_*, so it never fires on organic traffic. Enabled via the
  env-only MB_AI_EVAL_CAPTURE setting; the API path emits the trace as an
  `eval_trace` data part and exports it.
- Captures the full agent.turn -> llm.call -> tool.* tree with prompts,
  completions, tool args/results, durations, and concurrent-tool nesting
  (conveyed across the agent's virtual threads).
- OTLP export uses a dedicated TracerProvider (SimpleSpanProcessor +
  force-flush, never the production one), replaying the in-memory tree
  post-hoc. The domain -> Confident `confident.*` semconv mapping lives in a
  single chokepoint (`node->semconv-attrs`); vendor/schema swaps are one edit.
- Wires capture into run-agent-loop, loop-step, call-llm, and run-tool.
- Trace-level input/output (user question + final answer) set on the root.
- Export failures are logged loudly instead of swallowed.
- Tests cover the gate, nesting, enrichment, timing, concurrency, the
  semconv mapping, and a metadata-size regression guard.

* Make ai-tracing export more idiomatic; refresh stale docs

- export.clj: replace node->semconv-attrs's nested cond-> of repeated
  type checks + json/encode with :ai/keys destructuring and a flat
  medley/assoc-some table; compose final-llm-output as transducers;
  extract event->attributes; tidy parse-headers (for-comprehension),
  build-provider! (doto), and ensure-provider! (fresh? helper).
- core.clj: drop the stale "seam not wired" comment block and the
  outdated ns docstring (it referenced the abandoned gen_ai/OpenInference
  schema and "single namespace" framing); describe the shipped export.

No behavior change; 14 tests / 56 assertions still pass.

* Replace ai-tracing OTLP export with per-session JSONL log files

Switch the eval-trace collection method from OpenTelemetry/OTLP export to a
dedicated file-based logger, and extend instrumentation to the MCP server and
agent-api in addition to the Metabot agent loop.

- Rip out all OTLP/Confident code (export.clj, the OTLP settings, the OTel
  provider/semconv mapping, the eval_trace SSE part).
- New metabase.ai-tracing.log sink: each span streams one JSON line on a
  dedicated logger, routed per session to its own <session-id>.jsonl by a
  Log4j2 RoutingAppender keyed on mb-eval-session-id. Pure JSONL (%m%n), full
  payloads, no size limits.
- Session model = OTel trace-id analog: *session-id* minted at a fresh
  capture's root, or supplied by the caller, conveyed across virtual threads
  by bound-fn*. Nodes carry :id/:parent-id for tree reconstruction.
- Generic with-eval-session entrypoint (gated by MB_AI_EVAL_CAPTURE, inherits
  when nested) reused by run-agent-loop, MCP dispatch-request (keyed on the
  Mcp-Session-Id so a conversation lands in one file), and agent-api routes.
- MB_AI_EVAL_CAPTURE is the single on/off switch; the logger sits at info and
  writes nothing unless a capture is active.
- run-agent-loop emits a tiny eval_session SSE part so the HTTP harness can
  locate the session file.

Verified end-to-end: real agent run writes a per-session JSONL file with the
correct span tree (parent chain, full structured + normal tool output);
sessions route to separate files. 14 tests / 51 assertions pass.

* ai-tracing: capture full request/response context and add trace read API

Enrich eval spans so a captured trace is a self-contained record of a run:

- agent-api: record HTTP request body, user id, and the response status/body
  via a wrapping respond fn that fires inside the span.
- mcp: record JSON-RPC params, user id, scopes, and the materialized result.
- metabot agent: thread an optional eval-session-id through run-agent-loop so
  the harness can name and read back the per-session trace file; record the
  full request (messages/context/state), turn-level token usage, finish reason,
  and final agent state (link registry / generated entities). Surface the loop
  finish-reason out of loop-step instead of only logging it.
- log: guard span serialization so a non-encodable attribute can never break a
  traced run.

Add metabase.ai-tracing.api: a superuser-only GET /api/eval-trace/:session-id
that returns the captured JSONL, mounted only when MB_AI_EVAL_CAPTURE is on.
Filenames are validated against path traversal. Wire the module into routes and
the kondo module config.

* ai-tracing: preserve OpenAPI spec on agent-api routes; harden trace sink

The eval-tracing wrapper turned agent-api `routes` into a plain fn, which no
longer implemented OpenAPISpec — full-API spec generation (openapi.json,
endpoint-dox, the openapi-all-routes test) threw when the walk reached
/api/agent. Wrap it in `handler-with-open-api-spec`, delegating the spec to the
underlying ns-handler.

Also: narrow the trace-sink catch to Exception so Errors/InterruptedException
propagate; stream the trace file instead of slurping it into heap; and correct
the "single nil-check" docstring claim.

* ai-tracing: validate eval-session-id at mint time to prevent path traversal

The trace read endpoint sanitized the session-id before touching disk, but the
write path fed the raw, caller-supplied eval_session_id straight into the
RoutingAppender's `<session-id>.jsonl` file name — a value like `../../foo` on
an eval instance escaped the trace dir (arbitrary .jsonl write).

Hoist the safety regex to a single `safe-session-id-re` in ai-tracing.core and
add `checked-session-id`, which mints a uuid when none is supplied and throws on
an unsafe supplied id. `with-eval-session` now validates every caller-supplied
id before it can reach the log-file path; ai-tracing.api references the shared
regex so read/write validation can't drift.

* ai-tracing: gate span-macro arg construction on capture-active?

The span macros expanded to an eval-span* call, so span-name and attrs
were built on every invocation and discarded on the nil-check when no
capture was live. Hoist the capture-active? guard into a `spanning`
helper macro (and an inlined variant in with-tool-call, whose name
derives from attrs) so organic traffic never builds the span name string
or attrs map — it pays only the *capture* nil-check plus one thunk
closure. with-tool-call now evaluates attrs exactly once, and only when
capturing.

* ai-tracing: test cross-module span nesting; make agent tests capture-agnostic

Add eval-tracing-nesting-test: drives the real run-agent-loop under
capture-reducible with only the LLM + search backend mocked, and asserts
the emitted span tree nests agent.turn -> llm.call -> tool.search. This
covers the wiring the ai-tracing docstrings promise — the turn/llm spans
opened in metabot.agent.core, the tool span in metabot.self.core, and
*parent* conveyed across bound-fn* into the virtual-thread tool executor
— which the ai-tracing unit tests exercised only at the primitive level.

Also pin eval-capture off for the namespace via a fixture. run-agent-loop
appends an eval_session data part to the stream whenever capture is active,
so every exact-match assertion on its output broke under MB_AI_EVAL_CAPTURE
(which dedicated eval instances set). The fixture keeps those assertions on
the production stream shape; the new test forces capture independently.

* ai-tracing: key MCP eval session on UUID correlator; complete finish-reason

Key the MCP eval-trace session on the session id's UUID prefix rather than
the full <uuid>.<base64> id: the prefix is stable across the conversation and
always filesystem/URL-safe, so a base64 payload that require-valid-session
accepts but safe-session-id-re rejects (e.g. `=` padding) can no longer make
checked-session-id throw ahead of the JSON-RPC try/catch and 500 the request.

Record :finish-reason on the empty-response and reduced (early-termination)
loop terminations too, so the turn span's :ai/finish-reason is populated on
every path, not just natural completion.

* ai-tracing: harden trace sink serialization and error capture

- emit!: fall back to a JSON-safe copy when an attribute value isn't
  encodable, so one bad value no longer drops the span (and, for a root
  span, orphans its children).
- span error events now carry the exception class and ex-data, not just
  the message.
- rename JSONL :start-ns/:end-ns to :start-epoch-ns/:end-epoch-ns (they
  hold wall-clock epoch nanos, not System/nanoTime).
- test the api trace-file path-traversal guard directly.

* ai-tracing: bound session ids, gate user-input build, snapshot finish-node

- Cap supplied eval-session-id at 200 chars on the API schemas and in
  checked-session-id, so a supplied id can't produce a filename that fails
  creation and silently drops the trace (charset was already enforced).
- Compute the turn span's :ai/user-input inside the capture-gated attrs map
  instead of unconditionally on every run-agent-loop call.
- finish-node takes a single consistent snapshot of the node atom.

* ai-tracing: enforce session-id cap+charset at API boundaries

Route the 200-char cap through ait/max-session-id-length everywhere (read
endpoint, run-agent-loop opts, POST /api/metabot params) instead of
hardcoding the literal, and bound the previously-unbounded read-endpoint
:session-id path segment.

Validate the charset via safe-session-id-re in the Malli schemas so a bad
eval-session-id 400s at the boundary rather than throwing deep in
checked-session-id and surfacing as a generic agent error. Anchor
safe-session-id-re with \A/\z so it stays exact under both re-matches (mint/
read checks) and re-find (Malli :re) — unanchored, :re accepted any string
containing a safe run (e.g. ../../etc/passwd).

* ai-tracing: key MCP eval trace on a harness-supplied session id

Let an eval harness name and later fetch an MCP conversation's trace. The MCP
endpoint honors an `x-eval-session-id` header — `dispatch-request` keys
`with-eval-session` on it (validated through `checked-session-id`, the single
source of truth for the safe-id contract), falling back to the `Mcp-Session-Id`
correlator when absent. This is the MCP analogue of metabot's `eval_session_id`,
needed because the client owns the `Mcp-Session-Id` and never exposes it.

Also record the resolved query on the construct-query endpoints as `ai/query`
(inert unless capturing): the MCP `construct_query` tool returns only an opaque
handle, so this is the only place a harness can recover the agent's query off
the trace to grade it.

* ai-tracing: address review — guard :reduced append, cover the read API

- run-agent-loop: on a :reduced stop (client disconnect / cancellation) the
  reducing fn has already terminated, so don't step it again to append the
  eval-session pointer or debug part — that violates the transducer contract.
  Fold both trailing appends into one capture-gated cond-> that no-ops on
  :reduced (also fixes the latent debug-branch case). The harness reads the
  JSONL file, not the stream, so a skipped pointer on disconnect costs nothing.
- agent-api: record :http/user-id in the respond closure, where +auth has bound
  *current-user-id* — it is unbound when the span opens, so direct HTTP callers
  previously recorded nil.
- ai-tracing.api: add endpoint integration tests — capture-gated 404,
  superuser-gated 403, missing-trace 404, and existing-trace 200.
- log: mark node->entry ^:private; document that trace-file retention is the
  operator's responsibility (append-only, no rollover, re-runs accumulate).
- mcp: extract the dispatch lambda (line length) and note the fresh-file-per-
  request fallback when neither session id is present. Mark pure tests ^:parallel.

* ai-tracing: drop dead exports, document capture id, harden json-safe

- Remove the `sanitize-sql` re-export and `event!`: neither has a
  non-test caller. Dropping `sanitize-sql` also removes ai-tracing's only
  dependency on the `tracing` module (trimmed from `:uses`). The sites that
  put a query in a span record the raw value on purpose (a scorer wants the
  real SQL/MBQL), so the sanitizer was advertising an inconsistency the
  design doesn't want.
- Document that `capturing`/`capture-reducible` is return-value-based: a
  session id supplied to an inner `with-eval-session` is ignored because
  capture is already active, so no `<id>.jsonl` is written on that path.
- `json-safe` now stringifies non-scalar map keys too, so the fallback can
  never itself throw and orphan the root span it exists to save. Covered by
  a vector-keyed-map regression test.

* ai-tracing: address review — gate pointers, bound json-safe, drop indirection

Correctness:
- capturing/capture-reducible no longer mint a session id, so the in-process
  path is truly file-less (read :trace) instead of writing a stray <uuid>.jsonl
- gate the trailing eval_session pointer on a non-nil *session-id* so the
  file-less path doesn't emit a pointer to a <nil>.jsonl
- add *retain-tree* so the in-memory span tree is only built when a caller
  reads it back (capturing); the file-routed path no longer pins heap for the
  whole run
- depth-bound json-safe so a deep/cyclic attr degrades to a string instead of a
  StackOverflowError escaping emit!'s catch
- agent-api trace wrapper: only record a plain-data :http/response (skip a
  streaming/opaque body rather than stringifying it); fix the stale comment

Cleanup:
- drop the eval-capture-enabled? indirection; entrypoints and tests consult the
  ai-eval-capture setting directly
- with-eval-session: inline supplied-id, ^:once, generic 404 gate message
- extract mcp dispatch-method to de-nest the JSON-RPC case
- remove the dead ai-tracing.core/event! kondo entry
- keep eval JSONL spans out of the shared test log (log4j2-test.xml)

Tests: cover the agent-api trace wrapper, the file-less no-pointer/no-file
behaviour, and drive the log-sink assertions through with-eval-session.

* ai-tracing: fix route sort order and wire settings through init

Two structural test failures on the branch:

- Move the `/eval-trace` route into sorted position in the route-map
  (keep-routes-sorted-test reads the file and asserts the keys are sorted).
- Add `metabase.ai-tracing.init` (requiring the settings ns), require it from
  `metabase.core.init`, and add it to the module's `:api` — so
  settings-namespaces-should-be-required-by-init-namespaces-test passes and the
  `MB_AI_EVAL_CAPTURE` setting is registered deterministically at startup rather
  than only when some instrumented namespace happens to load `ai-tracing.core`.

* ai-tracing: store span wall-clock timestamps as epoch millis, not nanos

Review: the epoch fields were nanos synthesized from currentTimeMillis (ms-
granular anyway), and precise sub-ms duration already lives in :dur-ms — so nanos
bought nothing. Rename :start-epoch-nanos/:end-epoch-nanos → :start-epoch-ms/
:end-epoch-ms (and the JSONL keys :start-epoch-ns/:end-epoch-ns → :start-epoch-ms/
:end-epoch-ms). End is still start + the monotonic duration (rounded to ms), so it
never reads a second, possibly backward-jumping wall clock.
2026-07-14 17:30:50 +02:00
Nick Fitzpatrick
85485ed925 Fix flaky bulk-move dashboard-questions e2e test (#76165)
visitDashboard waits on every dashcard query with cy.wait's default 5s
per-alias window; with 20+ cards the tail queries stagger behind the HTTP/1
concurrency limit and miss it (the dashcardQuery### "no request occurred"
flake). Add a dashcardTimeout option to visitDashboard and use it for the
large-dashboard visits in this test, so the downstream card assertions can
stay at their default timeouts. Also give the 20-request bulk move a longer
window, since its tail lags under CPU constraint.
2026-07-14 20:54:53 +05:30
github-automation-metabase
8d3584593a Auto-update documentation for master (#77657)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-14 10:09:10 -04:00
Kamil Mielnik
6547e398ee GDGT-2748 Broken Security Center layout in error state (#77704) 2026-07-14 20:39:51 +07:00
Chris Vandoodewaard
a6b0d2c7d6 Clean up stale step condition in backend.yml (#77605)
* remove dead condition, update to match other checks

* just use always
2026-07-14 09:08:06 -04:00
Chris Truter
4d04a9743c MCP notes on archiving questions and dashboards (#77569) 2026-07-14 14:34:05 +02:00
Nemanja Glumac
7712c474dd Run master's ci-conductor sources on release branches (#77625)
The ci-conductor scripts are shared CI infrastructure: every supported
release branch runs them, and they keep evolving on master. A copy
committed to a branch goes stale, and syncing six branches by hand on
every change does not scale.

Add a prepare-ci-conductor action that overlays release/ci-conductor and
e2e/support/ci_conductor.ts from master, so a release branch runs the
current scripts while its workflow wiring stays branch-historical.
Anything headed for master uses its own sources, so a PR can still test
its own changes.

quarantine-gate and report-failures-to-ci-conductor delegate their bun
setup to it. prepare-cypress calls it too, because e2e/support/config.js
imports the reporter at Cypress bundle time and it has to be in place
before the run starts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:32:43 +02:00
metamben
dbedcbfbd4 Grouping set pivot (#76560)
* Implement schema, lib helpers, viz-settings + query -> query

* Implement grouping-set compilation

* Implement query nesting to support breakouts with parameters

For example, when selecting nested JSON fields in postgres,
the generated SQL query contains prepared query parameter
placeholders and the engine cannot determine that all copies
are the same expression.
2026-07-14 11:05:24 +00:00
Alexander Polyankin
25eebabf11 2FA E2E tests (#77671)
* 2fa e2e

* tests
2026-07-14 06:40:21 -04:00
Timur Mashara
30daa26a7b GRO-554 update storage upsell (#76052)
* GRO-554 update storage upsell

* combine 2 enabling storage modals

* redesign csv tab + storage upsell

* update gdrive panel upsell / small tweaks on supported DBs for upload

* move StoragePurchaseModal + StorageSetupProvider to ee

* revert unnecesary test change

* remove etl-connections alongside dwh-rent on storage delete; add check for collecting analytics in upsell
2026-07-14 10:36:01 +02:00
Oleh K
ebea514c84 Keep dominant treemap share honest instead of rounding to 100% (#77602)
* Keep dominant treemap share honest instead of rounding to 100%

The treemap percent formatter used a fixed 2 decimal places, so a dominant
leaf's share rounded up to a misleading 100.00% (e.g. 100000 vs 1 rendered
as 100.00% instead of 99.999%).

Mirror the pie chart: derive the decimal count from every node share via
computeMaxDecimalsForValues, so a tiny sibling forces enough precision to
keep the dominant share honest. Floored at the existing two decimals so
ordinary treemaps are unchanged, and a genuine whole still shows a clean 100%.

Fixes GDGT-2761

* Pass tree to test setup via SetupOpts object
2026-07-14 10:15:29 +02:00
github-automation-metabase
622629ef6c Update translations 2026-07-14 (#77689)
* Update translation sources 2026-07-14

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-14 07:04:45 +00:00
Fraser
d60573c199 Move single-consumer code to its consumer 2026-07-14 16:44:14 +12:00
Romeo Van Snick
71e305b6ab Dynamic/plugin route conformance (#77586)
* Add dynamic/plugin route conformance harness

* Add dynamic/plugin route conformance spec
2026-07-14 13:05:39 +10:00
Alexander Polyankin
e7211002cf Static viz imports optimization (#77658) 2026-07-13 22:40:54 -04:00
Romeo Van Snick
cf56c33cdd Delete react-router-redux and redux-auth-wrapper (#77590)
* Delete react-router-redux and redux-auth-wrapper

* Dedupe redux to a single v5 copy

Removing the @types packages dropped a transitive redux@3 pin that kept a
typeless redux at the top level and masked a redux@4-vs-RTK-v5 type conflict.
Force a single redux@5 so types stay consistent.

* Migrate remaining react-router-redux references

Drop the now-dead eslint import bans for the deleted package, remove the
obsolete redux-action cast in auth logout, and reword stale comments. Keep the
metabase/router provenance docs that name the package as their reference point.
2026-07-14 09:53:16 +10:00
Anderson
8eea1fc50b Convert visualization settings components and specs to TypeScript (#76759)
* Convert visualizations/components/settings/* to TypeScript (GDGT-1799)

* Remove castings

* Preserve null chart field values in field picker

* Address review
2026-07-13 20:23:26 -03:00
dpsutton
a420372ba0 Revert "update workflow triggers" (#77666)
Revert "update workflow triggers (#77662)"

This reverts commit bc0d27253e.
2026-07-13 17:57:21 -05:00
Fraser
0b7aa11b15 Remove querying e2e repros already covered by existing Lib/QP tests 2026-07-14 10:57:06 +12:00
dpsutton
bc0d27253e update workflow triggers (#77662) 2026-07-13 17:21:14 -05:00
Cam Saul
e3791b88e8 Change :template-tags from a map to a list (#77133)
* First pass for updating Lib

* Update metabase.lib.query.test-spec

* All Lib + Lib BE tests are passing

* Fix remaining tests in Lib + QP

* Update Lib JS template tag functions

* cljfmt

* Another round of test fixes 🔧

* Leave NativeQuerySnippet :template-tags alone for now; test fixes 🔧

* cljfmt

* kondo

* Test fixes 🔧

* FE test fix? 🔧

* Another round of test fixes 🔧

* Test fix 🔧

* Test fixes 🔧

* Test fix 🔧

* Test fixes 🔧

* PR feedback

* PR feedback

* 🔧

* Test fix 🔧
2026-07-13 14:14:16 -07:00
Michael Eby
6c25c78740 docs: pdfs in subscriptions (#77610)
* docs: pdfs in subscriptions

* Edit from Jeff
2026-07-13 17:08:04 -04:00
Nathan Voxland
b3cfb8d510 Persistent Queue (#76352) 2026-07-13 16:05:07 -05:00
Tyler Plude
2765c8d027 [GIT-74954] Fix search reindex hang on metric reference cycles (#77556)
Co-authored-by: mbql-backend-expert <agent@metabase.com>
Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-07-13 20:50:43 +00:00
Jeff Bruemmer
dad63b3da3 docs - supported AI providers (#77603)
* supported AI providers

* overview

* models
2026-07-13 16:24:59 -04:00
Chris Truter
8ef62d8612 Support pgvector on the app DB with minimal config (#77371) 2026-07-13 20:20:24 +00:00
github-automation-metabase
e2e10c2867 Auto-update documentation for master (#77545)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-13 16:20:15 -04:00
1130 changed files with 446596 additions and 348721 deletions

View File

@@ -179,6 +179,7 @@
clojure.core.async/take!
clojure.core.async/to-chan!
clojure.core.async/to-chan!!
metabase.ai-tracing.core/record!
metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable!
metabase.channel.core/send!
metabase.channel.render.pdf/draw-too-large!
@@ -624,6 +625,9 @@
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
metabase.model-persistence.api-test/with-setup! hooks.common/with-one-top-level-binding
metabase.model-persistence.test-util/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
metabase.mq.core/with-queue hooks.common/let-second
metabase.mq.test-util/with-test-mq hooks.common/with-one-binding
metabase.mq.core/def-listener! hooks.metabase.mq.impl/def-listener
metabase.models.interface/define-batched-hydration-method hooks.metabase.models.interface/define-hydration-method
metabase.models.interface/define-simple-hydration-method hooks.metabase.models.interface/define-hydration-method
metabase.permissions.core/allow-changing-all-users-group-members hooks.common/do*

View File

@@ -159,7 +159,8 @@
{:team "Metabot"
:api #{metabase.agent-api.api
metabase.agent-api.init}
:uses #{api
:uses #{ai-tracing
api
auth-identity
channel
collections
@@ -194,6 +195,15 @@
models
util}}
ai-tracing
{:team "Metabot"
:api #{metabase.ai-tracing.api
metabase.ai-tracing.core
metabase.ai-tracing.init}
:uses #{api
settings
util}}
analytics
{:team "UX West"
:api #{metabase.analytics.api
@@ -1125,6 +1135,7 @@
metabase.lib.metadata
metabase.lib.metadata.protocols
metabase.lib.options
metabase.lib.pivot
metabase.lib.schema
metabase.lib.schema.actions
metabase.lib.schema.aggregation
@@ -1253,6 +1264,7 @@
:model-exports #{:model/McpSessionLog
:model/McpToolCallLog}
:uses #{agent-api
ai-tracing
api
app-db
config
@@ -1298,6 +1310,7 @@
slackbot}
:uses #{activity-feed
agent-lib
ai-tracing
analytics
analytics-interface
api
@@ -1435,6 +1448,17 @@
:model/Table
:model/User}}
mq
{:team "UX West"
:api #{metabase.mq.init}
:uses #{analytics-interface
app-db
classloader
settings
startup
task
util}}
native-query-snippets
{:team "Graphy"
:api #{metabase.native-query-snippets.api
@@ -1878,6 +1902,7 @@
cache
config
dashboards
database-routing
driver
events
formatter
@@ -1971,12 +1996,12 @@
{:team "UX West"
:api #{metabase.sample-data.core
metabase.sample-data.init}
:uses #{plugins
:uses #{driver
plugins
settings
sync
util}
:model-imports #{:model/Database
:model/Table}}
:model-imports #{:model/DataPermissions :model/Database :model/Table}}
search
{:team "UX West"
@@ -2331,6 +2356,7 @@
permissions
premium-features
search
session
util}
:model-imports #{:model/AiUsageLog
:model/Card
@@ -3106,7 +3132,6 @@
{:team "UX West"
:api #{metabase-enterprise.database-routing.api}
:uses #{api
config
database-routing
driver
events
@@ -3539,6 +3564,7 @@
config
connection-pool
events
lib-be
llm
metabot
models

View File

@@ -36,6 +36,9 @@
;; Definitely thread safe
metabase.test.util.dynamic-redefs/patch-vars!
;; test-only cleanup of in-memory throttle state (atom resets)
metabase.session.api/reset-throttlers-for-testing!
;; TODO: most of these symbols shouldn't be here, we should go through them and
;; find the functions/macros that use them and make sure their names end with !
;; best way to do this is try remove each of these and rely on kondo output to find places where it's used

View File

@@ -0,0 +1,18 @@
(ns hooks.metabase.mq.impl
(:require
[clj-kondo.hooks-api :as hooks]))
(defn def-listener
"Kondo hook for `mq/def-listener`. Rewrites to a `fn` form so kondo can analyze bindings.
Form:
(mq/def-listener! :queue/bar [msgs] body...)"
[{:keys [node]}]
(let [[_ _channel-name bindings & body] (:children node)
;; rewrite as: (fn [bindings...] body...)
new-node (hooks/list-node
(list*
(hooks/token-node 'fn)
bindings
body))]
{:node new-node}))

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/vscode/devcontainers/java:21
FROM mcr.microsoft.com/vscode/devcontainers/java:25
# Set up nodesource, install node, bun, fontconfig for static viz, rlwrap for dev ergonomics

View File

@@ -4,7 +4,7 @@ description: |
inputs:
java-version:
required: true
default: "21"
default: "25"
clojure-version:
required: true
default: "1.12.0.1488"

View File

@@ -0,0 +1,52 @@
name: Prepare CI Conductor
description: >-
Make the ci-conductor sources runnable in the workspace.
Unless the checkout is headed for master, they are overlaid from master, so every
branch runs the latest scripts while its workflow wiring stays branch-historical.
runs:
using: composite
steps:
- name: Detect bun
id: bun
shell: bash
run: |
if command -v bun >/dev/null; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: Install bun
if: steps.bun.outputs.present != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: "package.json"
token: ${{ github.token }}
# Anything headed for master runs master's own sources, so a PR can test its own changes.
- name: Fetch ci-conductor sources from master
if: ${{ github.base_ref != 'master' && github.ref_name != 'master' }}
uses: actions/checkout@v6
with:
repository: metabase/metabase
ref: master
path: .ci-conductor-master
persist-credentials: false
sparse-checkout-cone-mode: false
sparse-checkout: |
release/ci-conductor
e2e/support/ci_conductor.ts
# e2e/support/config.js and e2e/support/ci_conductor.ts resolve these by relative
# path at bundle time, so the sources have to land where the branch expects them.
- name: Overlay ci-conductor sources from master
if: ${{ github.base_ref != 'master' && github.ref_name != 'master' }}
shell: bash
run: |
set -euo pipefail
rm -rf release/ci-conductor
mv .ci-conductor-master/release/ci-conductor release/ci-conductor
mv .ci-conductor-master/e2e/support/ci_conductor.ts e2e/support/ci_conductor.ts
rm -rf .ci-conductor-master
echo "Overlaid ci-conductor sources from master."

View File

@@ -15,6 +15,10 @@ outputs:
runs:
using: "composite"
steps:
# e2e/support/config.js imports the reporter, so it has to be in place before Cypress bundles.
- name: Prepare CI Conductor
uses: ./.github/actions/prepare-ci-conductor
- name: Install Chrome v144
uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2
with:

View File

@@ -22,21 +22,8 @@ inputs:
runs:
using: composite
steps:
- name: Detect bun
id: bun
shell: bash
run: |
if command -v bun >/dev/null; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: Install bun
if: steps.bun.outputs.present != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: "package.json"
token: ${{ github.token }}
- name: Prepare CI Conductor
uses: ./.github/actions/prepare-ci-conductor
- name: Check failures against the quarantine list
shell: bash
env:

View File

@@ -15,21 +15,8 @@ inputs:
runs:
using: composite
steps:
- name: Detect bun
id: bun
shell: bash
run: |
if command -v bun >/dev/null; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: Install bun
if: steps.bun.outputs.present != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: 'package.json'
token: ${{ github.token }}
- name: Prepare CI Conductor
uses: ./.github/actions/prepare-ci-conductor
- name: Report failed tests to ci-conductor
shell: bash
env:

View File

@@ -7,6 +7,10 @@ default: &default
- "locales/**"
- "bin/**/!(*.md)"
actionlint:
- ".github/workflows/*.yml"
- ".github/workflows/*.yaml"
shared_sources: &shared_sources
- "src/**/*.cljc"
- "src/**/*.cljs"

View File

@@ -19,7 +19,8 @@ while [[ $# -gt 0 ]]; do
done
echo "Cloning python-runner repository..."
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git --branch seccomp python-runner
if [ "$SKIP_START" = false ]; then
echo "Starting python-runner..."

42
.github/workflows/actionlint.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
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
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
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ steps.changes.outputs.actionlint_files }}

View File

@@ -121,7 +121,7 @@ jobs:
strategy:
fail-fast: false
matrix:
java-version: [21]
java-version: [25]
job:
- name: Enterprise Tests
edition: ee
@@ -221,7 +221,7 @@ jobs:
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always() && (steps.run-java-tests.conclusion != 'skipped' || steps.run-java-tests-21.conclusion != 'skipped')
if: always()
with:
input-path: ./target/junit/
output-name: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.edition }}
@@ -259,7 +259,7 @@ jobs:
timeout-minutes: 10
strategy:
matrix:
java-version: [21]
java-version: [25]
name: "Check Java ${{ matrix.java-version }}"
steps:
- uses: actions/checkout@v6

View File

@@ -26,7 +26,7 @@ jobs:
# Only record a data point when a bundle's as-served size (brotli where the
# build ships .br, else gzip) moved by at least this percent vs the last
# plotted point, keeping the chart to real changes.
MIN_DELTA_PERCENT: "1"
MIN_DELTA_PERCENT: "0.5"
steps:
- name: Check out the code
uses: actions/checkout@v6

22
.github/workflows/docs-generate-63.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Auto-update documentation (release)
on:
schedule:
- cron: "0 23 * * 1-5" # Every Monday through Friday at 11 PM UTC
workflow_dispatch:
inputs:
target_branch:
description: "Target branch for documentation update"
required: false
default: "release-x.63.x"
type: string
jobs:
update-release-docs:
if: ${{ github.event_name != 'schedule' || github.repository == 'metabase/metabase' }}
name: Call documentation update for release branch
uses: ./.github/workflows/docs-generate-base.yml
with:
target_branch: ${{ inputs.target_branch || 'release-x.63.x' }}
secrets:
automation_token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}

View File

@@ -46,7 +46,7 @@ jobs:
const { buildMatrix } = require('${{ github.workspace }}/.github/scripts/build-e2e-matrix.js');
const inputSpecs = "${{ inputs.specs }}";
const inputChunks = Number(process.env.chunks);
const java = 21;
const java = 25;
const defaultRunner = "${{ vars.DEFAULT_RUNNER_KEY }}";
const options = {
java,

View File

@@ -122,7 +122,7 @@ jobs:
strategy:
matrix:
edition: [oss, ee]
java-version: [21]
java-version: [25]
steps:
- name: Prepare JRE (Java Run-time Environment)
uses: actions/setup-java@v5.2.0

View File

@@ -301,12 +301,79 @@ jobs:
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
# 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
# appdb-pgvector-mode round trip (and the URL-independent unit tests) run for real.
semantic-search-tests-appdb-mode:
if: ${{ !inputs.skip }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
version:
- name: pgvector pg17 as app db, no dedicated pgvector
junit-name: semantic-search-tests-appdb-mode-pg17
appdb-image: pgvector/pgvector:pg17
job:
- name: Semantic Search Tests
build-static-viz: false
test-args: >-
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj"]'
exclude-tag: ':mb/driver-tests'
services:
postgres:
image: ${{ matrix.version.appdb-image }}
ports:
- "5432:5432"
env:
POSTGRES_USER: mb_test
POSTGRES_DB: mb_test
POSTGRES_HOST_AUTH_METHOD: trust
env:
CI: 'true'
MB_DB_TYPE: postgres
MB_DB_PORT: 5432
MB_DB_HOST: localhost
MB_DB_DBNAME: mb_test
MB_DB_USER: mb_test
# opts this job into the destructive appdb-pgvector-mode round trip, which then FAILS (rather
# than silently skipping) if the app db can't actually host pgvector
MB_APPDB_PGVECTOR_MODE_TEST: 'true'
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
steps:
- uses: actions/checkout@v6
- name: Test ${{ matrix.version.name }}
id: test-driver
uses: ./.github/actions/test-driver
with:
build-static-viz: ${{ matrix.job.build-static-viz }}
junit-name: ${{ matrix.version.junit-name }}
test-args: >-
${{ 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 }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
semantic-search-tests-result:
needs:
- semantic-search-tests-h2
- semantic-search-tests-mariadb
- semantic-search-tests-mysql
- semantic-search-tests-postgres
- semantic-search-tests-appdb-mode
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 5
name: semantic-search-tests-result

View File

@@ -32,7 +32,7 @@ jobs:
uses: actions/setup-java@v5.2.0
with:
distribution: temurin
java-version: 21
java-version: 25
- name: Set up Babashka
uses: turtlequeue/setup-babashka@d78ec6570aea3b614bc695cafcceb82eb45c2af9 # v1.8.0
with:

View File

@@ -201,7 +201,7 @@ jobs:
uses: actions/setup-java@v5.2.0
with:
distribution: 'temurin'
java-version: 21
java-version: 25
- name: Retrieve test build uberjar artifact for ${{ matrix.edition }}
uses: ./.github/actions/fetch-artifact

View File

@@ -1,3 +1,15 @@
# .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:
@@ -14,18 +26,58 @@ jobs:
( contains(github.event.issue.labels.*.name, 'Type:Bug') ||
contains(github.event.issue.labels.*.name, 'flaky-test-fix') )
steps:
- name: Setting title
- 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: |
core.setOutput('issue_title', JSON.stringify(${{ toJson(github.event.issue.title) }} + '\n' + ${{ toJson(github.event.issue.html_url) }}));
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=$(sed 's/&/\$/g' <<< "$LABEL_NAME")
team_label="${LABEL_NAME//&/$}"
echo "$team_label"
team_name=${team_label#*.Team/}
echo "$team_name"
@@ -55,7 +107,7 @@ jobs:
"type": "section",
"text": {
"type": "mrkdwn",
"text": ${{ steps.vars.outputs.issue_title }}
"text": ${{ steps.vars.outputs.message }}
}
}
]

View File

@@ -143,7 +143,7 @@ jobs:
- name: Prepare back-end environment
uses: ./.github/actions/prepare-backend
with:
java-version: "${{ needs.get-build-requirements.outputs.java_version || 21 }}"
java-version: "${{ needs.get-build-requirements.outputs.java_version || 25 }}"
- name: Build
run: ./bin/build.sh
- name: Upload bundle stats
@@ -174,7 +174,7 @@ jobs:
strategy:
matrix:
edition: ["ee", "oss"]
java-version: [21]
java-version: [25]
steps:
- name: Check out the code
uses: actions/checkout@v6

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -13,7 +13,7 @@ RUN apt-get update && apt-get upgrade -y && apt-get install wget apt-transport-h
&& wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor | tee /etc/apt/trusted.gpg.d/adoptium.gpg > /dev/null \
&& echo "deb https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | tee /etc/apt/sources.list.d/adoptium.list \
&& apt-get update \
&& apt install temurin-21-jdk -y \
&& apt install temurin-25-jdk -y \
&& 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 \
@@ -41,7 +41,7 @@ RUN INTERACTIVE=false CI=true MB_EDITION=$MB_EDITION bin/build.sh :version ${VER
## Remember that this runner image needs to be the same as bin/docker/Dockerfile with the exception that this one grabs the
## jar from the previous stage rather than the local build
FROM eclipse-temurin:21-jre-alpine AS runner
FROM eclipse-temurin:25-jre-alpine AS runner
ENV FC_LANG=en-US LC_CTYPE=en_US.UTF-8

View File

@@ -55,7 +55,9 @@
:fuzzy? (.isFuzzy message)
:plural? (.isPlural message)
:source-references (seq (remove str/blank? (.getSourceReferences message)))
:comment (.getMsgctxt message)}))
;; the `msgctxt` a message was extracted with (nil when it declares none). Part of a
;; message's identity: the same `msgid` can appear under several contexts.
:context (.getMsgctxt message)}))
(defn po-contents
"Contents of the PO file for a `locale`."

View File

@@ -22,20 +22,34 @@
[message-id]
(str/replace message-id #"\{\s*(\d+)\s*\}" "\\${ $1 }"))
(defn- ->translations-map [messages drop-msgids]
{"" (into {}
(comp
;; filter out i18n messages that aren't used on the FE client
(filter frontend-message?)
(remove (fn [{:keys [id]}] (contains? drop-msgids id)))
i18n/print-message-count-xform
(map (fn [message]
[(->ttag-reference (:id message))
(if (:plural? message)
{:msgid_plural (:id-plural message)
:msgstr (map ->ttag-reference (:str-plural message))}
{:msgstr [(->ttag-reference (:str message))]})])))
messages)})
(defn- ->translation-entry [message]
[(->ttag-reference (:id message))
(if (:plural? message)
{:msgid_plural (:id-plural message)
:msgstr (map ->ttag-reference (:str-plural message))}
{:msgstr [(->ttag-reference (:str message))]})])
(defn- ->translations-map
"ttag looks a translation up by (context, msgid), so the top level of the map is keyed by the
message's `msgctxt` — `\"\"` for the messages that declare none.
Two messages may share a `msgid` as long as their contexts differ, e.g. `Year` exists both as a
pluralized unit (`ngettext(\"Year\", \"Years\", n)`) and as a granularity option
(``c(\"Date granularity option\").t`Year` ``). Keying by `msgid` alone collapsed them onto one
entry, so the last one won and the other's translation was lost — including, for the pair above,
the plural form that `ngettext` then failed to find."
[messages drop-msgids]
(let [frontend-messages (into []
(comp
;; filter out i18n messages that aren't used on the FE client
(filter frontend-message?)
(remove (fn [{:keys [id]}] (contains? drop-msgids id)))
i18n/print-message-count-xform)
messages)]
(into {}
(map (fn [[context messages]]
[context (into {} (map ->translation-entry) messages)]))
(group-by #(or (:context %) "") frontend-messages))))
(defn- ->i18n-map
"Convert the contents of a `.po` file to map format used in the frontend client."

View File

@@ -36,5 +36,21 @@
{:msgstr ["Average of ${ 0 }"]}
"Median of ${ 0 }"
{:msgstr ["Median of ${ 0 }"]}}}}
(#'frontend/->i18n-map test-common/po-contents #{}))))
{:msgstr ["Median of ${ 0 }"]}
"Year"
{:msgid_plural "Years"
:msgstr ["Año" "Años"]}}
"Date granularity option, distinct from the pluralized unit"
{"Year"
{:msgstr ["Año"]}}}}
(#'frontend/->i18n-map test-common/po-contents #{})))
(testing "a msgid that exists under several contexts keeps one entry per context, so neither
translation (nor a plural form) is lost"
(let [{translations :translations} (#'frontend/->i18n-map test-common/po-contents #{})]
(is (= {:msgid_plural "Years", :msgstr ["Año" "Años"]}
(get-in translations ["" "Year"])))
(is (= {:msgstr ["Año"]}
(get-in translations ["Date granularity option, distinct from the pluralized unit"
"Year"]))))))

View File

@@ -8,7 +8,7 @@
:fuzzy? false
:plural? false
:source-references ["frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:136"]
:comment nil})
:context nil})
(def ^:private singular-message-backend
{:id "No table description yet"
@@ -18,7 +18,7 @@
:fuzzy? false
:plural? false
:source-references ["metabase/warehouse_schema/models/table.clj"]
:comment nil})
:context nil})
(def ^:private singular-template-message-frontend
{:id "Count of {0}"
@@ -28,7 +28,7 @@
:fuzzy? false
:plural? false
:source-references ["frontend/src/metabase/reference/databases/TableDetail.jsx:38"]
:comment nil})
:context nil})
(def ^:private singular-template-message-backend
{:id "Count of {0}"
@@ -38,7 +38,7 @@
:fuzzy? false
:plural? false
:source-references ["src/metabase/warehouse_schema/models/table.clj:80"]
:comment nil})
:context nil})
(def ^:private plural-message-frontend
{:id "{0} Queryable Table"
@@ -48,7 +48,7 @@
:fuzzy? false
:plural? true
:source-references ["frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx:77"]
:comment nil})
:context nil})
(def ^:private plural-message-backend
{:id "{0} table"
@@ -58,7 +58,7 @@
:fuzzy? false
:plural? true
:source-references ["src/metabase/automagic_dashboards/core.clj"]
:comment nil})
:context nil})
(def ^:private plural-message-frontend-with-empty
{:id "{0} metric"
@@ -68,7 +68,7 @@
:fuzzy? false
:plural? true
:source-references ["frontend/src/metabase/query_builder/components/view/QuestionDescription.jsx:20"]
:comment nil})
:context nil})
(def ^:private plural-message-backend-with-empty
{:id "{0} metric"
@@ -78,7 +78,7 @@
:fuzzy? false
:plural? true
:source-references ["src/metabase/automagic_dashboards/core.clj"]
:comment nil})
:context nil})
(def ^:private cljc-message
{:id "Average of {0}"
@@ -88,7 +88,7 @@
:fuzzy? false
:plural? false
:source-references ["src/metabase/utils/aggregation.cljc"]
:comment nil})
:context nil})
(def ^:private cljs-message
{:id "Median of {0}"
@@ -98,7 +98,29 @@
:fuzzy? false
:plural? false
:source-references ["src/metabase/utils/aggregation.cljs"]
:comment nil})
:context nil})
;; The same `msgid` under two different contexts — a pluralized unit and a granularity option.
;; They are separate messages, and collapsing them onto one entry loses a translation.
(def ^:private plural-message-frontend-no-context
{:id "Year"
:id-plural "Years"
:str nil
:str-plural ["Año" "Años"]
:fuzzy? false
:plural? true
:source-references ["frontend/src/metabase/querying/utils.ts:41"]
:context nil})
(def ^:private singular-message-frontend-with-context
{:id "Year"
:id-plural nil
:str "Año"
:str-plural nil
:fuzzy? false
:plural? false
:source-references ["frontend/src/metabase/visualizations/SmartScalar/definition.ts:127"]
:context "Date granularity option, distinct from the pluralized unit"})
(def ^:private messages
[singular-message-frontend
@@ -110,7 +132,9 @@
plural-message-backend
plural-message-backend-with-empty
cljc-message
cljs-message])
cljs-message
plural-message-frontend-no-context
singular-message-frontend-with-context])
(def po-contents
"Contents of a `.po` file."

View File

@@ -40,7 +40,7 @@ elif [[ "$BASE_REF" == "master" ]]; then
echo "CURRENT_VERSION: ${CURRENT_VERSION}"
matches=0
for l in backport double-backport triple-backport; do
for l in backport single-backport double-backport triple-backport; do
if [[ ",$labels," == *",$l,"* ]]; then
# N.B. not ((matches++)) — that exits 1 when matches=0 under set -e
matches=$((matches + 1))
@@ -51,6 +51,7 @@ elif [[ "$BASE_REF" == "master" ]]; then
version=$((CURRENT_VERSION + 1))
# Backport labels shift the target version back so migrations land on the correct release branch.
[[ ",$labels," == *",backport,"* ]] && version=$((CURRENT_VERSION))
[[ ",$labels," == *",single-backport,"* ]] && version=$((CURRENT_VERSION))
[[ ",$labels," == *",double-backport,"* ]] && version=$((CURRENT_VERSION - 1))
[[ ",$labels," == *",triple-backport,"* ]] && version=$((CURRENT_VERSION - 2))
@@ -104,7 +105,7 @@ if [[ "$errors" -gt 0 ]]; then
fi
echo "" >&2
echo "If this PR will be backported, ensure the correct backport label is applied" >&2
echo "(backport, double-backport, or triple-backport) and re-run CI, even if you" >&2
echo "(backport, single-backport, double-backport, or triple-backport) and re-run CI, even if you" >&2
echo "plan to create the backport PRs manually. Base the label on the oldest" >&2
echo "release you're targeting, and simply close any unwanted PRs." >&2
echo "" >&2

View File

@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jre-alpine
FROM eclipse-temurin:25-jre-alpine
ENV FC_LANG=en-US LC_CTYPE=en_US.UTF-8

View File

@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jre-noble as runner
FROM eclipse-temurin:25-jre-noble as runner
ENV FC_LANG=en-US LC_CTYPE=en_US.UTF-8

View File

@@ -11,6 +11,9 @@ JAVA_OPTS="${JAVA_OPTS} -Dlogfile.path=target/log"
JAVA_OPTS="${JAVA_OPTS} -XX:+CrashOnOutOfMemoryError"
JAVA_OPTS="${JAVA_OPTS} -server"
JAVA_OPTS="${JAVA_OPTS} --add-opens java.base/java.nio=ALL-UNNAMED"
# Truffle (GraalJS/GraalPy) loads a native library; JDK 25+ warns and future JDKs
# will block it unless native access is enabled
JAVA_OPTS="${JAVA_OPTS} --enable-native-access=ALL-UNNAMED"
if [ ! -z "$JAVA_TIMEZONE" ]; then
JAVA_OPTS="${JAVA_OPTS} -Duser.timezone=${JAVA_TIMEZONE}"

View File

@@ -68,7 +68,6 @@
"@tiptap/suggestion": "^3.2.0",
"@types/js-yaml": "^4.0.9",
"@types/react-router": "3.0.27",
"@types/redux-auth-wrapper": "2.0.16",
"@uiw/react-codemirror": "^4.23.6",
"@visx/axis": "^3.10.1",
"@visx/clip-path": "^3.3.0",
@@ -147,12 +146,10 @@
"react-redux": "^9.1.2",
"react-resizable": "^3.1.3",
"react-router": "3",
"react-router-redux": "^4.0.8",
"react-transition-group": "^4.4.5",
"react-use": "^17.5.1",
"react-virtualized": "9.22.6",
"redux-actions": "^2.0.1",
"redux-auth-wrapper": "2.1.0",
"redux-undo": "^1.1.0",
"rehype-external-links": "^3.0.0",
"remark-gfm": "^4.0.0",
@@ -271,7 +268,6 @@
"@types/react-grid-layout": "^1.3.5",
"@types/react-is": "^18.2.4",
"@types/react-resizable": "^3.0.4",
"@types/react-router-redux": "^4.0.53",
"@types/react-textarea-autosize": "^4.3.6",
"@types/react-transition-group": "^4.4.5",
"@types/react-virtualized": "^9.22.2",
@@ -362,6 +358,7 @@
"open": "^10.1.0",
"open-cli": "^8.0.0",
"openapi-typescript": "^7.10.0",
"otpauth": "^9.5.1",
"oxfmt": "^0.38.0",
"patch-package": "^8.0.1",
"pg": "^8.8.0",
@@ -456,6 +453,7 @@
"prosemirror-state": "^1.4.4",
"prosemirror-view": "^1.40.1",
"qs": "6.15.2",
"redux": "5.0.1",
"webpack": "5.105.0",
"webpack-dev-middleware": "^5.3.4",
"webpack-dev-server": "^5.2.1",
@@ -1235,7 +1233,7 @@
"@nicolo-ribaudo/eslint-scope-5-internals": ["@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1", "", { "dependencies": { "eslint-scope": "5.1.1" } }, "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
@@ -1973,8 +1971,6 @@
"@types/react-router": ["@types/react-router@3.0.27", "", { "dependencies": { "@types/history": "^3", "@types/react": "*" } }, "sha512-Sq4bfTZ8TuHFTHXyzA3tu/BTGm/d4B8CwtOK4qOyJNoJMkY87qMOazkBizMHNpszVR1+kYOCNLjPdh/wAD7ESw=="],
"@types/react-router-redux": ["@types/react-router-redux@4.0.55", "", { "dependencies": { "@types/history": "^3.2.5", "redux": "^3.6.0" } }, "sha512-2fbxIc9OTmVyOUgBnRa3gcbMJT035Scu1bmkYMEiv7IzMht24W+gGzIrlYurSz3cijY4r9d16O/0+nycZ0RXjg=="],
"@types/react-textarea-autosize": ["@types/react-textarea-autosize@4.3.6", "", { "dependencies": { "@types/react": "*" } }, "sha512-cTf8tCem0c8A7CERYbTuF+bRFaqYu7N7HLCa6ZhUhDx8XnUsTpGx5udMWljt87JpciUKuUkImKPEsy6kcKhrcQ=="],
"@types/react-transition-group": ["@types/react-transition-group@4.4.5", "", { "dependencies": { "@types/react": "*" } }, "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA=="],
@@ -1985,8 +1981,6 @@
"@types/redux-actions": ["@types/redux-actions@2.6.5", "", {}, "sha512-RgXOigay5cNweP+xH1ru+Vaaj1xXYLpWIfSVO8cSA8Ii2xvR+HRfWYdLe1UVOA8X0kIklalGOa0DTDyld0obkg=="],
"@types/redux-auth-wrapper": ["@types/redux-auth-wrapper@2.0.16", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "redux": "^3.7.2" } }, "sha512-Uj3IFMqVD4e9aCiBPrw+Ix5msJvWlrjRAVa1iV529zNzEYhdtLfVq9/tM4K9yZWMRjLFqXbTam2qz2bgnklrSA=="],
"@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="],
"@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
@@ -2863,8 +2857,6 @@
"decode-tiff": ["decode-tiff@0.2.1", "", {}, "sha512-v/7hQBv/DrOVQ+Eljg0BLMRbXZYuuw3YZ8duZuFxYpo6qUkdn7oFRkN95RZKbnh08EHNjrMXMbEUNhTLuhPvvA=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
@@ -4297,6 +4289,8 @@
"ospath": ["ospath@1.2.2", "", {}, "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA=="],
"otpauth": ["otpauth@9.5.1", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ=="],
"outdent": ["outdent@0.8.0", "", {}, "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A=="],
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
@@ -4721,8 +4715,6 @@
"react-router": ["react-router@3.2.6", "", { "dependencies": { "create-react-class": "^15.5.1", "history": "^3.0.0", "hoist-non-react-statics": "^3.3.2", "invariant": "^2.2.1", "loose-envify": "^1.2.0", "prop-types": "^15.7.2", "react-is": "^16.13.0", "warning": "^3.0.0" } }, "sha512-nlxtQE8B22hb/JxdaslI1tfZacxFU8x8BJryXOnR2RxB4vc01zuHYAHAIgmBkdk1kzXaA25hZxK6KAH/+CXArw=="],
"react-router-redux": ["react-router-redux@4.0.8", "", {}, "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4="],
"react-router-v7": ["react-router@7.18.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
@@ -4771,12 +4763,10 @@
"reduce-reducers": ["reduce-reducers@0.4.3", "", {}, "sha512-+CNMnI8QhgVMtAt54uQs3kUxC3Sybpa7Y63HR14uGLgI9/QR5ggHvpxwhGGe3wmx5V91YwqQIblN9k5lspAmGw=="],
"redux": ["redux@3.7.2", "", { "dependencies": { "lodash": "^4.2.1", "lodash-es": "^4.2.1", "loose-envify": "^1.1.0", "symbol-observable": "^1.0.3" } }, "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-actions": ["redux-actions@2.6.5", "", { "dependencies": { "invariant": "^2.2.4", "just-curry-it": "^3.1.0", "loose-envify": "^1.4.0", "reduce-reducers": "^0.4.3", "to-camel-case": "^1.0.0" } }, "sha512-pFhEcWFTYNk7DhQgxMGnbsB1H2glqhQJRQrtPb96kD3hWiZRzXHwwmFPswg6V2MjraXRXWNmuP9P84tvdLAJmw=="],
"redux-auth-wrapper": ["redux-auth-wrapper@2.1.0", "", { "dependencies": { "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4", "lodash.isempty": "^4.4.0", "prop-types": "^15.5.0", "query-string": "^5.1.1" } }, "sha512-UtU64cJk2pWcMMfgWIVoyBVG0p8ZiGJ++vqrvQ5r5ghZZOLRq+M5aTS0RRNneiB+aCCZBzI+txFSaKYVRrv8qQ=="],
"redux-thunk": ["redux-thunk@3.1.0", "", {}, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"redux-undo": ["redux-undo@1.1.0", "", {}, "sha512-zzLFh2qeF0MTIlzDhDLm9NtkfBqCllQJ3OCuIl5RKlG/ayHw6GUdIFdMhzMS9NnrnWdBX5u//ExMOHpfudGGOg=="],
@@ -5195,8 +5185,6 @@
"swc-plugin-coverage-instrument": ["swc-plugin-coverage-instrument@0.0.32", "", {}, "sha512-KsO1xNQdcVb331Rm98Op6uYf36/CYdF1kILQxOddmtCNlRLISpCUqHcnogu+QgELZvG48oie+w0fzS8uktjcEA=="],
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
@@ -5841,12 +5829,12 @@
"@redocly/openapi-core/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
"@redocly/respect-core/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@redocly/respect-core/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
"@redocly/respect-core/colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"@reduxjs/toolkit/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"@rushstack/node-core-library/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@rushstack/node-core-library/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="],
@@ -5983,8 +5971,6 @@
"@types/react-virtualized/@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/redux-auth-wrapper/@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="],
"@types/serve-index/@types/express": ["@types/express@4.17.13", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.18", "@types/qs": "*", "@types/serve-static": "*" } }, "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA=="],
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
@@ -6299,8 +6285,6 @@
"diffie-hellman/bn.js": ["bn.js@4.11.9", "", {}, "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw=="],
"dnd-core/redux": ["redux@4.2.0", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA=="],
"dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -7005,8 +6989,6 @@
"reduce-function-call/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"redux-auth-wrapper/query-string": ["query-string@5.1.1", "", { "dependencies": { "decode-uri-component": "^0.2.0", "object-assign": "^4.1.0", "strict-uri-encode": "^1.0.0" } }, "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw=="],
"reg-cli/glob": ["glob@7.2.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q=="],
"reg-cli/make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
@@ -8207,10 +8189,6 @@
"raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"react-dnd-html5-backend/dnd-core/redux": ["redux@4.2.0", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA=="],
"react-dnd/dnd-core/redux": ["redux@4.2.0", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA=="],
"react-docgen-typescript-plugin/find-cache-dir/make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
"react-docgen/@types/babel__core/@types/babel__traverse": ["@types/babel__traverse@7.14.2", "", { "dependencies": { "@babel/types": "^7.3.0" } }, "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA=="],

View File

@@ -117,15 +117,15 @@
org.apache.httpcomponents.client5/httpclient5 {:mvn/version "5.6.2"} ; pinned: must pair with httpcore5 5.4.x (upstream pairing); ClickHouse JDBC needs httpcore5 5.3.4+
org.apache.httpcomponents.core5/httpcore5 {:mvn/version "5.4.3"} ; pinned: 5.4.2+
org.apache.httpcomponents.core5/httpcore5-h2 {:mvn/version "5.4.3"} ; pinned: must match httpcore5 version
org.apache.logging.log4j/log4j-1.2-api {:mvn/version "2.25.4"} ; apache logging framework
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.4"} ; add compatibility with log4j 1.2
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.4"} ; apache logging framework
org.apache.logging.log4j/log4j-jcl {:mvn/version "2.25.4"} ; allows the commons-logging API to work with log4j 2
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.4"} ; java.util.logging (JUL) -> Log4j2 adapter
org.apache.logging.log4j/log4j-1.2-api {:mvn/version "2.26.1"} ; apache logging framework
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.1"} ; add compatibility with log4j 1.2
org.apache.logging.log4j/log4j-core {:mvn/version "2.26.1"} ; apache logging framework
org.apache.logging.log4j/log4j-jcl {:mvn/version "2.26.1"} ; allows the commons-logging API to work with log4j 2
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.1"} ; java.util.logging (JUL) -> Log4j2 adapter
org.apache.logging.log4j/log4j-layout-template-json
{:mvn/version "2.25.4"} ; allows the custom json logging format
{:mvn/version "2.26.1"} ; allows the custom json logging format
org.apache.logging.log4j/log4j-slf4j2-impl
{:mvn/version "2.25.4"} ; allows the slf4j2 API to work with log4j 2
{:mvn/version "2.26.1"} ; allows the slf4j2 API to work with log4j 2
org.apache.pdfbox/pdfbox {:mvn/version "3.0.5"} ; PDF generation (backend dashboard PDF prototype)
org.apache.poi/poi {:mvn/version "5.5.1"} ; Work with Office documents (e.g. Excel spreadsheets) -- newer version than one specified by Docjure
org.apache.poi/poi-ooxml {:mvn/version "5.5.1"
@@ -161,10 +161,10 @@
org.clojure/tools.namespace {:mvn/version "1.5.0"}
org.clojure/tools.reader {:mvn/version "1.5.2"}
org.clojure/tools.trace {:mvn/version "0.8.0"} ; function tracing
org.eclipse.jetty/jetty-server {:mvn/version "12.1.7"}
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.7"}
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.7"}
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.7"}
org.eclipse.jetty/jetty-server {:mvn/version "12.1.11"}
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.11"}
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.11"}
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.11"}
org.eclipse.jgit/org.eclipse.jgit {:mvn/version "7.3.0.202506031305-r"}; Java implementation of Git version control system
org.flatland/ordered {:mvn/version "1.15.12"} ; ordered maps & sets
org.graalvm.polyglot/js-community {:mvn/version "25.0.1" :extension "pom"} ; JavaScript engine
@@ -651,7 +651,15 @@
;; https://clojurians.slack.com/archives/C03S1KBA2/p1667925853699669
;; An issue has been filed here https://github.com/jonase/eastwood/issues/444
;; and a simple reproduction repo can be found here https://github.com/metabase/snowplow-eastwood-issue
:exclude-namespaces [metabase.analytics.snowplow]}}
;;
;; The two Quartz queue-affinity DriverDelegate shims are `gen-class` namespaces whose
;; only body is a `(.superRtp this ...)` call to their own exposed super method. That
;; method exists only on the class the namespace itself generates, so `this` can't be
;; type-hinted with it at compile time (the class isn't defined yet) — the reflective
;; call is unavoidable. Eastwood also can't reliably analyze these gen-class shims.
:exclude-namespaces [metabase.analytics.snowplow
metabase.mq.quartz-affinity-delegate-std
metabase.mq.quartz-affinity-delegate-postgres]}}
;; Test counterpart of :eastwood. Starts with :reflection only; add more linters as we fix other eastwood
;; issues in tests.

273
dev/src/dev/mq_perf.clj Normal file
View File

@@ -0,0 +1,273 @@
(ns dev.mq-perf
"Load/perf harness for the **Quartz** MQ backend (`metabase.mq.queue.quartz`).
Unlike the old appdb harness this drives the real Quartz path: each published batch becomes a
trigger on a per-queue durable job, fired by Quartz's worker pool. It therefore needs a running
Quartz scheduler with the JDBC JobStore — i.e. run it inside a normal dev system (the dev REPL
already has `db` + scheduler + mq up) rather than the old `start-mq!` standalone mode.
Key thing to watch: the publish buffer coalesces messages published close together into one
batch (one trigger carrying up to `:max-batch-messages` messages). So Quartz fires far fewer
triggers than there are messages — the benchmarks report both message throughput and the trigger
count / coalescing factor, since the trigger rate is what actually stresses the QRTZ_* tables.
Transactional modes (`:mode`): every benchmark takes a `:mode` selecting which publish path in
`metabase.mq.publish` it drives — this is what makes the QRTZ pressure differ dramatically:
:none-no-txn — `:never` queue, published *outside* a transaction. Immediate publish through the
sliding-window buffer, which coalesces across publishers (few, fat triggers).
:none-in-txn — `:never` queue, published *inside* a transaction. Deferred to after-commit (in
memory, no outbox table), then goes through the same buffer — still coalesced.
:require — `:require` queue, published *inside* a transaction (mandatory — throws otherwise).
Messages are written to the `queue_message_outbox` table in the business txn and
published *directly to the backend at commit*, BYPASSING the buffer. So there is
no cross-transaction coalescing: one committed publish ≈ one trigger. This is the
durable path, and its trigger rate tracks the message rate — the QRTZ tables feel
every message. (See the perf note on `def-queue!`.)
So the same message rate produces wildly different trigger counts across modes; the benchmarks
report `:mode`, triggers fired, and the coalescing factor so the tradeoff is visible.
Usage (in the dev REPL, with the system started):
(require 'dev.mq-perf)
(dev.mq-perf/run-all!) ; sweeps every mode
(dev.mq-perf/bench-sustained! {:duration-sec 10 :target-msgs-sec 2000 :mode :require})
(dev.mq-perf/bench-e2e-latency! {:n 500 :msgs-sec 500 :mode :none-in-txn})
(dev.mq-perf/bench-publish-throughput! {:n 5000 :mode :none-no-txn})"
(:require
[metabase.mq.core :as mq]
[metabase.mq.listener :as listener]
[metabase.mq.publish-buffer :as publish-buffer]
[metabase.mq.queue.quartz :as q.quartz]
[metabase.mq.queue.registry :as q.registry]
[metabase.task.core :as task]
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
(org.quartz JobKey JobListener Scheduler)
(org.quartz.impl.matchers GroupMatcher)))
(set! *warn-on-reflection* true)
;;; --------------------------------------------- helpers ---------------------------------------------
(defn- ensure-queue!
([ch] (ensure-queue! ch {:transactional :try}))
([ch config] (when-not (q.registry/get-queue ch) (q.registry/register-queue! ch config))))
(def scenarios
"Publish paths the harness exercises, each selecting a distinct route through
`metabase.mq.publish/publish-collected!`. `:transactional` is the queue's declared mode; `:in-txn?`
is whether the benchmark wraps each publish in a DB transaction. See the ns docstring for details."
{:none-no-txn {:transactional :never :in-txn? false}
:none-in-txn {:transactional :never :in-txn? true}
:require {:transactional :require :in-txn? true}})
(defn- scenario-queue
"Per-mode queue name, so the three modes never collide on config (a queue is registered once)."
[base mode]
(keyword "queue" (str base "-" (name mode))))
(defn- mode-config
"Queue config for `mode`, merged with any `extra` (e.g. `:max-batch-messages`)."
[mode extra]
(merge {:transactional (:transactional (scenarios mode))} extra))
(defn- putter
"Returns `(fn [msg])` publishing one message to `ch` on `mode`'s path — wrapping the publish in a DB
transaction for the transactional modes so `:require` hits the outbox and `:none-in-txn` hits the
after-commit defer."
[ch mode]
(if (:in-txn? (scenarios mode))
(fn [msg] (t2/with-transaction [_conn] (mq/with-queue ch [q] (mq/put q msg))))
(fn [msg] (mq/with-queue ch [q] (mq/put q msg)))))
(defn- outbox-depth
"Rows still sitting in the transactional outbox for `ch` — should be 0 after a clean run (the
after-commit path deletes each row once it's published). A non-zero value means publishes are being
left for the recovery sweep, i.e. the backend couldn't keep up at commit time."
[ch]
(t2/count :queue_message_outbox :queue_name (name ch)))
(defn- pct [sorted p]
(when (seq sorted)
(nth sorted (min (dec (count sorted)) (long (* p (count sorted)))))))
(defn- stats [xs]
(let [s (vec (sort xs))]
{:n (count s) :min (first s) :max (peek s)
:p50 (pct s 0.50) :p95 (pct s 0.95) :p99 (pct s 0.99)
:avg (when (seq s) (/ (reduce + 0.0 s) (count s)))}))
(defn- fmt [x] (when x (format "%.2f" (double x))))
(defn- publish-at-rate!
"Calls `(f i)` `n` times, pacing to ~`msgs-sec` (nil = as fast as possible)."
[n msgs-sec f]
(let [interval-ns (when (and msgs-sec (pos? msgs-sec)) (long (/ 1e9 msgs-sec)))
start (System/nanoTime)]
(dotimes [i n]
(f i)
(when interval-ns
(let [target (+ start (* (long (inc i)) interval-ns))
now (System/nanoTime)]
(when (> target now)
(let [sleep-ms (long (/ (- target now) 1e6))]
(when (pos? sleep-ms) (Thread/sleep sleep-ms)))))))))
(defn- do-with-trigger-count!
"Counts actual Quartz job executions (= triggers fired) in the mq group while `thunk` runs, via a
temporary JobListener. Returns `[thunk-result trigger-count]`. This is the true trigger rate — not
the listener-invocation count, which is inflated by max-batch-messages slicing."
[thunk]
(let [^Scheduler scheduler (task/scheduler)
counter (atom 0)
lname "mq-perf-trigger-counter"
jl (reify JobListener
(getName [_] lname)
(jobToBeExecuted [_ _ctx])
(jobExecutionVetoed [_ _ctx])
(jobWasExecuted [_ _ctx _ex] (swap! counter inc)))]
(.addJobListener (.getListenerManager scheduler) jl (GroupMatcher/jobGroupEquals "metabase.mq.queue"))
(try
[(thunk) @counter]
(finally (.removeJobListener (.getListenerManager scheduler) lname)))))
(defn clean!
"Unlisten + drop all `:queue/bench-*` queues and delete their durable Quartz jobs (and any
leftover triggers) so a benchmark run leaves the scheduler tidy.
Also evicts the bench queues from the quartz backend's private `ensured-jobs` process cache. That
cache is deliberately trusted over the scheduler on the publish hot path, so without this eviction
a *re-run* of a same-named benchmark would skip recreating the just-deleted durable job and every
publish would throw `JobPersistenceException` (silently dropped by the publish buffer → 0 delivered)."
[]
(doseq [ch (keys @q.registry/*queues*)
:when (.startsWith (name ch) "bench-")]
(try (mq/unlisten! ch) (catch Throwable _))
(swap! q.registry/*queues* dissoc ch))
;; `ensured-jobs` is private to the quartz backend; reach it via its var (dev harness only).
(swap! @#'q.quartz/ensured-jobs (fn [s] (into #{} (remove (fn [[ch _]] (.startsWith (name ch) "bench-"))) s)))
(when-let [^Scheduler scheduler (task/scheduler)]
(doseq [^JobKey jk (.getJobKeys scheduler (GroupMatcher/jobGroupEquals "metabase.mq.queue"))
:when (.startsWith (.getName jk) "bench-")]
(.deleteJob scheduler jk))))
;;; --------------------------------------------- benchmarks ---------------------------------------------
(defn bench-publish-throughput!
"How fast can the publish side enqueue + schedule triggers? Publishes `n` single-message batches
as fast as possible (buffer flushed at the end), measuring publish-side msgs/sec. Does not wait
for delivery. `:mode` selects the publish path (see [[scenarios]]); the transactional modes pay a
DB round-trip per message, so this is where their per-publish cost shows up."
[{:keys [n mode] :or {n 5000 mode :none-no-txn}}]
(let [ch (scenario-queue "bench-pub" mode)
put (putter ch mode)]
(ensure-queue! ch (mode-config mode nil))
(let [start (System/nanoTime)]
(publish-at-rate! n nil (fn [i] (put {:i i})))
(publish-buffer/flush-publish-buffer!)
(let [secs (/ (- (System/nanoTime) start) 1e9)]
{:bench :publish-throughput :mode mode :n n
:elapsed-sec (fmt secs) :msgs-sec (fmt (/ n secs))}))))
(defn bench-e2e-latency!
"Publishes `n` messages at ~`msgs-sec` and measures publish->receive latency (ms) percentiles
through the full pipeline (buffer -> quartz trigger -> worker thread -> listener)."
[{:keys [n msgs-sec mode] :or {n 500 msgs-sec 500 mode :none-no-txn}}]
(let [ch (scenario-queue "bench-e2e" mode)
put (putter ch mode)
latencies (atom [])
received (atom 0)]
(ensure-queue! ch (mode-config mode nil))
(listener/batch-listen! ch (fn [msgs]
(let [now (System/nanoTime)]
(doseq [m msgs]
(when-let [p (:pub-ns m)]
(swap! latencies conj (/ (- now (double p)) 1e6)))
(swap! received inc)))))
(try
(publish-at-rate! n msgs-sec (fn [_] (put {:pub-ns (System/nanoTime)})))
(publish-buffer/flush-publish-buffer!)
(let [deadline (+ (System/currentTimeMillis) 30000)]
(while (and (< @received n) (< (System/currentTimeMillis) deadline))
(Thread/sleep 50)))
(merge {:bench :e2e-latency :mode mode :published n :received @received :target-msgs-sec msgs-sec}
(update-vals (select-keys (stats @latencies) [:p50 :p95 :p99 :min :max :avg]) fmt))
(finally (mq/unlisten! ch)))))
(defn bench-sustained!
"Publishes at ~`target-msgs-sec` for `duration-sec`, then drains. Reports delivered messages/sec,
the number of Quartz triggers actually fired, and the resulting coalescing factor (messages per
trigger). `:mode` selects the publish path (see [[scenarios]]): the buffered modes coalesce so the
trigger rate stays flat, while `:require` publishes each committed batch straight to the backend so
the trigger rate tracks the message rate — compare `:triggers-sec` / `:msgs-per-trigger` across
modes to see the QRTZ pressure tradeoff. `:outbox-remaining` should be 0 (rows deleted on publish)."
[{:keys [duration-sec target-msgs-sec max-batch mode]
:or {duration-sec 10 target-msgs-sec 2000 max-batch 100 mode :none-no-txn}}]
(let [ch (scenario-queue (str "bench-sustained-mb" max-batch) mode)
put (putter ch mode)
received (atom 0)]
(ensure-queue! ch (mode-config mode {:max-batch-messages max-batch}))
(listener/batch-listen! ch (fn [msgs] (swap! received + (count msgs))))
(try
(let [n (* duration-sec target-msgs-sec)
[result triggers]
(do-with-trigger-count!
(fn []
(let [start (System/nanoTime)]
(publish-at-rate! n target-msgs-sec (fn [i] (put {:i i})))
(publish-buffer/flush-publish-buffer!)
(let [pub-secs (/ (- (System/nanoTime) start) 1e9)
deadline (+ (System/currentTimeMillis) 60000)]
(while (and (< @received n) (< (System/currentTimeMillis) deadline))
(Thread/sleep 100))
{:pub-secs pub-secs :total-secs (/ (- (System/nanoTime) start) 1e9)}))))]
{:bench :sustained :mode mode :target-msgs-sec target-msgs-sec :duration-sec duration-sec :max-batch max-batch
:published n :received @received :outbox-remaining (outbox-depth ch)
:publish-sec (fmt (:pub-secs result)) :total-sec (fmt (:total-secs result))
:delivered-msgs-sec (fmt (/ @received (:total-secs result)))
:triggers-fired triggers
:triggers-sec (fmt (/ triggers (:total-secs result)))
:msgs-per-trigger (fmt (/ @received (max 1 triggers)))})
(finally (mq/unlisten! ch)))))
(defn start-system!
"Headless bootstrap for a benchmark node: app DB + Quartz scheduler + MQ subsystem (no Jetty).
Idempotent enough for a fresh process. Point `MB_DB_*` at a DEDICATED database — running several
nodes against the same DB exercises Quartz clustering, but aiming at a live instance's DB would
make this node also run that instance's scheduled jobs."
[]
((requiring-resolve 'metabase.app-db.core/setup-db!) :create-sample-content? false)
((requiring-resolve 'metabase.task.core/start-scheduler!))
(require 'metabase.mq.init)
;; invoking the startup-logic method for ::MqStart runs mq.init/start!
((requiring-resolve 'metabase.startup.core/def-startup-logic!) :metabase.mq.init/MqStart)
(log/info "MQ benchmark node started (DB + scheduler + MQ)."))
(defn run-modes!
"Runs the sustained throughput benchmark across all three [[scenarios]] at the same message rate so
the QRTZ-pressure tradeoff is directly comparable. `clean!`s between modes so each starts fresh.
`:require` pays a DB round-trip per publish, so it defaults to a gentler rate."
[{:keys [duration-sec target-msgs-sec require-msgs-sec]
:or {duration-sec 10 target-msgs-sec 2000 require-msgs-sec 500}}]
(mapv (fn [mode]
(log/infof "MQ quartz perf: sustained (%s)" (name mode))
(clean!)
(bench-sustained! {:duration-sec duration-sec
:target-msgs-sec (if (= mode :require) require-msgs-sec target-msgs-sec)
:mode mode}))
[:none-no-txn :none-in-txn :require]))
(defn run-all!
"Runs the standard suite (publish throughput, e2e latency, then a sustained sweep across every
transactional mode) and returns a vector of result maps. Assumes the system is already up (use
[[start-system!]] first in a headless process)."
[]
(log/info "MQ quartz perf: publish throughput")
(let [r1 (bench-publish-throughput! {:n 5000})]
(log/info "MQ quartz perf: e2e latency")
(let [r2 (bench-e2e-latency! {:n 500 :msgs-sec 500})]
(log/info "MQ quartz perf: sustained throughput (all modes)")
(into [r1 r2] (run-modes! {:duration-sec 10 :target-msgs-sec 2000})))))

98
dev/src/dev/mq_perf_multi.sh Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Runs multiple Quartz-MQ benchmark nodes against the same database to exercise clustering.
#
# Usage:
# ./dev/src/dev/mq_perf_multi.sh start # Start 2 nodes (default)
# ./dev/src/dev/mq_perf_multi.sh start 4 # Start 4 nodes
# ./dev/src/dev/mq_perf_multi.sh stop # Stop all running nodes
# ./dev/src/dev/mq_perf_multi.sh logs # Tail all node logs
#
# Each node starts a headless JVM (DB + Quartz scheduler + MQ, no Jetty) and runs the benchmark
# suite immediately. Because the Quartz backend is clustered via the JDBC JobStore, all nodes
# pointed at the same DB form a cluster and share trigger execution.
#
# IMPORTANT: point MB_DB_* at a DEDICATED throwaway database, e.g.:
# MB_DB_TYPE=postgres MB_DB_DBNAME=mq_bench MB_DB_USER=... ./dev/src/dev/mq_perf_multi.sh start 3
# Aiming at a live instance's DB would make these nodes also run that instance's scheduled jobs.
#
# (A synchronized "go" start across nodes is not implemented — nodes begin as soon as they boot.)
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
LOG_DIR="target/mq-perf-logs"
PID_DIR="target/mq-perf-pids"
cmd_start() {
local num_nodes="${1:-2}"
mkdir -p "$LOG_DIR" "$PID_DIR"
# Clean up stale pids
rm -f "$PID_DIR"/*.pid
echo "Starting $num_nodes MQ benchmark nodes..."
echo "Logs in $LOG_DIR/"
echo
for i in $(seq 1 "$num_nodes"); do
local log_file="$LOG_DIR/node-$i.log"
clojure -M:dev:ee:ee-dev -e "
(require 'dev.mq-perf)
(dev.mq-perf/start-system!)
(clojure.pprint/pprint (dev.mq-perf/run-all!))
(dev.mq-perf/clean!)
(shutdown-agents)
" > "$log_file" 2>&1 &
local pid=$!
echo "$pid" > "$PID_DIR/node-$i.pid"
echo " Node $i started (PID $pid, log: $log_file)"
done
echo
echo "Nodes are running the suite. Watch progress with:"
echo " $0 logs"
}
cmd_stop() {
if [ ! -d "$PID_DIR" ]; then
echo "No nodes running."
return
fi
for pid_file in "$PID_DIR"/*.pid; do
[ -f "$pid_file" ] || continue
local pid
pid=$(cat "$pid_file")
local name
name=$(basename "$pid_file" .pid)
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
echo "Stopped $name (PID $pid)"
else
echo "$name (PID $pid) already exited"
fi
rm -f "$pid_file"
done
}
cmd_logs() {
if [ ! -d "$LOG_DIR" ]; then
echo "No logs found."
return
fi
tail -f "$LOG_DIR"/node-*.log
}
case "${1:-}" in
start) cmd_start "${2:-2}" ;;
stop) cmd_stop ;;
logs) cmd_logs ;;
*)
echo "Usage: $0 {start [N]|stop|logs}"
echo
echo " start [N] Start N benchmark nodes (default 2); each runs the suite immediately"
echo " stop Kill all running nodes"
echo " logs Tail all node logs"
exit 1
;;
esac

View File

@@ -26,7 +26,7 @@ Metabot is Metabase's built-in AI agent. Metabot can help you with most daily ta
**Provider**: Choose from:
- Metabase's own AI Service (available as an add-on exclusively for Metabase Cloud)
- Third-party models via an API key (Anthropic models only for now).
- Third-party models via your own [provider API key](settings.md#bring-your-own-api-key)
**Plans**: available on all plans. You can only use Metabase's AI Service on Metabase Cloud.

View File

@@ -54,9 +54,15 @@ To disable Metabase AI provider and stop charges:
### Bring your own API key
You can specify your own API key and model for Metabot from one of the supported providers. Currently, Metabase only supports models from Anthropic.
You can specify your own API key and model for Metabot from one of the supported providers. When you connect a provider, the **Model** dropdown lists the supported models that your API key can access.
If you're interested in Metabase supporting more AI providers, let us know by submitting a [feature request](../troubleshooting-guide/requesting-new-features.md).
- **Amazon Bedrock**: Claude and GPT models.
- **Anthropic**: Claude models (Opus, Sonnet, Haiku, and Fable).
- **Microsoft Azure**: Your own Azure deployments of Claude or GPT models. Enter the deployment name you configured in Azure.
- **OpenAI**: GPT models.
- **OpenRouter**: Claude and GPT models.
If you're interested in Metabase supporting more AI providers or models, let us know by submitting a [feature request](../troubleshooting-guide/requesting-new-features.md).
To enable AI features with your own API key:

1724
docs/api.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -222,6 +222,8 @@ config:
metabot-enabled: true
metabot-recent-views-enabled: true
metabot-slack-signing-secret: null
mfa-challenge-signing-key: null
mfa-enforcement: 'off'
native-query-autocomplete-match-style: substring
nested-field-columns-value-length-limit: 50000
no-data-illustration: default
@@ -242,6 +244,9 @@ config:
premium-embedding-token: null
query-caching-max-kb: 2000
query-caching-max-ttl: 3024000.0
queue-backend: quartz
queue-max-retries: 5
queue-no-listener-max-age-ms: 86400000
redirect-all-requests-to-https: false
remote-sync-auto-import: false
remote-sync-auto-import-rate: 5

View File

@@ -1301,7 +1301,7 @@ The Anthropic API Key.
- 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`, `openrouter/anthropic/claude-haiku-4.5`.
### `MB_LOAD_ANALYTICS_CONTENT`
@@ -1394,6 +1394,21 @@ Whether the user's recently viewed items are included in the Metabot system prom
Signing secret for verifying requests from the Metabot Slack app.
### `MB_MFA_CHALLENGE_SIGNING_KEY`
- Type: string
- Default: `null`
Key used to sign MFA challenge tokens. Generated automatically on first use.
### `MB_MFA_ENFORCEMENT`
- Type: keyword
- Default: `off`
- [Configuration file name](./config-file.md): `mfa-enforcement`
Controls whether two-factor authentication is available to users. :off disables it entirely; :optional allows users to enroll voluntarily.
### `MB_NATIVE_QUERY_AUTOCOMPLETE_MATCH_STYLE`
- Type: keyword
@@ -1581,6 +1596,27 @@ The maximum size of the cache, per saved question, in kilobytes.
The absolute maximum time to keep any cached query results, in seconds.
### `MB_QUEUE_BACKEND`
- Type: string
- Default: `quartz`
Which queue backend to use. Valid values: `quartz`, `memory`.
### `MB_QUEUE_MAX_RETRIES`
- Type: integer
- Default: `5`
Maximum number of times a failed queue message will be retried before being dropped.
### `MB_QUEUE_NO_LISTENER_MAX_AGE_MS`
- Type: integer
- Default: `86400000`
How long (in milliseconds) a Quartz queue message with no listener anywhere in the cluster is kept before the reaper drops it. With node-affinity, such a message is never acquired and just waits in the store; this bounds that wait so a message for a queue no node handles (e.g. a new queue whose node was rolled back) does not linger forever. Generous by default (1 day) so a node can restart or be re-upgraded and still deliver it.
### `MB_REDIRECT_ALL_REQUESTS_TO_HTTPS`
- Type: boolean

View File

@@ -331,6 +331,8 @@ Public links can be viewed by anyone, even if they don't have access to Metabase
You can export the entire dashboard as PDF, or export results of individual questions on the dashboard. See [Exporting results](../questions/exporting-results.md).
To get a PDF of a dashboard delivered on a schedule, [attach a PDF to a dashboard subscription](./subscriptions.md#attach-a-pdf-of-the-dashboard).
## Configuring a dashboard through its URL
You can amend the URL of a dashboard to automatically enter fullscreen or auto-refresh the dashboard. Customizing the dashboard's URL allows you to configure the dashboard - even when you do not have any input access to the device where the dashboard will be displayed, like scripted screens, for example.

View File

@@ -79,8 +79,6 @@ This is helpful for dashboards that are displaying time-limited results (for exa
When configuring an email subscription, you can tell Metabase if it should attach results to the email as a file, in addition to displaying the table in the email body.
Currently, you can only send attachments with question results. You can't send the whole dashboard as a PDF attachment.
If you've added filters to your dashboard and set default values for those filters, Metabase will apply those default values to your subscriptions, filtering the results of all questions that are connected to those filters when the subscriptions are sent. To learn more, check out [dashboard filters](./filters.md).
### File format
@@ -104,6 +102,20 @@ The attached files will include up to 1 048 575 rows by default (Excel's row lim
You can toggle on "Send only attachments (no charts)" if you want to receive just the .csv or .xlsx files without the charts. Remember to select the results you want to attach in "Questions to attach" above the toggle.
## Attach a PDF of the dashboard
To attach a PDF of the dashboard to your subscription, enable the **Attach a PDF of the dashboard** toggle in the subscription settings. When the subscription runs, Metabase generates the PDF and attaches it to the email or Slack message.
The PDF attachment follows the layout of your dashboard. Cards appear in the same arrangement as the dashboard, and each tab starts a new page. Some cards may display differently than they do in your browser. For example, tables that scroll on the dashboard may display all of their columns in the PDF.
Metabase generates the PDF on the Metabase server, which means:
- Generating the PDF can take a minute or more.
- The PDF uses a light theme. It doesn't follow the theme you set in your browser.
- The PDF can differ in small ways from the dashboard in your browser. For example, map legends may display in a different position, and charts with many small segments may hide some labels.
Iframe cards created from a URL display as links. Iframe cards created from embed code display "Unable to render this card".
## Adding multiple subscriptions
You can add multiple subscriptions to a single dashboard. To add a subscription, click on the **+** icon in the dashboard subscription panel.

View File

@@ -18,7 +18,7 @@ To complete any build of the Metabase code, you'll need to install the following
1. [Clojure (https://clojure.org)](https://clojure.org/guides/getting_started) - install the latest release by following the guide depending on your OS
2. [Java Development Kit JDK (https://adoptopenjdk.net/releases.html)](https://adoptopenjdk.net/releases.html) - you need to install JDK 21 ([more info on Java versions](../installation-and-operation/running-the-metabase-jar-file.md))
2. [Java Development Kit JDK (https://adoptopenjdk.net/releases.html)](https://adoptopenjdk.net/releases.html) - you need to install JDK 25 ([more info on Java versions](../installation-and-operation/running-the-metabase-jar-file.md))
3. [Node.js (https://nodejs.org/)](https://nodejs.org/) - latest LTS release
@@ -33,7 +33,7 @@ curl -fsSL https://bun.sh/install | bash
On a most recent stable Ubuntu/Debian, all the tools above, with the exception of Clojure and Bun, can be installed by using:
```
sudo apt install openjdk-21-jdk nodejs
sudo apt install openjdk-25-jdk nodejs
```
If you have multiple JDK versions installed in your machine, be sure to switch your JDK before building with:
@@ -42,7 +42,7 @@ If you have multiple JDK versions installed in your machine, be sure to switch y
sudo update-alternatives --config java
```
Then select Java 21 in the menu.
Then select Java 25 in the menu.
### Running on M1 Apple computers

View File

@@ -4,7 +4,11 @@ title: Driver interface changelog
# Driver Interface Changelog
## Metabase 0.63.0
## Metabase 0.64.0
- Added a `:native-pivot-tables` driver feature flag for drivers that can compile a pivot query as a single
`GROUP BY GROUPING SETS (...)` statement instead of the legacy multi-query path. Drivers that opt in must also
derive from `:sql-mbql5` (which provides the `:pivot` clause compiler). Defaults to `false`.
- Index Manager: drivers can now read and create table indexes, in the broad sense (secondary indexes, sort keys,
distribution keys, clustering, etc.). New driver feature flags:
@@ -34,8 +38,10 @@ title: Driver interface changelog
- `metabase.driver/compile-create-index` `[driver schema table structured]` -- compiles a `:standalone` index into
the DDL statement(s) that create it.
- `metabase.driver/refresh-table-stats!` `[driver database schema table transform-type]` -- refreshes table
statistics (e.g. `ANALYZE`) after a transform run. Defaults to a no-op.
## Metabase 0.63.0
- `metabase.driver/refresh-table-stats!` `[driver database schema table transform-type]` -- refreshes table
statistics (e.g. `ANALYZE`) after a transform run. Defaults to a no-op.
- `metabase.driver/describe-table-fks`, deprecated in 0.49.0, has been removed. Please implement
`metabase.driver/describe-fks` instead. This method is now required for drivers that support

View File

@@ -281,6 +281,8 @@ app.get("/sso/metabase", async (req, res) => {
});
```
For modular embedding, Metabase exchanges the JWT via `POST /auth/sso` with a JSON body (not a `GET` redirect). Full app embedding typically uses the `GET` redirect shown above. See [JWT-based authentication](../people-and-groups/authenticating-with-jwt.md).
## Embedding Metabase in a different domain
{% include plans-blockquote.html feature="Authenticated modular embeds" convert_pro_link_to_embedding=true is_plural=true %}

View File

@@ -109,6 +109,8 @@ If you're using [JWT](../people-and-groups/authenticating-with-jwt.md), you can
https://metabase.example.com/auth/sso?jwt=<token>&return_to=%2Fdashboard%2F1
```
To avoid putting the JWT in the URL, you can also authenticate with a `POST` request and a JSON body. See [JWT-based authentication](../people-and-groups/authenticating-with-jwt.md).
You must URL encode (or double encode, depending on your web setup) all of the parameters in your redirect link, including parameters for filters (e.g., `filter=value`) and [UI settings](#showing-or-hiding-metabase-ui-components) (e.g., `top_nav=true`). For example, if you added two filter parameters to the JWT example shown above, your `src` link would become:
```

View File

@@ -197,6 +197,8 @@ See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embe
Locked parameters let you filter data without exposing the filter to the end-user. Locking parameters is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
![Locked parameters](./images/locked-parameters.png)
To use locked parameters, you need to:
1. Set the parameter to **Locked** in the embed settings.

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -308,6 +308,8 @@ For an overview of how data permissions work in Metabase, see [Data permissions]
If every tenant has their data in a separate database, then instead of using permissions for data access control, you can use [**database routing**](../permissions/database-routing.md) to route queries to appropriate databases directly.
For a comparison of data isolation methods, see [Choosing a data isolation method](../permissions/data-isolation-methods.md).
- **Create queries** controls whether tenant users can create queries on the data they see. If you want to give your tenant users the ability to drill through (e.g., through `drills` parameter in [modular embedding](../embedding/modular-embedding.md)), you need to give them "Create queries" permissions, because a drill through is a new query.
- **Download results** controls, unsurprisingly, whether people can download results of queries. You need to set download permissions if you want to give your users the option to download their data as a spreadsheet (for example, through `with-downloads` parameter in [modular embedding](../embedding/modular-embedding.md)).
@@ -437,3 +439,4 @@ If you disable multi-tenant strategy, _all your tenant users will be deactivated
- [Embedding overview](./start.md)
- [JWT authentication](../people-and-groups/authenticating-with-jwt.md)
- [Permissions overview](../permissions/start.md)
- [Data isolation methods](../permissions/data-isolation-methods.md)

View File

@@ -45,7 +45,7 @@ You may already have Java installed. To check the version, open a terminal and r
java -version
```
If Java isn't installed, you'll need to install Java before you can run Metabase. We recommend version 21 of JRE from [Eclipse Temurin](https://adoptium.net/) with HotSpot JVM. You can run Metabase wherever Java 21 runs. Earlier Java versions aren't supported. The particular processor architecture shouldn't matter (although we only test Metabase for x86 and ARM).
If Java isn't installed, you'll need to install Java before you can run Metabase. We recommend version 25 of JRE from [Eclipse Temurin](https://adoptium.net/) with HotSpot JVM. You can run Metabase wherever Java 25 runs. Earlier Java versions aren't supported. The particular processor architecture shouldn't matter (although we only test Metabase for x86 and ARM).
### 2. Download Metabase

View File

@@ -22,7 +22,30 @@ Assuming your site is localhost serving on port 3000:
5. In the event of a successful sign-in, your authentication app should issue a GET request to your Metabase endpoint with the token and the "return to" URI: `http://localhost:3000/auth/sso?jwt=TOKEN_GOES_HERE&return_to=/question/1-superb-question`.
6. Metabase verifies the JSON Web Token, logs the person in, then redirects the person to their original destination, `/question/1-superb-question`.
As an alternative to putting the JWT in the URL query string (which some environments log or cache), you can send the same token in the body of a `POST` request to `/auth/sso` with `Content-Type: application/json` and a JSON body like `{"jwt": "TOKEN_GOES_HERE"}`. You can still pass `return_to` as a query parameter on that URL (for example `POST /auth/sso?return_to=/question/1-superb-question`). The login behavior is otherwise the same as the GET flow.
- For full app embeds, use a `GET` request.
- For modular embeds use a `POST` request with a JSON body (to avoid putting the JWT in the URL). You'll also want to use `POST` requests in contexts that log or cache URLs, such as server-side integrations or testing.
In both cases the login behavior is the same, and you can pass `return_to` as a query parameter in both `GET` and `POST` requests.
An example `POST` request with curl:
```bash
curl -X POST "http://localhost:3000/auth/sso?return_to=/question/1-superb-question" \
-H "Content-Type: application/json" \
-d '{"jwt": "TOKEN_GOES_HERE"}'
```
Or with JavaScript:
```js
await fetch(`${METABASE_URL}/auth/sso?return_to=/question/1-superb-question`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jwt: token }),
credentials: "include",
});
```
## Set up JWT authentication

View File

@@ -0,0 +1,90 @@
---
title: Data isolation methods
summary: Compare the ways Metabase can restrict data access for internal analytics or multi-tenant setups, including row and column security, impersonation, database routing, and locked parameters.
---
# Data isolation methods
> Row and column security, impersonation, and database routing are only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
Metabase offers several ways to ensure people only see the data they're supposed to see, whether you're restricting data by team for internal analytics or isolating customer data in a multi-tenant setup. The best method depends on how you store your data and where you want to enforce access rules:
- If all data lives in the same tables, use [row and column security](#row-and-column-security) to filter data in Metabase, or [impersonation](#impersonation) to enforce access rules at the database level.
- If each customer or team has their own database, use [database routing](#database-routing).
- If you're embedding dashboards for people without Metabase accounts, use [locked parameters](#locked-parameters).
You can combine these methods. For example, you can use locked parameters for a guest embed while using row and column security for signed-in users.
## Row and column security
[Row and column security](./row-and-column-security.md) lets you control which rows and columns each group can see. You can define rules that filter data based on [user attributes](../people-and-groups/managing.md#adding-a-user-attribute). Metabase applies your rules to questions you build in the query builder.
![Row and column security](./images/row-and-column-level-security.png)
Use row and column security when:
- You need granular control over which rows and columns each group or customer can see.
- You want to manage access rules in the Metabase UI instead of in your database.
Don't use row and column security when:
- People need to write native SQL queries. Row and column security doesn't apply to the SQL editor.
- You need column-level security on MongoDB or Druid, which only support row-level security.
## Impersonation
[Impersonation](./impersonation.md) lets your database control what each person can see. You define roles and row-level security policies in your database. Metabase connects using the role specified by each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute), and your database enforces what that role can see.
![Impersonation](./images/connection-impersonation.png)
Use impersonation when:
- You want to centralize access logic in your database instead of in Metabase.
- People need to write native SQL queries. Since your database enforces the rules, the rules apply in the SQL editor too.
Don't use impersonation when:
- Your database [doesn't support it](./impersonation.md#databases-that-support-impersonation).
- You don't want to set up and maintain roles and policies in your database.
- You rely heavily on caching. You can't share cached results between roles.
## Database routing
[Database routing](./database-routing.md) sends each person's queries to their own database. You build questions and dashboards once against a primary database, and Metabase routes queries to the right destination database based on each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute). Database routing requires that each group's data lives in a separate database, and that all destination databases have identical schemas.
![Database routing](./images/database-routing-diagram.png)
Use database routing when:
- You want the strongest isolation. Each group's data lives in a separate database.
- You want to avoid one group's queries affecting the performance of another group's queries.
Don't use database routing when:
- Groups share tables. Use [row and column security](#row-and-column-security) or [impersonation](#impersonation) instead.
- You want to manage access rules in the Metabase UI instead of maintaining a separate database for each group.
## Locked parameters
[Locked parameters](../embedding/guest-embedding.md#locked-parameters) filter an embedded dashboard or question before Metabase displays it. Your app passes the filter value in a signed JWT. The filter is invisible to the person viewing the embed, and they can't change it.
![Locked parameters](../embedding/images/locked-parameters.png)
Use locked parameters when:
- You want to restrict data without setting up Metabase accounts, user attributes, or permissions.
- You want to filter data without exposing the filter or its values to the people viewing the embed.
Don't use locked parameters when:
- You want account-level or role-based access control across your Metabase.
- You're working with sensitive data. Use a method that enforces access at the permissions or database level instead, like [row and column security](#row-and-column-security), [impersonation](#impersonation), or [database routing](#database-routing).
## Further reading
- [Permissions overview](./start.md)
- [Tenants](../embedding/tenants.md)
- [Row and column security](./row-and-column-security.md)
- [Impersonation](./impersonation.md)
- [Database routing](./database-routing.md)
- [Locked parameters for guest embeds](../embedding/guest-embedding.md#locked-parameters)

View File

@@ -9,6 +9,8 @@ summary: Route queries to different databases based on who's viewing them. Great
With database routing, an admin can build a question once using one database, and the question will run its query against a different database with the same schema depending on who is viewing the question.
![Database routing diagram](./images/database-routing-diagram.png)
Database routing is useful for:
- Managing modular and full app embedding setups where each customer has their own database with identical schemas.

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -10,6 +10,8 @@ This page covers the [View data](./data.md#view-data-permissions) permission lev
**Impersonation access** allows admins to "outsource" View data permissions to roles in your database. Admins can associate user attributes with database-defined roles and their privileges. If someone is in a group with their View data permission set to Impersonation, the person will be able to view and query data based on the privileges granted to the role specified by their user attribute.
![Connection impersonation](./images/connection-impersonation.png)
## Databases that support impersonation
For now, impersonation access is only available for the following databases:

View File

@@ -14,6 +14,8 @@ Row and column security lets you give granular permissions for different groups
You can use row and column security to set up [self-service analytics](https://www.metabase.com/learn/metabase-basics/embedding/multi-tenant-self-service-analytics), so that each of your customers can only view the rows that match their customer ID. For example, if you have an Accounts table with information about your customers, you can add permissions to the table so that each customer only sees the data relevant to them.
![Row and column security](./images/row-and-column-level-security.png)
> Row and column security was formerly called data sandboxing. It's the same feature, it just now has a more descriptive name.
## Row and column security examples

View File

@@ -28,6 +28,10 @@ Granting group access to different Metabase features.
Setting up row and column security to restrict access to rows and columns in tables.
## [Choosing a data isolation method](./data-isolation-methods.md)
Compare row and column security, impersonation, database routing, and locked parameters for multi-tenant setups.
## [Snippet folder permissions](./snippets.md)
Organize snippets into folders that require permissions to view.

View File

@@ -109,6 +109,8 @@ You can export a dashboard as a PDF. Click on the **Sharing** button, then selec
The PDF will only include screenshots of the charts as they are visible on the dashboard.
To get a PDF of a dashboard delivered on a schedule, [attach a PDF to a dashboard subscription](../dashboards/subscriptions.md#attach-a-pdf-of-the-dashboard).
### Exporting results of a dashboard card
To export the results of a particular card, hover over the dashboard card, click on the three dot menu (**...**), and select **Download results**.
@@ -128,7 +130,7 @@ If you don't see this option, you may not have [permissions to download results]
### Exporting results of a dashboard via dashboard subscriptions
You can use [dashboard subscriptions](../dashboards/subscriptions.md) to regularly export data from all questions on a dashboard, and include those results as an attachment.
You can use [dashboard subscriptions](../dashboards/subscriptions.md) to regularly export data from all questions on a dashboard, and include those results as an attachment. You can also [attach a PDF](../dashboards/subscriptions.md#attach-a-pdf-of-the-dashboard) of the entire dashboard.
## Remove Metabase branding from exports

View File

@@ -8,9 +8,9 @@ Metabase runs on the Java Virtual Machine (JVM), and depending on how it's confi
## Java version
Metabase should be run on Java version 21 (older versions are unsupported).
Metabase should be run on Java version 25 (older versions are unsupported).
When searching for versions of Java, always use the latest minor version of the major version you're choosing. E.g., when choosing between Java 21.0.1 and Java 21.0.4, choose the latest version (in this case, 21.0.4).
When searching for versions of Java, always use the latest minor version of the major version you're choosing. E.g., when choosing between Java 25.0.1 and Java 25.0.3, choose the latest version (in this case, 25.0.3).
We recommend running only one version of Java on a single server, because running more than one version of Java on a single server can cause application problems. If you need to run multiple applications that each require a different java version, consider using containers (as containers were meant to solve this problem). Otherwise, just make sure that you can run all your applications with a single Java version.

View File

@@ -604,6 +604,9 @@ The Topic column on the [Activity log](#activity-log) model takes one of:
- measure-create
- measure-delete
- measure-update
- mfa-disabled
- mfa-enrolled
- mfa-verification-failed
- notification-create
- notification-unsubscribe
- notification-unsubscribe-ex

View File

@@ -6,6 +6,7 @@ export type SecurityAdvisorySpec = {
remediation: string;
advisory_url?: string | null;
affected_versions: { min: string; fixed: string }[];
download_jar_urls?: { version: string; url: string }[];
matching_query?: Record<string, string> | null;
match_status: "unknown" | "active" | "resolved" | "not_affected" | "error";
published_at: string;

View File

@@ -177,13 +177,18 @@ export function visitMetric(id) {
* @param {number|string} dashboardIdOrAlias
* @param {Object} config
*/
export function visitDashboard(dashboardIdOrAlias, { params = {} } = {}) {
export function visitDashboard(
dashboardIdOrAlias,
{ params = {}, dashcardTimeout } = {},
) {
if (typeof dashboardIdOrAlias === "number") {
visitDashboardById(dashboardIdOrAlias, { params });
visitDashboardById(dashboardIdOrAlias, { params, dashcardTimeout });
}
if (typeof dashboardIdOrAlias === "string") {
cy.get(dashboardIdOrAlias).then((id) => visitDashboardById(id, { params }));
cy.get(dashboardIdOrAlias).then((id) =>
visitDashboardById(id, { params, dashcardTimeout }),
);
}
}
@@ -236,7 +241,12 @@ function visitDashboardById(dashboard_id, config) {
qs: config.params,
});
cy.wait(aliases);
// dashcardTimeout lets callers widen the per-query wait window for dashboards with
// many cards, whose tail queries stagger past cy.wait's 5s default under CPU load
cy.wait(
aliases,
config.dashcardTimeout ? { timeout: config.dashcardTimeout } : {},
);
} else {
// For a dashboard:
// - without questions (can be empty or markdown only) or

View File

@@ -48,6 +48,12 @@ export function restore(name = "default") {
resetWritableDb({ type: dbType });
}
cy.request({
method: "POST",
url: "/api/testing/reset-throttlers",
failOnStatusCode: false,
});
return cy.request("POST", `/api/testing/restore/${name}`);
}

View File

@@ -1,21 +1,14 @@
/**
* @param {object} segment
* @param {string} segment.name
* @param {TableId} segment.table_id
* @param {string|null} [segment.description]
* @param {object} segment.definition
*/
export const createSegment = ({
name,
table_id,
definition,
description = null,
}) => {
export const createSegment = ({ name, definition, description = null }) => {
cy.log(`Create a segment: ${name}`);
return cy.request("POST", "/api/segment", {
name,
description,
table_id,
definition,
});
};
@@ -23,21 +16,14 @@ export const createSegment = ({
/**
* @param {object} measure
* @param {string} measure.name
* @param {TableId} measure.table_id
* @param {string|null} [measure.description]
* @param {object} measure.definition
*/
export const createMeasure = ({
name,
table_id,
definition,
description = null,
}) => {
export const createMeasure = ({ name, definition, description = null }) => {
cy.log(`Create a measure: ${name}`);
return cy.request("POST", "/api/measure", {
name,
description,
table_id,
definition,
});
};
@@ -46,21 +32,19 @@ export const createMeasure = ({
* @param {object} measure
* @param {MeasureId} measure.id
* @param {string} [measure.name]
* @param {TableId} [measure.table_id]
* @param {string|null} [measure.description]
* @param {object} [measure.definition]
* @param {object} [options]
* @param {string|null} [options.revision_message]
*/
export const updateMeasure = (
{ id, name, table_id, definition, description = null },
{ id, name, definition, description = null },
{ revision_message = "Update measure" } = {},
) => {
cy.log(`Update a measure: ${id}`);
return cy.request("PUT", `/api/measure/${id}`, {
name,
description,
table_id,
definition,
revision_message,
});

View File

@@ -76,6 +76,7 @@ export function waitForSucceededTransformRuns() {
export function createMbqlTransform({
sourceTable,
sourceSchema,
targetTable,
targetSchema,
tagIds,
@@ -85,6 +86,7 @@ export function createMbqlTransform({
collectionId,
}: {
sourceTable: string;
sourceSchema?: string | null;
targetTable: string;
targetSchema: string | null;
tagIds?: TransformTagId[];
@@ -93,7 +95,11 @@ export function createMbqlTransform({
visitTransform?: boolean;
collectionId?: CollectionId | null;
}) {
return getTableId({ databaseId, name: sourceTable }).then((tableId) => {
return getTableId({
databaseId,
name: sourceTable,
schema: sourceSchema ?? undefined,
}).then((tableId) => {
return createTransform(
{
name,

View File

@@ -0,0 +1,352 @@
const { H } = cy;
import * as OTPAuth from "otpauth";
import { USERS } from "e2e/support/cypress_data";
import { NORMAL_USER_ID } from "e2e/support/cypress_sample_instance_data";
const { normal } = USERS;
const NEW_PASSWORD = "NewPassword2fa!123";
type MaildevEmail = { subject: string; html: string };
describe("scenarios > admin > settings > multi-factor authentication", () => {
beforeEach(() => {
H.restore();
H.clearInbox();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
cy.intercept("PUT", "/api/setting/mfa-enforcement").as("updateEnforcement");
cy.intercept("POST", "/api/ee/mfa/enroll").as("enroll");
});
it("admin can enable and disable 2FA in authentication settings", () => {
cy.visit("/admin/settings/authentication");
mfaSetting().scrollIntoView();
mfaSetting().findByText("Two-factor authentication").should("be.visible");
mfaToggle().should("not.be.checked").click();
cy.wait("@updateEnforcement");
mfaSetting()
.should("contain", "0 enrolled users")
.and("contain", "users without 2FA");
cy.log("Disable it again");
mfaToggle().should("be.checked").click();
cy.wait("@updateEnforcement");
mfaToggle().should("not.be.checked");
mfaSetting().should("not.contain", "enrolled");
});
it("user can set up 2FA in account settings and sign in with an authenticator code", () => {
let totpSecret = "";
enableMfa();
cy.log("User enrolls from account security settings");
cy.signInAsNormalUser();
cy.visit("/account/security");
cy.findByTestId("account-header")
.findByRole("tab", { name: "Security" })
.should("be.visible");
enrollViaUI().then((secret) => {
totpSecret = secret;
});
cy.findByRole("button", { name: "Disable" }).should("be.visible");
cy.findByRole("button", { name: "Generate recovery codes" }).should(
"be.visible",
);
cy.log("Signing in now requires an authenticator code");
signInWithPassword();
cy.findByTestId("login-page")
.findByText("Enter the 6-digit code from your authenticator app.")
.should("be.visible");
// The backend rejects a reused TOTP time step, so take the code for the
// next 30-second window — validation accepts one step of clock skew.
cy.then(() => generateTotpCode(totpSecret, Date.now() / 1000 + 30)).then(
(code) => {
cy.findByLabelText("Authenticator code").type(code);
},
);
cy.button("Verify").click();
cy.findByTestId("greeting-message").should("be.visible");
});
it("user can disable 2FA themselves and re-enroll", () => {
enableMfa();
enrollNormalUser().then(({ secret }) => {
cy.log("Disabling requires a fresh second factor, not just a password");
cy.visit("/account/security");
cy.findByRole("button", { name: "Disable" }).click();
H.modal().within(() => {
cy.findByText(
"Are you sure you want to disable two-factor authentication? Your account will be protected by your password only, and your recovery codes will stop working.",
).should("be.visible");
cy.findByLabelText(
"Confirm with an authenticator code or a recovery code",
).type(generateTotpCode(secret, Date.now() / 1000 + 30));
cy.button("Disable").click();
});
});
cy.findByRole("button", {
name: "Set up two-factor authentication",
}).should("be.enabled");
cy.log("Re-enroll from scratch with a new secret");
enrollViaUI();
cy.findByRole("button", { name: "Disable" }).should("be.visible");
});
it("recovery codes sign the user in once and regeneration invalidates the old set", () => {
enableMfa();
cy.intercept("POST", "/api/ee/mfa/recovery-codes").as("regenerate");
enrollNormalUser().then(({ secret, recoveryCodes }) => {
cy.log("Sign in with a recovery code instead of an authenticator code");
signInWithPassword();
cy.findByTestId("login-page")
.findByText("Use a recovery code instead")
.click();
cy.findByLabelText("Recovery code").type(recoveryCodes[0]);
cy.button("Verify").click();
cy.findByTestId("greeting-message").should("be.visible");
cy.log("Regenerate the recovery codes");
cy.visit("/account/security");
cy.findByRole("button", { name: "Generate recovery codes" }).click();
H.modal().within(() => {
cy.findByText(
"This will generate a new set of recovery codes and invalidate all of your old ones.",
).should("be.visible");
cy.findByLabelText(
"Confirm with an authenticator code or a recovery code",
).type(generateTotpCode(secret, Date.now() / 1000 + 30));
cy.button("Generate new codes").click();
cy.findByText("Your recovery codes").should("be.visible");
cy.button("Done").click();
});
cy.log("Old codes no longer work; new ones do");
cy.wait("@regenerate").then(({ response }) => {
const newCodes = response?.body.recovery_codes;
signInWithPassword();
cy.findByTestId("login-page")
.findByText("Use a recovery code instead")
.click();
cy.findByLabelText("Recovery code").type(recoveryCodes[1]);
cy.button("Verify").click();
cy.findByTestId("login-page")
.findByRole("alert")
.should("contain", "Invalid authentication code.");
cy.findByLabelText("Recovery code").clear().type(newCodes[0]);
// the submit button transiently reads "Failed" after the rejected
// attempt but stays clickable, so match either label
cy.button(/Verify|Failed/).click();
cy.findByTestId("greeting-message").should("be.visible");
});
});
});
it("an emailed one-time code works as a fallback second factor", () => {
enableMfa();
H.setupSMTP();
enrollNormalUser();
signInWithPassword();
cy.findByTestId("login-page")
.findByText("Enter the 6-digit code from your authenticator app.")
.should("be.visible");
cy.findByTestId("login-page").findByText("Email me a code").click();
cy.findByTestId("login-page")
.findByText("Code sent — check your email")
.should("be.visible");
H.getInbox().then(({ body: emails }: { body: MaildevEmail[] }) => {
const otpEmail = emails.find((email) =>
email.subject.includes("Your sign-in code"),
);
expect(otpEmail, "sign-in code email").to.exist;
const code = otpEmail?.html.match(/>\s*(\d{6})\s*</)?.[1];
expect(code, "6-digit code in the email body").to.be.a("string");
cy.findByLabelText("Authenticator code").type(String(code));
});
cy.button("Verify").click();
cy.findByTestId("greeting-message").should("be.visible");
});
it("resetting a forgotten password does not bypass the second factor", () => {
enableMfa();
H.setupSMTP();
enrollNormalUser().then(({ secret }) => {
cy.log("Request a reset link and set a new password");
cy.signOut();
cy.visit("/auth/forgot_password");
cy.findByLabelText("Email address").type(normal.email);
cy.button("Send password reset email").click();
cy.findByTestId("login-page")
.findByText(/If the email exists/)
.should("be.visible");
// the reset email is sent asynchronously and lands next to the "2FA
// enabled" notification from enrollment — wait for both to be there
H.getInbox(2).then(({ body: emails }: { body: MaildevEmail[] }) => {
const resetEmail = emails.find((email) =>
email.subject.includes("Password Reset"),
);
expect(resetEmail, "password reset email").to.exist;
cy.visit(getResetLink(String(resetEmail?.html)));
});
cy.findByLabelText("Create a password").type(NEW_PASSWORD);
cy.findByLabelText("Confirm your password").type(NEW_PASSWORD);
cy.button("Save new password").click();
cy.log("No session is minted — the new password still needs a code");
cy.url().should("contain", "/auth/login");
cy.findByLabelText("Email address").type(normal.email);
cy.findByLabelText("Password").type(NEW_PASSWORD);
cy.button("Sign in").click();
cy.findByTestId("login-page")
.findByText("Enter the 6-digit code from your authenticator app.")
.should("be.visible");
cy.then(() => generateTotpCode(secret, Date.now() / 1000 + 30)).then(
(code) => {
cy.findByLabelText("Authenticator code").type(code);
},
);
cy.button("Verify").click();
cy.findByTestId("greeting-message").should("be.visible");
});
});
it("an enrolled user is still challenged and can disable 2FA after the license lapses", () => {
enableMfa();
enrollNormalUser().then(({ secret, recoveryCodes }) => {
cy.log("Drop the premium token — the gate must fail closed");
cy.signInAsAdmin();
H.deleteToken();
signInWithPassword();
cy.findByTestId("login-page")
.findByText("Enter the 6-digit code from your authenticator app.")
.should("be.visible");
cy.then(() => generateTotpCode(secret, Date.now() / 1000 + 30)).then(
(code) => {
cy.findByLabelText("Authenticator code").type(code);
},
);
cy.button("Verify").click();
cy.findByTestId("greeting-message").should("be.visible");
cy.log("Managing the existing enrollment still works without a license");
cy.visit("/account/security");
cy.findByRole("button", { name: "Disable" }).click();
H.modal().within(() => {
cy.findByLabelText(
"Confirm with an authenticator code or a recovery code",
).type(recoveryCodes[0]);
cy.button("Disable").click();
});
cy.log("Without the feature there is no way back into setup");
cy.url().should("contain", "/account/profile");
cy.visit("/account/security");
cy.findByRole("button", {
name: "Set up two-factor authentication",
}).should("be.disabled");
});
});
it("admin can remove a user's enrollment to unlock them", () => {
enableMfa();
enrollNormalUser();
cy.log("Admin sees the enrollment and removes it (lockout escape hatch)");
cy.signInAsAdmin();
cy.visit("/admin/settings/authentication");
mfaSetting().scrollIntoView().should("contain", "1 enrolled user");
cy.request("POST", "/api/ee/mfa/admin/remove", {
user_id: NORMAL_USER_ID,
});
cy.log("After the reset the user signs in with just a password");
signInWithPassword();
cy.findByTestId("greeting-message").should("be.visible");
cy.url().should("not.contain", "/auth/login");
});
});
function mfaSetting() {
return cy.findByTestId("mfa-setting");
}
function mfaToggle() {
return mfaSetting().findByLabelText(/Enabled|Disabled/);
}
function enableMfa() {
return cy.request("PUT", "/api/setting/mfa-enforcement", {
value: "optional",
});
}
function enrollNormalUser() {
cy.signInAsNormalUser();
return cy
.request("POST", "/api/ee/mfa/enroll", { password: normal.password })
.then(({ body: { secret } }) =>
cy
.request("POST", "/api/ee/mfa/enroll/confirm", {
code: generateTotpCode(secret, Date.now() / 1000),
})
.then(({ body: { recovery_codes } }) => ({
secret,
recoveryCodes: recovery_codes,
})),
);
}
function enrollViaUI(): Cypress.Chainable<string> {
cy.findByRole("button", {
name: "Set up two-factor authentication",
}).click();
H.modal().within(() => {
cy.findByLabelText("Confirm your password to begin").type(normal.password);
cy.button("Continue").click();
});
return cy.wait("@enroll").then(({ response }) => {
const secret = response?.body.secret;
H.modal().within(() => {
cy.findByLabelText(
"Enter the 6-digit code from the authenticator app",
).type(generateTotpCode(secret, Date.now() / 1000));
cy.button("Set up authentication").click();
cy.findByText("Your recovery codes").should("be.visible");
cy.button("Done").click();
});
return cy.wrap(secret, { log: false });
});
}
function signInWithPassword() {
cy.signOut();
cy.visit("/auth/login");
cy.findByLabelText("Email address").type(normal.email);
cy.findByLabelText("Password").type(normal.password);
cy.button("Sign in").click();
}
function getResetLink(html: string) {
const [, anchor] = html.match(/<a (.*)>/) ?? [];
const [, href] = String(anchor).match(/href="([^"]+)"/) ?? [];
return String(href);
}
function generateTotpCode(secret: string, unixSeconds: number): string {
return new OTPAuth.TOTP({
secret: OTPAuth.Secret.fromBase32(secret),
algorithm: "SHA1",
digits: 6,
period: 30,
}).generate({ timestamp: unixSeconds * 1000 });
}

View File

@@ -218,4 +218,50 @@ describe("scenarios > admin > security center", { tags: "@EE" }, () => {
cy.findByTestId("sync-advisories").click();
cy.wait("@sync");
});
describe("per-version download link", () => {
const DOWNLOADABLE_ADVISORY = {
advisory_id: "TEST-DL",
severity: "critical",
title: "Critical bug with a downloadable fix",
description: "A critical issue shipping a per-version JAR fix.",
remediation: "Upgrade to the patched release",
affected_versions: [{ min: "0.58.0", fixed: "0.59.11" }],
download_jar_urls: [
{ version: "0.58.11", url: "https://downloads.example.com/58.jar" },
{ version: "0.59.11", url: "https://downloads.example.com/59.jar" },
],
match_status: "active",
published_at: "2026-03-24T00:00:00Z",
updated_at: "2026-03-24T00:00:00Z",
} as const;
it("stores and returns download_jar_urls through the real backend", () => {
H.seedSecurityAdvisories([DOWNLOADABLE_ADVISORY]);
cy.request("GET", "/api/ee/security-center").then(({ body }) => {
const advisory = body.advisories.find(
(a: { advisory_id: string }) => a.advisory_id === "TEST-DL",
);
expect(advisory.download_jar_urls).to.deep.equal([
{ version: "0.58.11", url: "https://downloads.example.com/58.jar" },
{ version: "0.59.11", url: "https://downloads.example.com/59.jar" },
]);
});
});
it("shows a single download button for the fix matching the instance major version", () => {
H.mockSessionProperty("version", { tag: "v0.59.3" });
H.seedSecurityAdvisories([DOWNLOADABLE_ADVISORY]);
cy.visit("/admin/security-center");
cy.findAllByTestId("advisory-card")
.first()
.within(() => {
cy.findByRole("link", { name: /Download v0\.59\.11/ })
.should("have.attr", "href", "https://downloads.example.com/59.jar")
.and("have.attr", "target", "_blank");
cy.findByText("Download v0.58.11").should("not.exist");
});
});
});
});

View File

@@ -674,7 +674,6 @@ describe("scenarios > admin > databases > sample database", () => {
H.createSegment({
name: "Small orders",
description: "All orders with a total under $100.",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],

View File

@@ -243,7 +243,6 @@ describe("issues 55617, 55618", () => {
cy.intercept("GET", "/api/segment").as("getSegments");
H.createSegment({
name: "My segment",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],

View File

@@ -101,7 +101,6 @@ describe("scenarios > admin > datamodel > segments", () => {
H.createSegment({
name: SEGMENT_NAME,
description: "All orders with a total under $100.",
table_id: ORDERS_ID,
definition: {
type: "query",
database: 1,
@@ -346,7 +345,6 @@ describe("scenarios > admin > datamodel > segments", () => {
H.createSegment({
name: "Foo",
description: "All orders with a total under $100.",
table_id: ORDERS_ID,
definition: {
type: "query",
database: 1,
@@ -403,7 +401,6 @@ describe("scenarios > admin > datamodel > segments", () => {
H.createSegment({
name: SEGMENT_IN_ORDERS,
description: "Hey oh",
table_id: ORDERS_ID,
definition: {
type: "query",
database: 1,
@@ -417,7 +414,6 @@ describe("scenarios > admin > datamodel > segments", () => {
H.createSegment({
name: SEGMENT_IN_PEOPLE,
description: "This is a segment in the PEOPLE table",
table_id: PEOPLE_ID,
definition: {
type: "query",
database: 1,

View File

@@ -655,6 +655,26 @@ describe("Remote Sync", () => {
.click();
H.collectionTable().findByText(UPDATED_REMOTE_QUESTION_NAME);
});
it("keeps the Embed sharing option available for a question in a read-only synced collection (metabase#72752)", () => {
H.copySyncedCollectionFixture();
H.commitToRepo();
// Enable static embedding instance-wide so the Embed option is offered.
H.updateSetting("enable-embedding-static", true);
H.configureGitAndPullChanges("read-only");
cy.visit("/");
H.navigationSidebar()
.findByRole("treeitem", { name: /Synced Collection/ })
.click();
H.collectionTable().findByText(REMOTE_QUESTION_NAME).click();
// The Embed option stays available on a read-only synced question; the
// Publish button inside the modal is disabled instead (unit-tested).
H.openSharingMenu();
H.sharingMenu().findByText("Embed").should("be.visible");
});
});
describe("shared tenant collections", () => {

View File

@@ -636,7 +636,6 @@ describe("issue 24922", () => {
const segmentDetails = {
name: "OrdersSegment",
description: "All orders with a total under $100.",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
@@ -1391,37 +1390,3 @@ describe("issue 53527", () => {
H.tableInteractive().findByText("ab").should("be.visible");
});
});
describe("issue 48562", () => {
const questionDetails = {
query: {
"source-table": ORDERS_ID,
expressions: {
CustomColumn: ["contains", ["field", 10000, null], "abc"],
},
filter: ["+", 1, ["segment", 10001]],
aggregation: [["metric", 10002]],
},
};
beforeEach(() => {
H.restore();
cy.signInAsNormalUser();
});
it("should not crash when referenced columns, segments, and metrics do not exist (metabase#48562)", () => {
H.createQuestion(questionDetails, { visitQuestion: true });
H.openNotebook();
H.getNotebookStep("expression").findByText("CustomColumn").click();
H.CustomExpressionEditor.value().should("contain", "[Unknown Field]");
H.expressionEditorWidget().button("Cancel").click();
H.getNotebookStep("filter").findByText("1 + [Unknown Segment]").click();
H.CustomExpressionEditor.value().should("contain", "[Unknown Segment]");
H.expressionEditorWidget().button("Cancel").click();
H.getNotebookStep("summarize").findByText("[Unknown Metric]").click();
H.CustomExpressionEditor.value().should("contain", "[Unknown Metric]");
});
});

View File

@@ -305,7 +305,6 @@ describe("issue 55300", () => {
H.createSegment({
name: "now",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
@@ -315,7 +314,6 @@ describe("issue 55300", () => {
H.createSegment({
name: "Count",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
@@ -895,64 +893,6 @@ describe("issue 56602", () => {
});
});
describe("issue 61010", () => {
const CUSTOM_COLUMN_NAME = "Foo";
const AGGREGATION_NAME = "New count";
beforeEach(() => {
H.restore();
cy.signInAsNormalUser();
H.createQuestion(
{
query: {
"source-table": ORDERS_ID,
expressions: {
[CUSTOM_COLUMN_NAME]: ["+", 1, 2],
},
aggregation: [
[
"aggregation-options",
["+", ["count"], 1],
{
name: AGGREGATION_NAME,
"display-name": AGGREGATION_NAME,
},
],
],
},
},
{ visitQuestion: true },
);
H.openNotebook();
});
it("should not be possible to reference a custom expression in itself (metabase#61010)", () => {
H.getNotebookStep("expression").findByText(CUSTOM_COLUMN_NAME).click();
H.CustomExpressionEditor.clear().type("[Fo");
H.CustomExpressionEditor.completions()
.findByText("Foo")
.should("not.exist");
H.CustomExpressionEditor.clear().type("[Foo]");
H.popover().findByText("Unknown column: Foo").should("be.visible");
});
it("should not be possible to reference an aggregation in itself(metabase#61010)", () => {
H.getNotebookStep("summarize").findByText(AGGREGATION_NAME).click();
H.CustomExpressionEditor.clear().type("[New cou");
H.CustomExpressionEditor.completions()
.findByText("New count")
.should("not.exist");
H.CustomExpressionEditor.clear().type("[New count]");
H.popover()
.findByText("Unknown Aggregation, Measure or Metric: New count")
.should("be.visible");
});
});
describe("issue 62987", () => {
beforeEach(() => {
H.restore();

View File

@@ -1665,3 +1665,37 @@ describe("issue 63416", () => {
});
});
});
describe("issue 76056", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.setActionsEnabledForDB(SAMPLE_DB_ID);
H.createDashboard().then(({ body: dashboard }) => {
H.visitDashboard(dashboard.id);
});
});
it("the dashcard actions panel should not have redundant horizontal space (metabase#76056)", () => {
H.editDashboard();
cy.button("Add action").click();
cy.button("Close").click();
H.showDashboardCardActions();
cy.findByTestId("dashboardcard-actions-panel")
.should("be.visible")
.then(($panel) => {
const panelRect = $panel[0].getBoundingClientRect();
const buttonRects = Array.from($panel[0].querySelectorAll("a")).map(
(button) => button.getBoundingClientRect(),
);
const maxRight = Math.max(...buttonRects.map((rect) => rect.right));
const minLeft = Math.min(...buttonRects.map((rect) => rect.left));
const contentWidth = maxRight - minLeft;
expect(panelRect.width).to.be.closeTo(contentWidth, 10);
});
});
});

View File

@@ -242,11 +242,13 @@ describe("Dashboard > Dashboard Questions", () => {
H.entityPickerModal().button("Move").click();
// we shouldn't be making 20 requests here
cy.wait(new Array(20).fill("@updateCard"));
// their staggered tail can land past cy.wait's default 5s under CPU load, so allow more time
cy.wait(new Array(20).fill("@updateCard"), { timeout: 30000 });
H.undoToast().findByText("Moved 20 questions");
H.visitDashboard(S.ORDERS_DASHBOARD_ID);
// 20+ dashcard queries stagger behind the concurrency limit; allow their tail past the 5s default
H.visitDashboard(S.ORDERS_DASHBOARD_ID, { dashcardTimeout: 30000 });
new Array(20).fill("slowbro").forEach((_, i) => {
H.dashboardCards().findByText(`Question ${i + 1}`);
@@ -269,7 +271,7 @@ describe("Dashboard > Dashboard Questions", () => {
cy.wait(["@updateCard", "@updateCard"]);
cy.findByTestId("error-boundary").should("not.exist");
H.visitDashboard(S.ORDERS_DASHBOARD_ID);
H.visitDashboard(S.ORDERS_DASHBOARD_ID, { dashcardTimeout: 30000 });
H.dashboardCards().findByText("Orders");
H.dashboardCards().findByText("Orders, Count");
});

View File

@@ -841,7 +841,6 @@ describe("issue 31697", () => {
const segmentDetails = {
name: "Orders segment",
description: "All orders with a total under $100.",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],

View File

@@ -404,7 +404,6 @@ describe(
cy.log("create segment on Products table");
H.createSegment({
name: "Expensive Products",
table_id: PRODUCTS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,
@@ -441,7 +440,6 @@ describe(
cy.log("create segment based on segment");
H.createSegment({
name: "High Value Recent Orders",
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,
@@ -475,7 +473,6 @@ describe(
cy.log("create Segment A");
H.createSegment({
name: "Segment A",
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,
@@ -488,7 +485,6 @@ describe(
cy.log("create Segment B that depends on A");
H.createSegment({
name: "Segment B",
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,
@@ -739,7 +735,6 @@ function createTestSegment(
H.createSegment({
name,
description,
table_id: tableId,
definition: {
type: "query",
database: SAMPLE_DB_ID,

View File

@@ -404,6 +404,11 @@ describe(
createTestTables();
createSourceQuestion("Graph question").as("question");
// The replacement modal fetches the dependents of the source table
// exactly once when it opens; wait for the async dependency backfill
// so the question's dependency row exists by then.
H.waitForBackfillComplete();
getTableId(SOURCE_TABLE).then((sourceTableId) => {
cy.visit(`/data-studio/dependencies?id=${sourceTableId}&type=table`);
});
@@ -821,6 +826,12 @@ function getTableId(tableName: string) {
}
function openReplacementModal(sourceTableLabel: string) {
// The modal fetches the dependents of the source table exactly once when it
// opens, and the card -> table dependency rows are written by an async
// backfill job. Wait for the backfill so the modal doesn't race it and show
// "Nothing uses this data source" for a table that does have dependents.
H.waitForBackfillComplete();
H.DataModel.visitDataStudio();
H.DataModel.TablePicker.getDatabase("Writable Postgres12").click();
@@ -1042,7 +1053,6 @@ function createHighAmountSegment() {
(amountFieldId) =>
H.createSegment({
name: "High amount",
table_id: sourceTableId,
definition: {
type: "query",
database: WRITABLE_DB_ID,
@@ -1062,7 +1072,6 @@ function createSourceTotalAmountMeasure() {
(amountFieldId) =>
H.createMeasure({
name: "Total amount",
table_id: sourceTableId,
definition: {
"source-table": sourceTableId,
aggregation: [["sum", ["field", amountFieldId, null]]],

View File

@@ -529,7 +529,6 @@ function createTestMeasure(
H.createMeasure({
name,
description,
table_id: tableId,
definition: {
type: "query",
database: SAMPLE_DB_ID,

View File

@@ -140,7 +140,6 @@ function createTestMeasure(opts: { name?: string; description?: string } = {}) {
H.createMeasure({
name,
description,
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,

View File

@@ -107,7 +107,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should create a measure based on a segment", () => {
H.createSegment({
name: "TotalSegment",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
filter: ["<", ["field", ORDERS.TOTAL, null], 100],
@@ -129,7 +128,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should create a measure based on another measure with an identity expression", () => {
H.createMeasure({
name: "TotalMeasure",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -151,7 +149,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should create a measure based on another measure", () => {
H.createMeasure({
name: "TotalMeasure",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -173,7 +170,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should create a measure based on another measure with an identity expression", () => {
H.createMeasure({
name: "TotalMeasure",
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -254,7 +250,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to create measures with filters like CountIf based on segments", () => {
H.createSegment({
name: "LargeTotal",
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,
@@ -280,7 +275,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to create measures with filters like based on segments that are nested", () => {
H.createSegment({
name: "LargeTotal",
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,
@@ -292,7 +286,6 @@ describe("scenarios > data studio > measures > queries", () => {
}).then(({ body: segment }) => {
H.createSegment({
name: "NestedSegment",
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,
@@ -323,7 +316,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to offset a measure in a query", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -349,7 +341,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to order by an aggregation using a measure directly", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -375,7 +366,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to order by an aggregation using a custom expression based on a measure", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -409,7 +399,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to use results of a measure in follow up stages", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -449,7 +438,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to join on a measure in a follow up stage", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -478,7 +466,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to join on a measure in a follow up stage with a custom expression", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -512,7 +499,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to rename a measure without breaking queries that reference it", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -533,7 +519,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to rename an aggregation expression based on a measure without breaking it", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -556,7 +541,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("changing the top-level aggregation expression in a measure might break queries that reference it in follow up stages", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
@@ -613,7 +597,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to use a measure in a pivot table", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -660,7 +643,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to use measure results in a saved question as source for a follow up question", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -714,7 +696,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible to use measure results in a saved question as source for a follow up model", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
@@ -769,7 +750,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible x-ray a question containing a measure", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
@@ -803,7 +783,6 @@ describe("scenarios > data studio > measures > queries", () => {
it("should be possible x-ray a model containing a measure", () => {
H.createMeasure({
name: MEASURE_NAME,
table_id: ORDERS_ID,
definition: {
"source-table": ORDERS_ID,
aggregation: [["count"]],

View File

@@ -144,7 +144,6 @@ function createTestSegment(opts: { name?: string; description?: string } = {}) {
H.createSegment({
name,
description,
table_id: ORDERS_ID,
definition: {
type: "query",
database: SAMPLE_DB_ID,

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